Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0db8052d2c | ||
|
|
ab0e5e3a2f | ||
|
|
b12f315cb3 | ||
|
|
4b9b4f4371 | ||
|
|
05761227fa | ||
|
|
ca24fbb17a | ||
|
|
59f2d564f0 | ||
|
|
c08c519698 |
@@ -84,7 +84,7 @@ Vite 通常使用 http://localhost:5173。
|
||||
}
|
||||
```
|
||||
|
||||
响应新增 `version/updated_at/is_deleted` 及关联、采集、审核字段;完整类型以 `/openapi.json` 和 `/docs` 为准。前端已接入登录/注册、Cookie 会话恢复、个人中心、账户设置、角色路由守卫、角色菜单、知识录入和来源登记/查询;文档管理已接入创建、编辑、软删除、分页搜索及历史快照的真实接口。知识审核、来源的完整编辑/历史页面及浏览器端 PostgreSQL 联调仍需按接口验收。
|
||||
响应新增 `version/updated_at/is_deleted` 及关联、采集、审核字段;完整类型以 `/openapi.json` 和 `/docs` 为准。前端已接入登录/注册、Cookie 会话恢复、个人中心、账户设置、角色路由守卫、角色菜单、知识录入和来源登记/查询;来源和文档管理均已接入创建、编辑、软删除、分页搜索及历史快照的真实接口。知识审核与浏览器端 PostgreSQL 联调仍需按接口验收。
|
||||
|
||||
### 关键词检索基线
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ FastAPI + Pydantic
|
||||
|-- 来源获取 -> 人工核验 -> 文本切分 -> 生成向量 -> 发布版本
|
||||
```
|
||||
|
||||
当前后端实现健康/数据库就绪检查、来源与文档管理、知识增删改查、分页筛选、人工审核、历史快照、认证会话/角色权限、文本/向量快照及独立关键词/向量/RRF 检索;问答仍为占位接口。数据库由 Alembic 显式迁移并启用 pgvector,DeepSeek RAG 待明确恢复后实现。前端已接入登录/注册、个人中心、账户设置、会话恢复、路由守卫和角色菜单,以及文档的创建、编辑、软删除、分页搜索与历史快照;实验页不伪造数据,知识审核、来源的完整维护、浏览器 Cookie 与 PostgreSQL 联调仍需验收。
|
||||
当前后端实现健康/数据库就绪检查、来源与文档管理、知识增删改查、分页筛选、人工审核、历史快照、认证会话/角色权限、文本/向量快照及独立关键词/向量/RRF 检索;问答仍为占位接口。数据库由 Alembic 显式迁移并启用 pgvector,DeepSeek RAG 待明确恢复后实现。前端已接入登录/注册、个人中心、账户设置、会话恢复、路由守卫和角色菜单,以及来源、文档的创建、编辑、软删除、分页搜索与历史快照;实验页不伪造数据,知识审核与浏览器 Cookie/PostgreSQL 联调仍需验收。
|
||||
|
||||
## 阶段 2 数据边界
|
||||
|
||||
|
||||
+9
-1
@@ -8,9 +8,17 @@ 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 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 function getSources(q=''){return apiRequest<{items:SourceItem[];total:number}>(`/api/sources?q=${encodeURIComponent(q)}`)}
|
||||
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 updateSource(id:number,payload:{expected_version:number;name?:string;source_url?:string|null;notes?:string}){return apiRequest<SourceItem>(`/api/sources/${id}`,{method:'PATCH',body:JSON.stringify(payload)},true)}
|
||||
export function deleteSource(id:number,expectedVersion:number){return apiRequest<void>(`/api/sources/${id}?expected_version=${expectedVersion}`,{method:'DELETE'},true)}
|
||||
export function getSourceHistory(id:number,page=1,pageSize=20){return apiRequest<{items:AuditItem[];total:number;page:number;page_size:number}>(`/api/sources/${id}/history?page=${page}&page_size=${pageSize}`)}
|
||||
export function reviewKnowledge(id:number,payload:{expected_version:number;verification_status:VerificationStatus;reviewer:string;verification_notes:string}){return apiRequest(`/api/knowledge/${id}/review`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(payload)},true)}
|
||||
export type DocumentItem={id:number;version:number;source_id:number;title:string;category:string;content:string;collected_at:string;notes:string;created_at:string;updated_at:string;is_deleted:boolean}
|
||||
export function getDocuments(q='',page=1,pageSize=20){return apiRequest<{items:DocumentItem[];total:number;page:number;page_size:number}>(`/api/documents?q=${encodeURIComponent(q)}&page=${page}&page_size=${pageSize}`)}
|
||||
|
||||
@@ -12,7 +12,9 @@ const router=createRouter({history:createWebHistory(),routes:[
|
||||
{path:'/settings',component:()=>import('./views/AccountSettingsView.vue')},
|
||||
{path:'/indexes',component:()=>import('./views/IndexManagementView.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:'/sources',component:()=>import('./views/SourceManagementView.vue'),meta:{role:'admin'}},
|
||||
{path:'/retrieval-exports',component:()=>import('./views/RetrievalExportView.vue'),meta:{role:'admin'}},
|
||||
{path:'/admin',component:()=>import('./views/AdminView.vue'),meta:{role:'admin'}},
|
||||
]})
|
||||
|
||||
@@ -29,7 +29,7 @@ onMounted(() => load(0))
|
||||
<main>
|
||||
<h1>管理员用户管理</h1>
|
||||
<p>用户数据由受服务端管理员权限保护的接口实时提供。</p>
|
||||
<p><RouterLink to="/reviews">进入知识审核工作台</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-else-if="loading">正在读取用户列表…</p>
|
||||
<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>
|
||||
@@ -0,0 +1,19 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { createSource, deleteSource, getSourceHistory, getSources, updateSource, type AuditItem, type SourceItem } from '../api'
|
||||
|
||||
type SourceForm={name:string;source_url:string;notes:string}
|
||||
const blank=():SourceForm=>({name:'',source_url:'',notes:''})
|
||||
const sources=ref<SourceItem[]>([]), form=ref<SourceForm>(blank()), editing=ref<SourceItem>(), historyFor=ref<SourceItem>(), historyItems=ref<AuditItem[]>([])
|
||||
const query=ref(''), page=ref(1), total=ref(0), loading=ref(true), saving=ref(false), historyLoading=ref(false), error=ref(''), notice=ref('')
|
||||
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 getSources(query.value,next);sources.value=result.items;total.value=result.total;page.value=result.page}catch(cause){error.value=failure(cause,'无法读取来源')}finally{loading.value=false}}
|
||||
function beginEdit(source:SourceItem){editing.value=source;form.value={name:source.name,source_url:source.source_url??'',notes:source.notes};notice.value=''}
|
||||
function cancelEdit(){editing.value=undefined;form.value=blank()}
|
||||
async function submit(){error.value='';notice.value='';if(!form.value.name.trim()){error.value='请填写来源名称。';return}if(!validUrl(form.value.source_url.trim())){error.value='来源网址必须是有效的 http(s) 地址。';return}saving.value=true;const wasEditing=Boolean(editing.value);try{const payload={name:form.value.name.trim(),source_url:form.value.source_url.trim()||null,notes:form.value.notes.trim()};if(editing.value){await updateSource(editing.value.id,{expected_version:editing.value.version,...payload});notice.value='来源已更新;关联知识会由后端重新置为待审核。'}else{await createSource({...payload,source_url:payload.source_url??undefined});notice.value='来源已登记。'}cancelEdit();await refresh(wasEditing?page.value:1)}catch(cause){error.value=failure(cause,wasEditing?'更新来源失败':'创建来源失败')}finally{saving.value=false}}
|
||||
async function remove(source:SourceItem){if(!window.confirm(`确定删除“${source.name}”吗?若仍被文档或知识条目引用,后端会拒绝删除。`))return;error.value='';notice.value='';try{await deleteSource(source.id,source.version);notice.value='来源已软删除,历史记录仍可追溯。';if(editing.value?.id===source.id)cancelEdit();await refresh(sources.value.length===1&&page.value>1?page.value-1:page.value)}catch(cause){error.value=failure(cause,'删除来源失败')}}
|
||||
async function showHistory(source:SourceItem){historyFor.value=source;historyLoading.value=true;error.value='';try{historyItems.value=(await getSourceHistory(source.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>来源是可追溯资料的登记基础;编辑、删除与历史快照均通过管理员受保护的 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>名称<input v-model.trim="form.name" required maxlength="250"/></label><label>网址(可选)<input v-model.trim="form.source_url" type="url" placeholder="https://..." maxlength="1000"/></label><label>备注<textarea v-model.trim="form.notes" maxlength="10000" rows="3"/></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>备注</th><th>操作</th></tr></thead><tbody><tr v-for="source in sources" :key="source.id"><td>{{source.name}}</td><td><a v-if="source.source_url" :href="source.source_url" target="_blank" rel="noreferrer">打开来源</a><span v-else>未填写</span></td><td>{{source.notes||'—'}}</td><td><button @click="beginEdit(source)">编辑</button><button @click="showHistory(source)">历史</button><button @click="remove(source)">删除</button></td></tr><tr v-if="!sources.length"><td colspan="4">暂无来源。</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.name}}”的历史记录</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>
|
||||
Reference in New Issue
Block a user