Compare commits

..
4 changed files with 25 additions and 1 deletions
+5
View File
@@ -8,6 +8,11 @@ export const getKnowledge = () => apiRequest<{id:number;title:string;category:st
export const createKnowledge = (payload:{title:string;category:string;content:string;source_url?:string;collected_at?:string}) => apiRequest('/api/knowledge',{method:'POST',body:JSON.stringify(payload)},true) export const createKnowledge = (payload:{title:string;category:string;content:string;source_url?:string;collected_at?:string}) => apiRequest('/api/knowledge',{method:'POST',body:JSON.stringify(payload)},true)
export type VerificationStatus='pending'|'verified'|'rejected' export type VerificationStatus='pending'|'verified'|'rejected'
export type KnowledgeItem={id:number;version:number;title:string;category:string;content:string;source_url:string|null;source_id:number|null;document_id:number|null;collected_at:string|null;verification_status:VerificationStatus;reviewer:string|null;reviewed_at:string|null;verification_notes:string|null;created_at:string;updated_at:string;is_deleted:boolean}
export function getKnowledgePage(filters:{q?:string;category?:string;verification_status?:VerificationStatus;page?:number;page_size?:number}={}){const params=new URLSearchParams();for(const [key,value] of Object.entries(filters)){if(value!==undefined&&value!=='')params.set(key,String(value))}return apiRequest<{items:KnowledgeItem[];total:number;page:number;page_size:number}>(`/api/knowledge/page?${params}`)}
export function updateKnowledge(id:number,payload:{expected_version:number;title?:string;category?:string;content?:string;source_url?:string|null;collected_at?:string|null}){return apiRequest<KnowledgeItem>(`/api/knowledge/${id}`,{method:'PATCH',body:JSON.stringify(payload)},true)}
export function deleteKnowledge(id:number,expectedVersion:number){return apiRequest<void>(`/api/knowledge/${id}?expected_version=${expectedVersion}`,{method:'DELETE'},true)}
export function getKnowledgeHistory(id:number,page=1,pageSize=20){return apiRequest<{items:AuditItem[];total:number;page:number;page_size:number}>(`/api/knowledge/${id}/history?page=${page}&page_size=${pageSize}`)}
export type SourceItem={id:number;version:number;name:string;source_url:string|null;notes:string;created_at:string} export type SourceItem={id:number;version:number;name:string;source_url:string|null;notes:string;created_at:string}
export function getSources(q='',page=1,pageSize=20){return apiRequest<{items:SourceItem[];total:number;page:number;page_size:number}>(`/api/sources?q=${encodeURIComponent(q)}&page=${page}&page_size=${pageSize}`)} export function getSources(q='',page=1,pageSize=20){return apiRequest<{items:SourceItem[];total:number;page:number;page_size:number}>(`/api/sources?q=${encodeURIComponent(q)}&page=${page}&page_size=${pageSize}`)}
export function createSource(payload:{name:string;source_url?:string;notes:string}){return apiRequest<SourceItem>('/api/sources',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(payload)},true)} export function createSource(payload:{name:string;source_url?:string;notes:string}){return apiRequest<SourceItem>('/api/sources',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(payload)},true)}
+1
View File
@@ -12,6 +12,7 @@ const router=createRouter({history:createWebHistory(),routes:[
{path:'/settings',component:()=>import('./views/AccountSettingsView.vue')}, {path:'/settings',component:()=>import('./views/AccountSettingsView.vue')},
{path:'/indexes',component:()=>import('./views/IndexManagementView.vue'),meta:{role:'admin'}}, {path:'/indexes',component:()=>import('./views/IndexManagementView.vue'),meta:{role:'admin'}},
{path:'/reviews',component:()=>import('./views/KnowledgeReviewView.vue'),meta:{role:'admin'}}, {path:'/reviews',component:()=>import('./views/KnowledgeReviewView.vue'),meta:{role:'admin'}},
{path:'/knowledge',component:()=>import('./views/KnowledgeManagementView.vue'),meta:{role:'admin'}},
{path:'/documents',component:()=>import('./views/DocumentManagementView.vue'),meta:{role:'admin'}}, {path:'/documents',component:()=>import('./views/DocumentManagementView.vue'),meta:{role:'admin'}},
{path:'/sources',component:()=>import('./views/SourceManagementView.vue'),meta:{role:'admin'}}, {path:'/sources',component:()=>import('./views/SourceManagementView.vue'),meta:{role:'admin'}},
{path:'/retrieval-exports',component:()=>import('./views/RetrievalExportView.vue'),meta:{role:'admin'}}, {path:'/retrieval-exports',component:()=>import('./views/RetrievalExportView.vue'),meta:{role:'admin'}},
+1 -1
View File
@@ -29,7 +29,7 @@ onMounted(() => load(0))
<main> <main>
<h1>管理员用户管理</h1> <h1>管理员用户管理</h1>
<p>用户数据由受服务端管理员权限保护的接口实时提供。</p> <p>用户数据由受服务端管理员权限保护的接口实时提供。</p>
<p><RouterLink to="/reviews">进入知识审核工作台</RouterLink> <RouterLink to="/sources">进入来源管理</RouterLink> <RouterLink to="/documents">进入文档管理</RouterLink> <RouterLink to="/indexes">进入索引管理</RouterLink> <RouterLink to="/retrieval-exports">导出检索历史</RouterLink></p> <p><RouterLink to="/knowledge">进入知识管理</RouterLink> <RouterLink to="/reviews">进入知识审核工作台</RouterLink> <RouterLink to="/sources">进入来源管理</RouterLink> <RouterLink to="/documents">进入文档管理</RouterLink> <RouterLink to="/indexes">进入索引管理</RouterLink> <RouterLink to="/retrieval-exports">导出检索历史</RouterLink></p>
<p v-if="error" role="alert">{{ error }}</p> <p v-if="error" role="alert">{{ error }}</p>
<p v-else-if="loading">正在读取用户列表…</p> <p v-else-if="loading">正在读取用户列表…</p>
<template v-else> <template v-else>
@@ -0,0 +1,18 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { deleteKnowledge, getKnowledgeHistory, getKnowledgePage, updateKnowledge, type AuditItem, type KnowledgeItem, type VerificationStatus } from '../api'
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('')
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 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 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>