Compare commits

..
6 changed files with 32 additions and 21 deletions
+1 -1
View File
@@ -83,7 +83,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 联调仍需按接口验收。
### 关键词检索基线
+1 -1
View File
@@ -14,7 +14,7 @@ FastAPI + Pydantic
|-- 来源获取 -> 人工核验 -> 文本切分 -> 生成向量 -> 发布版本
```
当前后端实现健康/数据库就绪检查、来源与文档管理、知识增删改查、分页筛选、人工审核、历史快照、认证会话/角色权限、文本/向量快照及独立关键词/向量/RRF 检索;问答仍为占位接口。数据库由 Alembic 显式迁移并启用 pgvector,DeepSeek RAG 待明确恢复后实现。前端已接入登录/注册、个人中心、账户设置、会话恢复、路由守卫和角色菜单,以及文档的创建、编辑、软删除、分页搜索与历史快照;实验页不伪造数据,知识审核、来源的完整维护、浏览器 Cookie 与 PostgreSQL 联调仍需验收。
当前后端实现健康/数据库就绪检查、来源与文档管理、知识增删改查、分页筛选、人工审核、历史快照、认证会话/角色权限、文本/向量快照及独立关键词/向量/RRF 检索;问答仍为占位接口。数据库由 Alembic 显式迁移并启用 pgvector,DeepSeek RAG 待明确恢复后实现。前端已接入登录/注册、个人中心、账户设置、会话恢复、路由守卫和角色菜单;实验页不伪造数据,文档、审核、完整分页、浏览器 Cookie 与 PostgreSQL 联调仍需验收。
## 阶段 2 数据边界
+17
View File
@@ -0,0 +1,17 @@
# 用户系统与角色权限:前后端接口清单
当前后端未注册认证或管理员路由;前端只提供真实请求封装,任何 404、401、403 或 5xx 都会如实抛出,绝不创建本地假身份。
| 目标接口 | 方法 | 前端用途 | 后端待实现要求 |
|---|---:|---|---|
| `/api/auth/register` | POST | 注册 | 只创建 `user`;不得接受角色字段 |
| `/api/auth/login` | POST | 登录 | 建立 HttpOnly、Secure、SameSite Cookie 会话 |
| `/api/auth/logout` | POST | 退出 | 作废会话并清除 Cookie |
| `/api/auth/me` | GET | 刷新恢复会话 | 返回用户资料与 `user`/`admin` 角色 |
| `/api/users/me` | PATCH | 修改昵称/邮箱 | 只允许修改当前用户自己的基本资料 |
| `/api/auth/change-password` | POST | 修改密码 | 验证旧密码并进行安全哈希 |
| `/api/admin/users` | GET | 用户管理 | 必须由后端校验 `admin` 角色 |
## 前端待接入条件
现有工程尚未安装 `vue-router`。在用户安装该依赖并确认认证接口可用后,前端将接入路由守卫:未登录跳转登录页、成功后回跳、管理员路由额外检查 `role === 'admin'`。前端只用于体验层控制;知识库、来源、实验、用户管理的最终授权必须由 FastAPI 校验。
-4
View File
@@ -15,10 +15,6 @@ export function reviewKnowledge(id:number,payload:{expected_version:number;verif
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}`)}
export function createDocument(payload:{source_id:number;title:string;category:string;content:string;collected_at:string;notes?:string}){return apiRequest<DocumentItem>('/api/documents',{method:'POST',body:JSON.stringify(payload)},true)}
export function updateDocument(id:number,payload:{expected_version:number;title?:string;category?:string;content?:string;collected_at?:string;notes?:string}){return apiRequest<DocumentItem>(`/api/documents/${id}`,{method:'PATCH',body:JSON.stringify(payload)},true)}
export function deleteDocument(id:number,expectedVersion:number){return apiRequest<void>(`/api/documents/${id}?expected_version=${expectedVersion}`,{method:'DELETE'},true)}
export type AuditItem={id:number;entity_type:string;entity_id:number;action:string;actor:string|null;notes:string;snapshot:Record<string,unknown>;created_at:string}
export function getDocumentHistory(id:number,page=1,pageSize=20){return apiRequest<{items:AuditItem[];total:number;page:number;page_size:number}>(`/api/documents/${id}/history?page=${page}&page_size=${pageSize}`)}
export type RetrievalHit={rank:number;knowledge_id:number;knowledge_version:number;document_id:number|null;document_version:number|null;source_id:number|null;source_version:number|null;source_name:string|null;source_url:string;title:string;category:string;snippet:string;snippet_start:number;snippet_end:number;score:number;matched_terms:string[]}
export type RetrievalResult={run_id:string;query:string;mode:'keyword';status:'ok'|'no_evidence';top_k:number;category:string|null;query_terms:string[];corpus_size:number;corpus_fingerprint:string;total_hits:number;results:RetrievalHit[];elapsed_ms:number;created_at:string;config:{tokenizer:string;scorer:string;k1:number;b:number;title_weight:number;stopwords_version:string}}
+5 -15
View File
@@ -1,20 +1,10 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { createDocument, deleteDocument, getDocumentHistory, getDocuments, getSources, updateDocument, type AuditItem, type DocumentItem, type SourceItem } from '../api'
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:''})
import { createDocument, getDocuments, getSources, type DocumentItem, type SourceItem } from '../api'
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 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 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}}
const form=ref({source_id:0,title:'',category:'未分类',content:'',collected_at:new Date().toISOString().slice(0,16),notes:''})
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=cause instanceof Error?cause.message:'无法读取文档'}finally{loading.value=false}}
async function submit(){error.value='';notice.value='';if(!form.value.source_id||!form.value.title.trim()||!form.value.content.trim()||!form.value.collected_at){error.value='请填写来源、标题、正文和采集时间。';return}saving.value=true;try{await createDocument({...form.value,title:form.value.title.trim(),content:form.value.content.trim(),collected_at:new Date(form.value.collected_at).toISOString()});notice.value='文档已创建,可在后续知识条目中关联并审核。';form.value={source_id:0,title:'',category:'未分类',content:'',collected_at:new Date().toISOString().slice(0,16),notes:''};await refresh(1)}catch(cause){error.value=cause instanceof Error?cause.message:'创建文档失败'}finally{saving.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>新增文档</h2><label>来源<select v-model.number="form.source_id" required><option :value="0" disabled>请选择来源</option><option v-for="source in sources" :key="source.id" :value="source.id">{{source.name}}</option></select></label><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?'保存中…':'保存文档'}}</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></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>{{new Date(doc.collected_at).toLocaleString()}}</td></tr><tr v-if="!documents.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></main></template>
@@ -0,0 +1,8 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { getUsers } from '../services/auth'
import type { CurrentUser } from '../types/auth'
const users=ref<CurrentUser[]>([]), error=ref('')
onMounted(async()=>{try{users.value=await getUsers()}catch(e){error.value=e instanceof Error?e.message:'无权读取用户列表'}})
</script>
<template><main><h1>用户管理</h1><p v-if="error">{{error}}</p><table v-else><tr><th>用户名</th><th>昵称</th><th>邮箱</th><th>角色</th></tr><tr v-for="user in users" :key="user.id"><td>{{user.username}}</td><td>{{user.nickname}}</td><td>{{user.email}}</td><td>{{user.role}}</td></tr></table></main></template>