fix: preserve collection timestamps during catalog edits

This commit is contained in:
2026-10-17 00:13:27 +08:00
parent ab0e5e3a2f
commit d714c87d31
11 changed files with 156 additions and 10 deletions
+1 -1
View File
@@ -107,7 +107,7 @@ Vite 通常使用 http://localhost:5173。
前端包括问答、知识库管理、来源与更新、实验评估四个页面。实验评估页面没有虚构数据;所有页面明确标记当前功能状态。
注意:当前已实现本地 Embedding、文本/向量快照、精确向量及 RRF 混合检索;`/api/chat` 仍是占位接口,生成模型实际调用暂缓,网络搜索、后台更新任务和真实领域评测尚未实现。管理接口由服务端认证与角色校验;审核表单的核验人仍是人工输入字段。已完成隔离环境的浏览器 Cookie/CSRF、角色与真实检索核心流程验收,见 [浏览器验收记录](docs/BROWSER_ACCEPTANCE.md);完整生产部署、Docker 数据库与 HTTPS 验收仍待完成。`.env` 不要提交到 Git。
注意:当前已实现本地 Embedding、文本/向量快照、精确向量及 RRF 混合检索;`/api/chat` 仍是占位接口,生成模型实际调用暂缓,网络搜索、后台更新任务和真实领域评测尚未实现。管理接口由服务端认证与角色校验;审核表单的核验人仍是人工输入字段。已完成隔离环境的浏览器 Cookie/CSRF、角色与真实检索核心流程验收,见 [浏览器验收记录](docs/BROWSER_ACCEPTANCE.md) 与 [导出/采集时间复核](docs/EXPORT_BROWSER_ACCEPTANCE.md);完整生产部署、Docker 数据库与 HTTPS 验收仍待完成。`.env` 不要提交到 Git。
### 后端测试和迁移回滚
+59
View File
@@ -0,0 +1,59 @@
# 2026-10-17 导出与采集时间浏览器验收
本轮基于已合入的检索导出、导出工作台和资料生命周期页面,在隔离 PostgreSQL/pgvector 与默认生命周期 FastAPI、Vite、真实浏览器之间联调。全部账号、资料和查询均为合成测试;没有加载 Embedding、调用生成模型或产生领域实验结论。
## 基线与准备
- 旧 AGit [PR #33](https://gitea.kronecker.cc/saint/border-red-post-nalati/pulls/33) 关闭未合并,其普通分支通过真正 merged 的 [PR #34](https://gitea.kronecker.cc/saint/border-red-post-nalati/pulls/34) 合入 dev,保留原实现并增加下载响应的显式 `Cache-Control: no-store`。原 head、替代 head 和 merge 均已核对为最新 dev 祖先。
- [PR #36](https://gitea.kronecker.cc/saint/border-red-post-nalati/pulls/36) 的导出页面和 [PR #38](https://gitea.kronecker.cc/saint/border-red-post-nalati/pulls/38) 的来源生命周期页面也已合入。从最新 dev 创建本阶段分支,没有叠加待审功能或合入 main。
- 独立 HTTP 初始化合成来源、带微秒采集时间的关联文档、已审核知识和文本发布,并实际执行 22 条条目/chunk 关键词检索,准备其中一条的兼容请求上下文。这些是测试准备,不能称为浏览器录入或向量推理。
## 浏览器已实际验证的路径
| 路径 | 结果 |
|---|---|
| 匿名访问导出页 | 跳转登录并保留回跳路径;管理员登录后回到导出页 |
| 历史分页 | 显示 22 条,第一页 20 条、第二页 2 条;两端翻页按钮正确禁用 |
| 跨页选择 | 第一页选择一条 chunk 记录,第二页选择一条条目记录,仍保留两条选择 |
| 可选上下文 | 勾选关联 RAG 准备上下文后触发真实浏览器文件下载 |
| 下载文件核对 | 文件实际落盘;manifest 为 2 条检索和 1 条上下文;记录区 SHA-256 与页面显示一致,每个 payload 与原保存快照一致 |
| 文档时区缺陷复现 | 原采集时间为 `2026-10-16T12:34:56.789123+08:00`,仅修改备注却变成 `2026-10-16T04:34:00+08:00` |
| 修正后时间回填 | 编辑页显示当地时钟的 `12:34:56.789`,而非 UTC 时钟的 `04:34`;新建表单采集时间留空,要求明确填写 |
| 修正后仅修改备注 | 页面保存成功;独立 API 读取确认原带微秒的时间字符串完全保留,文档版本递增 |
| 文档历史 | 页面展开最新版本快照,可看到版本 4 及原 `12:34:56.789123+08:00` 采集时间 |
浏览器实际下载:
![跨页选择并下载](assets/export-acceptance/export-download.jpg)
备注修改后的历史快照:
![采集时间保留原微秒值](assets/export-acceptance/document-collected-at.jpg)
## 修正内容
原表单把 `toISOString()` 的 UTC 时钟直接填入 `datetime-local`,提交时又按本地时区解析,造成时区偏移;截取到分钟还会丢失秒和更高精度。
新增日期转换函数:回填使用本地时钟,支持秒/毫秒输入;若管理员没有修改时间,直接保留服务端原时间字符串,避免丢失微秒或在夏令时重叠时刻改变实际瞬间。明确修改时间时才转换为带时区的 ISO 时间。新建文档不再预填当前时间作为采集事实,仍由必填校验要求输入。 最新 dev 新合入的知识编辑页使用了相同的 UTC 截取方式,本轮同步复用此转换函数,保留其无采集时间时的空值语义;该知识页面的修改通过共享函数回归和构建验证,没有冒充新的浏览器操作。
为复核修正效果,缺陷复现后只在隔离合成数据上通过独立 HTTP 恢复原采集时间,再重新加载页面执行仅改备注的浏览器验证。该恢复属于测试基线准备,没有冒充浏览器操作;不自动修复或猜测既有业务资料的真实采集时间。
## 独立 HTTP 交叉核对
以下和浏览器操作分开记录:
- 文档修改后关联知识回到 pending,旧文本发布的检索返回 409;没有继续使用旧审核依据。
- 被活动知识/文档引用的文档、来源删除返回 409;过期版本修改返回 409。
- 单独可丢弃来源/文档经过修改和软删除后,正文详情隐藏,删除历史快照保留。
- 来源与文档修订后再次通过 HTTP 导出原选择,记录区原始字节与首次浏览器下载完全一致;manifest 的导出时间/编号允许变化,生成用量/费用仍未采集。
- 下载响应 `Cache-Control: no-store`;普通用户直接请求导出返回 403。
## 验证命令和边界
- `npm run test:datetime`:**6 passed**,覆盖中国时区、UTC、负偏移跨日、夏令时重叠、显式修改、微秒与空值;这些是 Node 时区单元测试,不等同于多浏览器验收。
- `npm run test:session`:**3 passed**;`npm run build`(含类型检查):通过。
- `pytest -q backend/tests/test_exports.py backend/tests/test_catalog.py`:**49 passed**;`ruff check backend`、`ruff format --check backend`:通过。本轮未把此前全量 254 项结果当作本阶段重新执行的结果。
- 原生删除确认框使本次浏览器控制无法继续完成确认;该浏览器删除路径未验收。相关删除保护、软删除与历史保留仅由上述独立 HTTP 和后端专项覆盖,仍需人工复核确认交互。没有据此放宽删除保护或修改确认机制。
- Docker、生产 HTTPS、多浏览器、浏览器改密、领域资料/问题集及生成模型实际调用仍不在本轮已完成范围。
复现时使用单独数据库或隔离 Schema、合成账号和明确的采集时间,按上述步骤比较下载原始字节及修改前后时间;环境、密码、原始 JSONL、数据库、日志和本机路径不入库。验证结束后关闭测试服务并删除本轮隔离 Schema。截图只保留合成数据,导出下载文件和原始检查记录保存在 Git 元数据目录。
+1 -1
View File
@@ -88,7 +88,7 @@ rows = [json.loads(line) for line in records.rstrip(b"\n").split(b"\n")]
assert len(rows) == sum(manifest["counts"].values())
```
前端调用层 `services/exports.ts` 的 `exportRetrievalHistory` 返回 Blob、受限文件名和记录摘要,沿用同一 Cookie/CSRF、401 撤销及错误处理,不放入 localStorage。此阶段没有新增下载页面,页面入口由后续前端集成;调用层测试是模拟网络的契约测试,不冒充浏览器下载验收。
前端调用层 `services/exports.ts` 的 `exportRetrievalHistory` 返回 Blob、受限文件名和记录摘要,沿用同一 Cookie/CSRF、401 撤销及错误处理,不放入 localStorage。下载页面已通过 PR #36 合入,可从管理员工作台进入。真实浏览器跨页选择、可选上下文、落盘文件与摘要核对见 [导出浏览器验收](EXPORT_BROWSER_ACCEPTANCE.md);调用层模拟网络测试与真实浏览器路径分别记录。
## 验证与撤回
+9
View File
@@ -107,3 +107,12 @@
- 一个 SQL 语句读取历史快照,保留旧版本而不重新检索;导出前完整校验,最多100条检索/100条上下文/8MiB,不静默截断,不返回部分文件。不新增数据库迁移或环境配置。
- 前端仅增加下载类型与调用层,复用 Cookie/CSRF/错误处理,不新增页面;真实浏览器下载与实验展示留给后续集成。完整字段、摘要核对、复现与回滚见 [RETRIEVAL_EXPORT_API.md](RETRIEVAL_EXPORT_API.md)。
- 专项测试 **30 passed**,包括真实 PostgreSQL 并发快照和 HTTP 重启导出;完整回归 **254 passed, 1 skipped**(skip 为须显式启用的真实 Embedding 专项),`ruff check` 与 `ruff format --check` 通过。前端会话下载契约 **3 passed**,类型与生产构建通过。测试使用合成资料与坐标 fixture,未重跑模型推理或调用生成模型。
## 2026-10-17 T05/T10:导出与采集时间浏览器复核
- 导出原实现及显式禁用缓存修正已通过替代 PR #34 真正合入 dev,原 #33 关闭未合并;已核对原 head、当前普通分支 head 与 merge 均包含在最新 dev。导出工作台 #36 与来源生命周期 #38 已合入,再创建唯一阶段分支 `test/backend-export-acceptance`。
- 真实浏览器完成匿名回跳/管理员登录、22条历史两页跨页选择、关联准备上下文与实际 JSONL 下载;落盘文件为2条检索/1条上下文,记录区摘要与页面一致,payload 与保存的原始快照一致。本轮没有模型推理。
- 发现并修正仅改文档备注时采集时间偏移8小时且截断到分钟的问题;回填使用本地时钟,未改时间保留服务端原微秒值,新建不猜测采集时间。最新合入的知识编辑页复用同一修正。文档浏览器保存与历史快照、独立API均复核通过;知识页仅共享函数测试和构建验证。
- 前端时区6项、会话3项测试与构建通过;后端导出/目录专项49项和ruff检查/格式检查通过。来源/文档版本、引用删除保护、软删除历史及修订后原导出字节不变均经独立HTTP核对。详见 [EXPORT_BROWSER_ACCEPTANCE.md](EXPORT_BROWSER_ACCEPTANCE.md)。
- 浏览器原生删除确认交互受工具限制仍待人工复核,不把独立HTTP删除验证当作浏览器结果;Docker/HTTPS/多浏览器/浏览器改密、领域资料与生成调用继续保留待办。
Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

+2 -1
View File
@@ -7,7 +7,8 @@
"dev": "vite",
"build": "vue-tsc -b && vite build",
"preview": "vite preview",
"test:session": "node --test tests/session.test.mjs"
"test:session": "node --test tests/session.test.mjs",
"test:datetime": "node --test tests/datetime.test.mjs"
},
"dependencies": {
"lucide-vue-next": "^0.468.0",
+16
View File
@@ -0,0 +1,16 @@
// datetime-local contains wall-clock time; it must not be filled with UTC clock digits.
export function localDateTimeInput(value: string): string {
const date = new Date(value)
const shifted = new Date(date.getTime() - date.getTimezoneOffset() * 60_000)
const [seconds, fraction] = shifted.toISOString().slice(0, -1).split('.')
const significantFraction = fraction.replace(/0+$/, '')
if (significantFraction) return `${seconds}.${significantFraction}`
return seconds.endsWith(':00') ? seconds.slice(0, -3) : seconds
}
export function collectedAtFromInput(value: string, original?: string): string {
// Preserve server precision (including microseconds) and ambiguous DST instants
// when the administrator only changes other document fields.
if (original && (value === localDateTimeInput(original) || new Date(value).getTime() === new Date(original).getTime())) return original
return new Date(value).toISOString()
}
@@ -1,20 +1,21 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { createDocument, deleteDocument, getDocumentHistory, getDocuments, getSources, updateDocument, type AuditItem, type DocumentItem, type SourceItem } from '../api'
import { collectedAtFromInput, localDateTimeInput } from '../utils/datetime'
type DocumentForm={source_id:number;title:string;category:string;content:string;collected_at:string;notes:string}
const blankForm=():DocumentForm=>({source_id:0,title:'',category:'未分类',content:'',collected_at:new Date().toISOString().slice(0,16),notes:''})
const blankForm=():DocumentForm=>({source_id:0,title:'',category:'未分类',content:'',collected_at:'',notes:''})
const documents=ref<DocumentItem[]>([]), sources=ref<SourceItem[]>([]), query=ref(''), page=ref(1), total=ref(0), loading=ref(true), saving=ref(false), error=ref(''), notice=ref('')
const form=ref<DocumentForm>(blankForm()), editing=ref<DocumentItem>(), historyFor=ref<DocumentItem>(), historyItems=ref<AuditItem[]>([]), historyLoading=ref(false)
function message(cause:unknown,fallback:string){return cause instanceof Error?cause.message:fallback}
function localTime(value:string){return new Date(value).toLocaleString()}
async function refresh(next=page.value){loading.value=true;error.value='';try{const result=await getDocuments(query.value,next);documents.value=result.items;total.value=result.total;page.value=result.page;if(!sources.value.length)sources.value=(await getSources()).items}catch(cause){error.value=message(cause,'无法读取文档')}finally{loading.value=false}}
function beginEdit(doc:DocumentItem){editing.value=doc;form.value={source_id:doc.source_id,title:doc.title,category:doc.category,content:doc.content,collected_at:new Date(doc.collected_at).toISOString().slice(0,16),notes:doc.notes};notice.value=''}
function beginEdit(doc:DocumentItem){editing.value=doc;form.value={source_id:doc.source_id,title:doc.title,category:doc.category,content:doc.content,collected_at:localDateTimeInput(doc.collected_at),notes:doc.notes};notice.value=''}
function cancelEdit(){editing.value=undefined;form.value=blankForm()}
function valid(){if(!form.value.source_id||!form.value.title.trim()||!form.value.content.trim()||!form.value.collected_at){error.value='请填写来源、标题、正文和采集时间。';return false}return true}
async function submit(){error.value='';notice.value='';if(!valid())return;saving.value=true;const isEditing=Boolean(editing.value);try{const common={title:form.value.title.trim(),category:form.value.category.trim(),content:form.value.content.trim(),collected_at:new Date(form.value.collected_at).toISOString(),notes:form.value.notes.trim()};if(editing.value){await updateDocument(editing.value.id,{expected_version:editing.value.version,...common});notice.value='文档已更新;关联知识会由后端重新置为待审核。'}else{await createDocument({source_id:form.value.source_id,...common});notice.value='文档已创建,可在后续知识条目中关联并审核。'}cancelEdit();await refresh(isEditing?page.value:1)}catch(cause){error.value=message(cause,isEditing?'更新文档失败':'创建文档失败')}finally{saving.value=false}}
async function submit(){error.value='';notice.value='';if(!valid())return;saving.value=true;const isEditing=Boolean(editing.value);try{const common={title:form.value.title.trim(),category:form.value.category.trim(),content:form.value.content.trim(),collected_at:collectedAtFromInput(form.value.collected_at,editing.value?.collected_at),notes:form.value.notes.trim()};if(editing.value){await updateDocument(editing.value.id,{expected_version:editing.value.version,...common});notice.value='文档已更新;关联知识会由后端重新置为待审核。'}else{await createDocument({source_id:form.value.source_id,...common});notice.value='文档已创建,可在后续知识条目中关联并审核。'}cancelEdit();await refresh(isEditing?page.value:1)}catch(cause){error.value=message(cause,isEditing?'更新文档失败':'创建文档失败')}finally{saving.value=false}}
async function remove(doc:DocumentItem){if(!window.confirm(`确定删除“${doc.title}”吗?若仍被知识条目引用,后端会拒绝删除。`))return;error.value='';notice.value='';try{await deleteDocument(doc.id,doc.version);notice.value='文档已软删除,历史记录仍可追溯。';if(editing.value?.id===doc.id)cancelEdit();await refresh(documents.value.length===1&&page.value>1?page.value-1:page.value)}catch(cause){error.value=message(cause,'删除文档失败')}}
async function showHistory(doc:DocumentItem){historyFor.value=doc;historyLoading.value=true;error.value='';try{historyItems.value=(await getDocumentHistory(doc.id)).items}catch(cause){error.value=message(cause,'无法读取文档历史');historyFor.value=undefined}finally{historyLoading.value=false}}
onMounted(()=>refresh(1))
</script>
<template><main><RouterLink to="/admin">返回管理员工作台</RouterLink><h1>文档管理</h1><p>文档必须关联已有来源;创建、编辑、删除和历史记录均调用管理员受保护的 FastAPI 接口。</p><p v-if="error" role="alert">{{error}}</p><p v-if="notice" role="status">{{notice}}</p><form @submit.prevent="submit"><h2>{{editing?'编辑文档':'新增文档'}}</h2><label>来源<select v-model.number="form.source_id" required :disabled="Boolean(editing)"><option :value="0" disabled>请选择来源</option><option v-for="source in sources" :key="source.id" :value="source.id">{{source.name}}</option></select></label><p v-if="editing">来源归属不可修改;如需变更来源,请新建文档。</p><label>标题<input v-model.trim="form.title" required maxlength="250"/></label><label>分类<input v-model.trim="form.category" required maxlength="80"/></label><label>采集时间<input v-model="form.collected_at" type="datetime-local" required/></label><label>正文<textarea v-model.trim="form.content" required maxlength="100000" rows="6"/></label><label>备注<textarea v-model.trim="form.notes" maxlength="10000" rows="2"/></label><button :disabled="saving">{{saving?(editing?'更新中…':'保存中…'):(editing?'保存修改':'保存文档')}}</button><button v-if="editing" type="button" :disabled="saving" @click="cancelEdit">取消编辑</button></form><p v-if="loading">正在读取文档…</p><template v-else><label>搜索<input v-model="query" @keyup.enter="refresh(1)"/></label><button @click="refresh(1)">查询</button><table><thead><tr><th>标题</th><th>分类</th><th>来源 ID</th><th>采集时间</th><th>操作</th></tr></thead><tbody><tr v-for="doc in documents" :key="doc.id"><td><b>{{doc.title}}</b><p>{{doc.content.slice(0,120)}}</p></td><td>{{doc.category}}</td><td>{{doc.source_id}}</td><td>{{localTime(doc.collected_at)}}</td><td><button @click="beginEdit(doc)">编辑</button><button @click="showHistory(doc)">历史</button><button @click="remove(doc)">删除</button></td></tr><tr v-if="!documents.length"><td colspan="5">暂无文档。</td></tr></tbody></table><div><button :disabled="page===1" @click="refresh(page-1)">上一页</button><span>第 {{page}} 页,共 {{total}} 条</span><button :disabled="page*20>=total" @click="refresh(page+1)">下一页</button></div></template><section v-if="historyFor"><h2>“{{historyFor.title}}”的历史记录</h2><button @click="historyFor=undefined">关闭历史</button><p v-if="historyLoading">正在读取历史…</p><table v-else><thead><tr><th>操作</th><th>时间</th><th>操作者</th><th>版本快照</th></tr></thead><tbody><tr v-for="item in historyItems" :key="item.id"><td>{{item.action}}</td><td>{{localTime(item.created_at)}}</td><td>{{item.actor||'系统'}}</td><td><details><summary>查看快照</summary><pre>{{JSON.stringify(item.snapshot,null,2)}}</pre></details></td></tr><tr v-if="!historyItems.length"><td colspan="4">暂无历史记录。</td></tr></tbody></table></section></main></template>
<template><main><RouterLink to="/admin">返回管理员工作台</RouterLink><h1>文档管理</h1><p>文档必须关联已有来源;创建、编辑、删除和历史记录均调用管理员受保护的 FastAPI 接口。</p><p v-if="error" role="alert">{{error}}</p><p v-if="notice" role="status">{{notice}}</p><form @submit.prevent="submit"><h2>{{editing?'编辑文档':'新增文档'}}</h2><label>来源<select v-model.number="form.source_id" required :disabled="Boolean(editing)"><option :value="0" disabled>请选择来源</option><option v-for="source in sources" :key="source.id" :value="source.id">{{source.name}}</option></select></label><p v-if="editing">来源归属不可修改;如需变更来源,请新建文档。</p><label>标题<input v-model.trim="form.title" required maxlength="250"/></label><label>分类<input v-model.trim="form.category" required maxlength="80"/></label><label>采集时间<input v-model="form.collected_at" type="datetime-local" step="any" required/></label><label>正文<textarea v-model.trim="form.content" required maxlength="100000" rows="6"/></label><label>备注<textarea v-model.trim="form.notes" maxlength="10000" rows="2"/></label><button :disabled="saving">{{saving?(editing?'更新中…':'保存中…'):(editing?'保存修改':'保存文档')}}</button><button v-if="editing" type="button" :disabled="saving" @click="cancelEdit">取消编辑</button></form><p v-if="loading">正在读取文档…</p><template v-else><label>搜索<input v-model="query" @keyup.enter="refresh(1)"/></label><button @click="refresh(1)">查询</button><table><thead><tr><th>标题</th><th>分类</th><th>来源 ID</th><th>采集时间</th><th>操作</th></tr></thead><tbody><tr v-for="doc in documents" :key="doc.id"><td><b>{{doc.title}}</b><p>{{doc.content.slice(0,120)}}</p></td><td>{{doc.category}}</td><td>{{doc.source_id}}</td><td>{{localTime(doc.collected_at)}}</td><td><button @click="beginEdit(doc)">编辑</button><button @click="showHistory(doc)">历史</button><button @click="remove(doc)">删除</button></td></tr><tr v-if="!documents.length"><td colspan="5">暂无文档。</td></tr></tbody></table><div><button :disabled="page===1" @click="refresh(page-1)">上一页</button><span>第 {{page}} 页,共 {{total}} 条</span><button :disabled="page*20>=total" @click="refresh(page+1)">下一页</button></div></template><section v-if="historyFor"><h2>“{{historyFor.title}}”的历史记录</h2><button @click="historyFor=undefined">关闭历史</button><p v-if="historyLoading">正在读取历史…</p><table v-else><thead><tr><th>操作</th><th>时间</th><th>操作者</th><th>版本快照</th></tr></thead><tbody><tr v-for="item in historyItems" :key="item.id"><td>{{item.action}}</td><td>{{localTime(item.created_at)}}</td><td>{{item.actor||'系统'}}</td><td><details><summary>查看快照</summary><pre>{{JSON.stringify(item.snapshot,null,2)}}</pre></details></td></tr><tr v-if="!historyItems.length"><td colspan="4">暂无历史记录。</td></tr></tbody></table></section></main></template>
@@ -1,6 +1,7 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { deleteKnowledge, getKnowledgeHistory, getKnowledgePage, updateKnowledge, type AuditItem, type KnowledgeItem, type VerificationStatus } from '../api'
import { collectedAtFromInput, localDateTimeInput } from '../utils/datetime'
type Form={title:string;category:string;content:string;source_url:string;collected_at:string}
const items=ref<KnowledgeItem[]>([]), selected=ref<KnowledgeItem>(), historyFor=ref<KnowledgeItem>(), historyItems=ref<AuditItem[]>([])
const query=ref(''), category=ref(''), status=ref<VerificationStatus|''>(''), page=ref(1), total=ref(0), loading=ref(true), saving=ref(false), historyLoading=ref(false), error=ref(''), notice=ref('')
@@ -8,11 +9,11 @@ const form=ref<Form>({title:'',category:'',content:'',source_url:'',collected_at
function failure(cause:unknown,fallback:string){return cause instanceof Error?cause.message:fallback}
function validUrl(value:string){if(!value)return true;try{const url=new URL(value);return url.protocol==='http:'||url.protocol==='https:'}catch{return false}}
async function refresh(next=page.value){loading.value=true;error.value='';try{const result=await getKnowledgePage({q:query.value,category:category.value,verification_status:status.value||undefined,page:next});items.value=result.items;page.value=result.page;total.value=result.total}catch(cause){error.value=failure(cause,'无法读取知识条目')}finally{loading.value=false}}
function edit(item:KnowledgeItem){selected.value=item;form.value={title:item.title,category:item.category,content:item.content,source_url:item.source_url??'',collected_at:item.collected_at?new Date(item.collected_at).toISOString().slice(0,16):''};error.value='';notice.value=''}
function edit(item:KnowledgeItem){selected.value=item;form.value={title:item.title,category:item.category,content:item.content,source_url:item.source_url??'',collected_at:item.collected_at?localDateTimeInput(item.collected_at):''};error.value='';notice.value=''}
function cancel(){selected.value=undefined}
async function save(){if(!selected.value)return;error.value='';notice.value='';if(!form.value.title.trim()||!form.value.category.trim()||!form.value.content.trim()){error.value='标题、分类和正文不能为空。';return}if(!validUrl(form.value.source_url.trim())){error.value='来源网址必须是有效的 http(s) 地址。';return}saving.value=true;try{const payload={expected_version:selected.value.version,title:form.value.title.trim(),category:form.value.category.trim(),content:form.value.content.trim(),source_url:form.value.source_url.trim()||null,...(form.value.collected_at?{collected_at:new Date(form.value.collected_at).toISOString()}: {})};await updateKnowledge(selected.value.id,payload);notice.value='知识条目已更新,后端已将其重新置为待审核。';cancel();await refresh()}catch(cause){error.value=failure(cause,'更新知识条目失败')}finally{saving.value=false}}
async function save(){if(!selected.value)return;error.value='';notice.value='';if(!form.value.title.trim()||!form.value.category.trim()||!form.value.content.trim()){error.value='标题、分类和正文不能为空。';return}if(!validUrl(form.value.source_url.trim())){error.value='来源网址必须是有效的 http(s) 地址。';return}saving.value=true;try{const payload={expected_version:selected.value.version,title:form.value.title.trim(),category:form.value.category.trim(),content:form.value.content.trim(),source_url:form.value.source_url.trim()||null,...(form.value.collected_at?{collected_at:collectedAtFromInput(form.value.collected_at,selected.value.collected_at??undefined)}: {})};await updateKnowledge(selected.value.id,payload);notice.value='知识条目已更新,后端已将其重新置为待审核。';cancel();await refresh()}catch(cause){error.value=failure(cause,'更新知识条目失败')}finally{saving.value=false}}
async function remove(item:KnowledgeItem){if(!window.confirm(`确定删除“${item.title}”吗?该操作为软删除,历史记录会保留。`))return;error.value='';notice.value='';try{await deleteKnowledge(item.id,item.version);notice.value='知识条目已软删除。';if(selected.value?.id===item.id)cancel();await refresh(items.value.length===1&&page.value>1?page.value-1:page.value)}catch(cause){error.value=failure(cause,'删除知识条目失败')}}
async function showHistory(item:KnowledgeItem){historyFor.value=item;historyLoading.value=true;error.value='';try{historyItems.value=(await getKnowledgeHistory(item.id)).items}catch(cause){error.value=failure(cause,'无法读取知识历史');historyFor.value=undefined}finally{historyLoading.value=false}}
onMounted(()=>refresh(1))
</script>
<template><main><RouterLink to="/admin">返回管理员工作台</RouterLink><h1>知识管理</h1><p>列表、编辑、删除和历史均直接调用管理员受保护接口;编辑后条目会由后端重新置为待审核。</p><p v-if="error" role="alert">{{error}}</p><p v-if="notice" role="status">{{notice}}</p><form @submit.prevent="refresh(1)"><label>关键词<input v-model="query"/></label><label>分类<input v-model="category"/></label><label>状态<select v-model="status"><option value="">全部</option><option value="pending">待审核</option><option value="verified">已核验</option><option value="rejected">已驳回</option></select></label><button>筛选</button></form><p v-if="loading">正在读取知识条目…</p><template v-else><table><thead><tr><th>标题</th><th>分类</th><th>状态</th><th>来源</th><th>操作</th></tr></thead><tbody><tr v-for="item in items" :key="item.id"><td><b>{{item.title}}</b><p>{{item.content.slice(0,120)}}</p></td><td>{{item.category}}</td><td>{{item.verification_status}}</td><td><a v-if="item.source_url" :href="item.source_url" target="_blank" rel="noreferrer">打开来源</a><span v-else>未填写</span></td><td><button @click="edit(item)">编辑</button><button @click="showHistory(item)">历史</button><button @click="remove(item)">删除</button></td></tr><tr v-if="!items.length"><td colspan="5">暂无符合条件的知识条目。</td></tr></tbody></table><div><button :disabled="page===1" @click="refresh(page-1)">上一页</button><span>第 {{page}} 页,共 {{total}} 条</span><button :disabled="page*20>=total" @click="refresh(page+1)">下一页</button></div></template><form v-if="selected" @submit.prevent="save"><h2>编辑:{{selected.title}}</h2><label>标题<input v-model.trim="form.title" required maxlength="250"/></label><label>分类<input v-model.trim="form.category" required maxlength="80"/></label><label>正文<textarea v-model.trim="form.content" required maxlength="100000" rows="6"/></label><label>来源网址<input v-model.trim="form.source_url" type="url" maxlength="1000" placeholder="https://..."/></label><label>采集时间<input v-model="form.collected_at" type="datetime-local"/></label><button type="button" :disabled="saving" @click="cancel">取消</button><button :disabled="saving">{{saving?'保存中…':'保存修改'}}</button></form><section v-if="historyFor"><h2>“{{historyFor.title}}”的历史</h2><button @click="historyFor=undefined">关闭历史</button><p v-if="historyLoading">正在读取历史…</p><table v-else><thead><tr><th>操作</th><th>时间</th><th>操作者</th><th>版本快照</th></tr></thead><tbody><tr v-for="item in historyItems" :key="item.id"><td>{{item.action}}</td><td>{{new Date(item.created_at).toLocaleString()}}</td><td>{{item.actor||'系统'}}</td><td><details><summary>查看快照</summary><pre>{{JSON.stringify(item.snapshot,null,2)}}</pre></details></td></tr><tr v-if="!historyItems.length"><td colspan="4">暂无历史记录。</td></tr></tbody></table></section></main></template>
<template><main><RouterLink to="/admin">返回管理员工作台</RouterLink><h1>知识管理</h1><p>列表、编辑、删除和历史均直接调用管理员受保护接口;编辑后条目会由后端重新置为待审核。</p><p v-if="error" role="alert">{{error}}</p><p v-if="notice" role="status">{{notice}}</p><form @submit.prevent="refresh(1)"><label>关键词<input v-model="query"/></label><label>分类<input v-model="category"/></label><label>状态<select v-model="status"><option value="">全部</option><option value="pending">待审核</option><option value="verified">已核验</option><option value="rejected">已驳回</option></select></label><button>筛选</button></form><p v-if="loading">正在读取知识条目…</p><template v-else><table><thead><tr><th>标题</th><th>分类</th><th>状态</th><th>来源</th><th>操作</th></tr></thead><tbody><tr v-for="item in items" :key="item.id"><td><b>{{item.title}}</b><p>{{item.content.slice(0,120)}}</p></td><td>{{item.category}}</td><td>{{item.verification_status}}</td><td><a v-if="item.source_url" :href="item.source_url" target="_blank" rel="noreferrer">打开来源</a><span v-else>未填写</span></td><td><button @click="edit(item)">编辑</button><button @click="showHistory(item)">历史</button><button @click="remove(item)">删除</button></td></tr><tr v-if="!items.length"><td colspan="5">暂无符合条件的知识条目。</td></tr></tbody></table><div><button :disabled="page===1" @click="refresh(page-1)">上一页</button><span>第 {{page}} 页,共 {{total}} 条</span><button :disabled="page*20>=total" @click="refresh(page+1)">下一页</button></div></template><form v-if="selected" @submit.prevent="save"><h2>编辑:{{selected.title}}</h2><label>标题<input v-model.trim="form.title" required maxlength="250"/></label><label>分类<input v-model.trim="form.category" required maxlength="80"/></label><label>正文<textarea v-model.trim="form.content" required maxlength="100000" rows="6"/></label><label>来源网址<input v-model.trim="form.source_url" type="url" maxlength="1000" placeholder="https://..."/></label><label>采集时间<input v-model="form.collected_at" type="datetime-local" step="any"/></label><button type="button" :disabled="saving" @click="cancel">取消</button><button :disabled="saving">{{saving?'保存中…':'保存修改'}}</button></form><section v-if="historyFor"><h2>“{{historyFor.title}}”的历史</h2><button @click="historyFor=undefined">关闭历史</button><p v-if="historyLoading">正在读取历史…</p><table v-else><thead><tr><th>操作</th><th>时间</th><th>操作者</th><th>版本快照</th></tr></thead><tbody><tr v-for="item in historyItems" :key="item.id"><td>{{item.action}}</td><td>{{new Date(item.created_at).toLocaleString()}}</td><td>{{item.actor||'系统'}}</td><td><details><summary>查看快照</summary><pre>{{JSON.stringify(item.snapshot,null,2)}}</pre></details></td></tr><tr v-if="!historyItems.length"><td colspan="4">暂无历史记录。</td></tr></tbody></table></section></main></template>
+59
View File
@@ -0,0 +1,59 @@
import assert from 'node:assert/strict'
import { readFile } from 'node:fs/promises'
import test from 'node:test'
import ts from 'typescript'
const source = await readFile(new URL('../src/utils/datetime.ts', import.meta.url), 'utf8')
const { outputText } = ts.transpileModule(source, { compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.ESNext } })
const { localDateTimeInput, collectedAtFromInput } = await import(`data:text/javascript;base64,${Buffer.from(outputText).toString('base64')}`)
function timezone(t, zone) {
const original = process.env.TZ
process.env.TZ = zone
t.after(() => { if (original === undefined) delete process.env.TZ; else process.env.TZ = original })
}
test('Shanghai display uses local clock and unchanged input preserves microseconds', t => {
timezone(t, 'Asia/Shanghai')
const original = '2026-10-16T04:34:56.789123Z'
const value = localDateTimeInput(original)
assert.equal(value, '2026-10-16T12:34:56.789')
assert.equal(collectedAtFromInput(value, original), original)
const offsetOriginal = '2026-10-16T12:34:56.789123+08:00'
assert.equal(collectedAtFromInput(value, offsetOriginal), offsetOriginal)
})
test('zero milliseconds are normalized like datetime-local without losing server fractions', t => {
timezone(t, 'Asia/Shanghai')
const original = '2026-10-16T04:34:00.000123Z'
assert.equal(localDateTimeInput(original), '2026-10-16T12:34')
assert.equal(collectedAtFromInput('2026-10-16T12:34', original), original)
assert.equal(localDateTimeInput('2026-10-16T04:34:56.010Z'), '2026-10-16T12:34:56.01')
})
test('explicit local time changes convert to the correct UTC instant', t => {
timezone(t, 'Asia/Shanghai')
assert.equal(collectedAtFromInput('2026-10-16T12:35:56.789', '2026-10-16T04:34:56.789123Z'), '2026-10-16T04:35:56.789Z')
assert.equal(collectedAtFromInput('2026-10-16T12:35'), '2026-10-16T04:35:00.000Z')
})
test('negative UTC offsets preserve date boundaries', t => {
timezone(t, 'America/Los_Angeles')
const original = '2026-01-01T04:34:56.789123Z'
assert.equal(localDateTimeInput(original), '2025-12-31T20:34:56.789')
assert.equal(collectedAtFromInput(localDateTimeInput(original), original), original)
})
test('unchanged repeated DST clock time keeps the original instant and precision', t => {
timezone(t, 'America/New_York')
const original = '2026-11-01T06:30:00.000123Z'
assert.equal(localDateTimeInput(original), '2026-11-01T01:30')
assert.equal(collectedAtFromInput('2026-11-01T01:30', original), original)
})
test('UTC and invalid timestamps do not invent a collection time', t => {
timezone(t, 'UTC')
assert.equal(localDateTimeInput('2026-10-16T04:34:56.789Z'), '2026-10-16T04:34:56.789')
assert.throws(() => localDateTimeInput('invalid'))
assert.throws(() => collectedAtFromInput(''))
})