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
+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>