Compare commits

..
Author SHA1 Message Date
saint 6b55e4615e feat: add retrieval history export workspace 2026-10-16 23:19:23 +08:00
5 changed files with 38 additions and 26 deletions
-17
View File
@@ -1,17 +0,0 @@
# 用户系统与角色权限:前后端接口清单
当前后端未注册认证或管理员路由;前端只提供真实请求封装,任何 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 校验。
+1
View File
@@ -13,6 +13,7 @@ const router=createRouter({history:createWebHistory(),routes:[
{path:'/indexes',component:()=>import('./views/IndexManagementView.vue'),meta:{role:'admin'}},
{path:'/reviews',component:()=>import('./views/KnowledgeReviewView.vue'),meta:{role:'admin'}},
{path:'/documents',component:()=>import('./views/DocumentManagementView.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'}},
]})
router.beforeEach(async to=>{if(to.meta.public)return true;try{role=(await getCurrentUser()).role}catch{return {path:'/login',query:{redirect:to.fullPath}}}if(to.meta.role&&role!==to.meta.role)return {path:'/profile',query:{forbidden:'1'}};return true})
+1 -1
View File
@@ -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></p>
<p><RouterLink to="/reviews">进入知识审核工作台</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,36 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { getRetrievalRuns, type RetrievalRunResult } from '../api'
import { exportRetrievalHistory } from '../services/exports'
const runs=ref<RetrievalRunResult[]>([]), selected=ref<string[]>([]), page=ref(1), total=ref(0), loading=ref(true), exporting=ref(false), error=ref(''), notice=ref(''), includeContexts=ref(false)
const pageSize=20
const selectedSet=computed(()=>new Set(selected.value))
function runLabel(run:RetrievalRunResult){return `${new Date(run.created_at).toLocaleString()} · ${run.mode} · ${run.query}`}
function toggle(runId:string){if(selectedSet.value.has(runId)){selected.value=selected.value.filter(id=>id!==runId);return}if(selected.value.length>=100){error.value='一次最多选择 100 条检索历史。';return}selected.value=[...selected.value,runId]}
async function refresh(next=page.value){loading.value=true;error.value='';try{const result=await getRetrievalRuns(next,pageSize);runs.value=result.items;total.value=result.total;page.value=result.page}catch(cause){error.value=cause instanceof Error?cause.message:'无法读取检索历史'}finally{loading.value=false}}
async function download(){error.value='';notice.value='';if(!selected.value.length){error.value='请先选择至少一条检索历史。';return}exporting.value=true;try{const file=await exportRetrievalHistory({run_ids:selected.value,include_contexts:includeContexts.value});const url=URL.createObjectURL(file.blob);const link=document.createElement('a');link.href=url;link.download=file.filename;link.style.display='none';document.body.append(link);link.click();link.remove();window.setTimeout(()=>URL.revokeObjectURL(url),0);notice.value=`已开始下载 ${file.filename}${file.recordsSha256?`(记录摘要 ${file.recordsSha256})`:''}。`}catch(cause){error.value=cause instanceof Error?cause.message:'导出失败'}finally{exporting.value=false}}
onMounted(()=>refresh(1))
</script>
<template>
<main>
<RouterLink to="/admin">返回管理员工作台</RouterLink>
<h1>导出检索历史</h1>
<p>从已保存的历史快照选择记录,导出 JSONL;不会重新检索、调用模型或导出认证信息。</p>
<p v-if="error" role="alert">{{error}}</p><p v-if="notice" role="status">{{notice}}</p>
<form @submit.prevent="download">
<label><input v-model="includeContexts" type="checkbox"/> 同时包含关联的 RAG 准备上下文</label>
<button :disabled="exporting||!selected.length">{{exporting?'正在准备…':`导出已选 ${selected.length} 条`}}</button>
<p>最多选择 100 条记录;导出上限为 8 MiB。文件仅表示当时历史快照,并不代表当前知识仍有效。</p>
</form>
<p v-if="loading">正在读取检索历史…</p>
<template v-else>
<table>
<thead><tr><th>选择</th><th>时间</th><th>模式</th><th>查询</th><th>状态</th><th>命中数</th></tr></thead>
<tbody><tr v-for="run in runs" :key="run.run_id"><td><input :checked="selectedSet.has(run.run_id)" type="checkbox" :aria-label="runLabel(run)" @change="toggle(run.run_id)"/></td><td>{{new Date(run.created_at).toLocaleString()}}</td><td>{{run.mode}}</td><td>{{run.query}}</td><td>{{run.status}}</td><td>{{run.total_hits}}</td></tr><tr v-if="!runs.length"><td colspan="6">暂无可导出的检索历史。</td></tr></tbody>
</table>
<div><button :disabled="page===1" @click="refresh(page-1)">上一页</button><span>第 {{page}} 页,共 {{total}} 条</span><button :disabled="page*pageSize>=total" @click="refresh(page+1)">下一页</button></div>
</template>
</main>
</template>
@@ -1,8 +0,0 @@
<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>