Compare commits

..
Author SHA1 Message Date
saint 66ee56fcc6 merge: synchronize knowledge intake fix to main 2026-10-16 22:49:19 +08:00
saint e18260a9de Merge pull request 'fix: 修复知识采集录入并完成浏览器联调验收' (#29) from kronecker/test/backend-browser-acceptance into dev 2026-10-16 22:48:55 +08:00
saint e3d1b18776 Merge pull request 'release: integrate dev into main' (#28) from release/main-dev-integration into main 2026-10-16 22:43:45 +08:00
Kronecker 2fd31f6f77 fix: complete knowledge intake and browser acceptance 2026-10-16 21:45:22 +08:00
saint 0f36f7403b merge: integrate dev into main 2026-10-16 20:33:13 +08:00
saint 7ccb1f7fcb Merge pull request 'feat: add document management workspace' (#27) from feat/frontend-document-management into dev 2026-10-16 20:28:21 +08:00
saint 78263ae310 feat: add document management workspace 2026-10-16 20:27:55 +08:00
saint 68d3e44520 Merge pull request 'fix: link admin workspaces' (#26) from fix/frontend-review-navigation into dev 2026-10-16 20:22:43 +08:00
saint ca46a0c687 fix: link admin workspaces 2026-10-16 20:21:55 +08:00
saint cb512a4a35 Merge pull request 'feat: add knowledge review workspace' (#25) from feat/frontend-knowledge-review into dev 2026-10-16 20:19:00 +08:00
saint 9c290afbf9 feat: add knowledge review workspace 2026-10-16 20:18:25 +08:00
saint 4f3824be95 Merge pull request 'feat: add index management interface' (#24) from feat/frontend-index-management into dev 2026-10-16 20:12:08 +08:00
saint 5724878f26 feat: add index management interface 2026-10-16 20:11:33 +08:00
saint 6f81077501 Merge pull request 'feat: prepare RAG evidence and compatible chat requests' (#23) from fix/pr17-rag-context into dev 2026-10-16 20:02:24 +08:00
saint 460f120364 feat: add auth API clients and view scaffolding 2026-10-15 21:29:21 +08:00
saint bca123303e docs: add phase one execution plan 2026-10-15 19:42:57 +08:00
saint da80fffebf docs: assign Codex as frontend reviewer 2026-10-15 17:29:11 +08:00
saint 8481b4a403 docs: define pull request review policy 2026-10-15 17:27:36 +08:00
saint e374380ec0 docs: expand team execution guide 2026-10-15 17:23:55 +08:00
saint 7b614397bd docs: add team work plan 2026-10-15 17:19:09 +08:00
15 changed files with 261 additions and 12 deletions
+1 -1
View File
@@ -106,7 +106,7 @@ Vite 通常使用 http://localhost:5173。
前端包括问答、知识库管理、来源与更新、实验评估四个页面。实验评估页面没有虚构数据;所有页面明确标记当前功能状态。
注意:当前已实现本地 Embedding、文本/向量快照、精确向量及 RRF 混合检索;`/api/chat` 仍是占位接口,DeepSeek RAG 调用暂缓,网络搜索、后台更新任务和真实领域评测尚未实现。管理接口由服务端认证与角色校验;审核表单的核验人仍是人工输入字段。服务保持仅本机访问,完整生产部署、Docker 数据库和浏览器端 Cookie 联调尚未验收。`.env` 不要提交到 Git。
注意:当前已实现本地 Embedding、文本/向量快照、精确向量及 RRF 混合检索;`/api/chat` 仍是占位接口,生成模型实际调用暂缓,网络搜索、后台更新任务和真实领域评测尚未实现。管理接口由服务端认证与角色校验;审核表单的核验人仍是人工输入字段。已完成隔离环境的浏览器 Cookie/CSRF、角色与真实检索核心流程验收,见 [浏览器验收记录](docs/BROWSER_ACCEPTANCE.md);完整生产部署、Docker 数据库与 HTTPS 验收仍待完成。`.env` 不要提交到 Git。
### 后端测试和迁移回滚
+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 校验。
+80
View File
@@ -0,0 +1,80 @@
# 2026-10-16 浏览器与后端联调验收
本轮在真实浏览器中访问 Vite 页面,经同源 `/api` 代理连接默认生命周期 FastAPI 和隔离 PostgreSQL 16 + pgvector,验证合成资料的录入、审核、发布、检索以及 Cookie/CSRF/角色权限。数据仅用于功能验收,不代表那拉提文旅事实、领域质量或生产性能。
## 代码基线与范围
- [PR #17](https://gitea.kronecker.cc/saint/border-red-post-nalati/pulls/17) 的上下文阶段经真正 merged 的 [替代 PR #23](https://gitea.kronecker.cc/saint/border-red-post-nalati/pulls/23) 合入 `dev`。重放产生新 SHA,不能只看旧 AGit PR 是否仍打开。本轮核对替代 head/merge 均为最新 dev 的祖先;完整 backend 文件树、RAG 调用类型与专用契约文档和原实现一致,前端依赖 JSON 相同、锁文件对应版本与完整性值相同,通用说明保留集成后的更新。
- 从最新 dev 创建 `test/backend-browser-acceptance`,没有叠加待审功能;实际生成模型仍延期,没有调用生成模型或配置其凭据。
- 使用真实固定 BGE small 中文 Embedding(revision `7999e1d3359715c523056ef9478215996d62a620`、512 维)及现有离线缓存;不是用坐标 fixture 生成本轮页面上的检索结果。自动化回归中的坐标 fixture 仍只用于协议/数据库边界测试。
- 浏览器、SQL 实例、测试账号和原始检查记录属于本机隔离环境;仓库仅保留步骤、结果摘要及明确含有合成资料的截图,不提交运行环境、日志、凭据或个人数据。
## 真实浏览器操作与结果
| 验收路径 | 观察结果 |
|---|---|
| 匿名打开受保护页面 | 跳转登录页,保留回跳路径 |
| 管理员登录后刷新页面 | Cookie 会话恢复,管理员身份和受保护列表正常读取 |
| 来源登记 | 经浏览器提交来源、网址和合成备注,成功后列表显示 1 条 |
| 知识录入 | 保存为 pending,不能直接进入检索 |
| 缺失采集时间时审核 | 后端拒绝通过,页面如实显示须补全来源与真实采集时间 |
| 完整元数据知识审核 | 填入明确采集时间、来源、合成核验人和说明后审核保存成功 |
| 文本快照构建与发布 | 仅 1 条已核验知识进入 1 个 chunk;另一待审条目被排除;ready 后显式发布、代数递增 |
| 真实向量构建与发布 | 本地固定模型编码后保存向量,ready 后显式发布;页面显示真实向量版本 |
| 管理员 RRF 查询 | 关键词与向量各命中 1 条同一合成依据,页面展示原文与来源链接,说明尚未生成模型答案 |
| 文档登记 | 选择已登记来源并填入采集时间,创建成功;来源关联、正文和第一页列表可读取 |
| 管理员用户列表 | 数据来自服务端;只有一页时前后翻页按钮正确禁用。本轮未验证多页/大规模表现 |
| 退出登录 | 返回登录页;再次访问受保护页面仍跳转登录 |
| 普通用户注册及登录 | 服务端返回 user,界面不显示任何管理菜单 |
| 普通用户直接访问管理路由及登录回跳 | `/admin` 与 `/indexes` 均跳转个人中心的 forbidden 状态,没有获得管理页面 |
| 普通用户查询 | 重新发布当前资料快照后,普通用户仍能收到真实 RRF 原文证据,管理入口保持隐藏 |
管理员检索画面:
![管理员真实浏览器检索](assets/browser-acceptance/rrf-browser.jpg)
普通用户检索与菜单:
![普通用户检索](assets/browser-acceptance/user-retrieval-browser.jpg)
普通用户访问管理路由后的角色状态:
![普通用户角色拦截](assets/browser-acceptance/user-role-browser.jpg)
## 本轮修正
1. 知识录入原表单没有采集时间字段,导致从页面新建的知识无法满足后端审核前置条件。增加可选的采集时间输入,转换为带时区的 ISO 字符串提交;未填写仍可登记为待审,审核通过的后端约束继续有效,不自动猜测采集时间。
2. 主工作台缺少审核与文档管理入口。增加仅管理员可见的导航按钮,沿用已有路由和服务端授权,普通用户菜单验收通过。
3. 首页仍显示“尚未接入检索”,来源页仍称文档工作台待接入。按实际状态改为已接入检索证据、生成式回答未启用及已有文档管理入口;README 不再把尚未确定的生成模型供应商写成已选择。
## API 与数据库交叉核对
以下为独立 HTTP 检查,和上表的浏览器操作分别记录,不把 API 检查冒充浏览器动作:
- 匿名读取管理资源返回 401;普通用户读取知识、来源、文档、文本/向量索引、用户、检索历史和 RAG 准备历史八类管理资源均返回 403。
- 管理员写入缺少 CSRF 返回 403;使用正确 CSRF 但 Origin 不在允许列表仍返回 403。浏览器真实写入成功,说明正常代理调用携带了会话和 CSRF。
- 真实登录响应含 HttpOnly 与 SameSite=Lax;本次仅为隔离 HTTP test 环境关闭 Secure,生产默认配置未改。本轮没有 HTTPS/Secure Cookie 浏览器验收结论。
- 退出后 `/api/auth/me` 返回 401。浏览器刷新恢复及退出跳转与服务端会话状态一致。
- 文档等目录写入后,旧向量查询明确返回 409;API 重新构建/发布后恢复查询,仍只命中那条已核验知识。这是现有保守失效策略,不自动使用旧快照。
- 持久化记录为 1 条来源、2 条知识(其中 1 条未审核)、1 条关联文档。真实模型的空间、版本和归一化契约沿用 [VECTOR_API.md](VECTOR_API.md)。
- 最新检索记录可准备 RAG 上下文,状态 prepared 且 `model_called=false`;没有以准备结果冒充生成式回答。
## 复现路径
1. 使用单独测试数据库或隔离 Schema,启用 public 的 vector 扩展,执行 `alembic upgrade head`。不要把合成验收资料写入业务库。
2. 依 [AUTH_API.md](AUTH_API.md) 初始化本机测试管理员;真实密码、环境文件与日志不入库。前后端 Origin、Cookie 的开发设置按该文档配置。
3. 依 [VECTOR_API.md](VECTOR_API.md) 配置固定本地模型的离线缓存,启动默认生命周期后端及 Vite。
4. 从浏览器执行上表步骤,先验证缺失元数据被拒绝,再录入有明确采集时间的合成资料并审核、发布文本和向量、查询证据。
5. 使用独立普通用户核对菜单、直接路由拦截与退出;同时用 HTTP 核对服务端授权、CSRF 和失效行为。
6. 保存验收摘要与合成截图后,关闭本次测试服务并清理隔离 Schema;本次运行环境与原始记录仅保存在 Git 元数据目录。
## 本轮回归验证
- `pytest -q backend/tests`:**224 passed, 1 skipped**;使用隔离 PostgreSQL,skip 为必须显式启用的真实 Embedding 专项。
- `ruff check backend`、`ruff format --check backend`:通过。
- 前端 `npm run build`(含 `vue-tsc -b`):通过。
- 本轮真实固定 BGE small 的运行证据来自上述浏览器/HTTP 验收,和常规自动化的坐标 fixture 分开记录;没有生成模型调用。
## 尚未完成的验收
本轮不替代 Docker 容器验收、多浏览器/设备回归、生产 HTTPS、浏览器改密和历史知识元数据修订页面。既有后端自动化已覆盖改密、撤销和重启持久化,本轮未将它们称作浏览器操作。生成模型、已核验领域资料/独立问题集和领域质量实验继续保留后续范围。
+12 -3
View File
@@ -4,16 +4,16 @@
| 编号 | 模块 | 任务 | 负责人 | 状态 |
|---|---|---|---|---|
| T01 | 前端 | 验证 Vue 页面启动、补充截图 | 吉海燕 | 进行中(生产构建已通过;登录、账户设置与角色导航已合入,待浏览器 Cookie 联调) |
| T01 | 前端 | 验证 Vue 页面启动、补充截图 | 吉海燕 | 进行中(生产构建及登录/角色/核心管理流程浏览器联调已通过;账户设置等剩余验收见下文) |
| T02 | 后端 | 验证 FastAPI `/docs`、`/api/health` | 范涵宇 | 已完成自动化/真实 HTTP 验证;待目标 Docker 环境复核 |
| T03 | 数据库 | 启动 PostgreSQL 并验证知识入库 | 范涵宇 | 已用隔离 PostgreSQL 完成迁移与回归;本机 Docker Compose 验收受阻 |
| T04 | 文档 | 明确接口字段、知识来源审核规则 | 杨星萱 | 待办 |
| T05 | 知识库 | 设计 sources/documents/chunks/version 表 | 范涵宇 | 已完成(迁移、发布快照和接口已合入;待 Docker 联调) |
| T06 | 检索 | 实现关键词检索基线 | 范涵宇 | 已完成(已核验知识 BM25 与检索历史已合入) |
| T07 | 向量 | 评估 Embedding 并实现 pgvector | 范涵宇 | 已完成(本地模型、精确向量和 RRF 已合入;待真实领域评测) |
| T08 | RAG | 集成 LLM 与来源展示 | 范涵宇 | 暂缓(已确定 DeepSeek,待明确恢复外部模型调用授权) |
| T08 | RAG | 集成 LLM 与来源展示 | 范涵宇 | 协议已合入(证据与 OpenAI 兼容请求契约已完成;生成模型选型与实际调用按要求延期) |
| T09 | 实验 | 构建可核验问题集及评分规范 | 杨星萱 | 待办 |
| T10 | 工程 | 补充测试、Alembic、鉴权与部署 | 范涵宇(主)、全员 | 进行中(迁移、认证、CSRF 与自动化回归已完成;本机 pytest/Docker/部署验收待补) |
| T10 | 工程 | 补充测试、Alembic、鉴权与部署 | 范涵宇(主)、全员 | 进行中(迁移、认证、CSRF、224项回归及浏览器核心流程已通过;Docker/HTTPS/部署待验收) |
## 2026-10-15 首次联调记录
@@ -90,3 +90,12 @@
- 实际验证:`pytest -q` **224 passed, 1 skipped**;较上一阶段新增 43 项上下文/协议/权限/失效/预算/原文/失败回滚/真实 HTTP 重启用例,另新增 1 项上下文迁移降级保留数据验证。skip 为既有须显式启用的真实 Embedding 用例,本轮未重跑真实模型专项,不沿用旧结果冒充生成模型验证。`ruff check`、`ruff format --check` 通过。前端调用层同步,构建在补齐依赖后通过。
- PR #16 已合入的认证路由引用了 `vue-router`,原 package.json/锁文件未声明;本轮补充匹配现有 Vue 3 的 vue-router 4 依赖及锁记录,保留其他依赖原版本和平台元数据。前端 `npm run build` 通过;浏览器实际 Cookie/CSRF/角色及容器验收仍待完成。
- 本轮所有测试为协议/合成资料或坐标 fixture,没有真实生成模型请求、质量分数或费用。生成模型选择和服务凭据保持延期,不重复索要;本阶段提交 PR 后等待实际合入,再继续下一项可独立验收的后端任务。
## 2026-10-16 真实浏览器联调与阶段验收收尾
- 上下文阶段已通过真正 merged 的替代 PR #23 合入 dev;完整 backend 树及专用 RAG 契约/API 类型与原 #17 一致,通用文档保留集成更新,已记录重放等价依据。旧 #17 打开不阻塞后续阶段。
- 从最新 dev 创建 `test/backend-browser-acceptance`,用隔离 PostgreSQL16/pgvector、默认 FastAPI 生命周期、Vite 与真实浏览器走通管理员登录/刷新会话、来源/知识/文档录入、审核、文本与真实 BGE small 向量发布、RRF 原文来源展示,以及普通用户菜单/路由限制和退出。
- 修正页面联调发现的采集时间缺失、审核/文档导航入口和过期功能说明;后端审核/授权不放宽,不猜测历史资料采集时间。具体路径、HTTP 与浏览器证据分工及合成截图见 [BROWSER_ACCEPTANCE.md](BROWSER_ACCEPTANCE.md)。
- 独立 HTTP 检查确认匿名401、普通用户八类管理资源403、缺失CSRF/错误Origin403、退出后401;目录写入使旧发布409,重新发布后恢复真实检索;准备上下文仍为 model_called=false。合成数据和本机运行环境、原始记录、凭据不入库。
- 该轮验收收尾了T02的真实浏览器API连接、T05知识录入/审核与文本/向量页面核心流程、Issue #5登录/Cookie/CSRF/角色的主要浏览器路径。Docker、HTTPS、多浏览器、浏览器改密及历史元数据修订页面仍待验收;生成模型和领域评测继续延期。本轮 `pytest -q backend/tests` **224 passed, 1 skipped**,`ruff check`、`ruff format --check` 和前端 `npm run build` 均通过;常规 skip 为须显式启用的真实模型专项,本轮另外通过浏览器与独立 HTTP 实际运行了固定 BGE small。
Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

+19 -7
View File
@@ -8,8 +8,8 @@ import { useRouter } from 'vue-router'
import type { SessionUser } from './types/auth'
type Page='chat'|'knowledge'|'sources'|'evaluation'
const router=useRouter(),currentUser=ref<SessionUser>(),page=ref<Page>('chat'), apiStatus=ref('检测中'), question=ref(''), busy=ref(false), search=ref(''), showForm=ref(false), error=ref(''), notice=ref('')
const messages=ref([{role:'assistant',content:'你好!我是边疆红驿文旅问答原型。当前后端仅为基础接口,尚未接入知识检索和大语言模型。'}] as {role:string;content:string;sources?:{title:string;source_url?:string|null}[]}[])
const items=ref<any[]>([]), form=ref({title:'',category:'旅游资源',content:'',source_url:''})
const messages=ref([{role:'assistant',content:'你好!我是边疆红驿文旅问答原型。已接入已核验资料的混合检索,可展示原文证据与来源;生成式回答尚未启用。'}] as {role:string;content:string;sources?:{title:string;source_url?:string|null}[]}[])
const items=ref<any[]>([]), form=ref({title:'',category:'旅游资源',content:'',source_url:'',collected_at:''})
const sources=ref<SourceItem[]>([]), sourceSearch=ref(''), showSourceForm=ref(false), sourceForm=ref({name:'',source_url:'',notes:''})
const nav=[{id:'chat',label:'智能问答',icon:MessageSquareText},{id:'knowledge',label:'知识库管理',icon:Database,adminOnly:true},{id:'sources',label:'来源与更新',icon:Globe2,adminOnly:true},{id:'evaluation',label:'实验评估',icon:Activity,adminOnly:true}] as const
const visibleNav=computed(()=>nav.filter(item=>!('adminOnly' in item)||!item.adminOnly||currentUser.value?.role==='admin'))
@@ -22,10 +22,22 @@ watch(sourceSearch,()=>{window.clearTimeout(sourceSearchTimer);sourceSearchTimer
onMounted(async()=>{try{currentUser.value=await getCurrentUser()}catch{router.replace('/login');return}try{await getHealth();apiStatus.value='已连接'}catch{apiStatus.value='未连接'}if(currentUser.value.role==='admin')await Promise.all([refresh(),refreshSources()])})
async function send(){const q=question.value.trim();if(!q||busy.value)return;messages.value.push({role:'user',content:q});question.value='';busy.value=true;try{const r=await retrieveHybrid({query:q,top_k:5,candidate_k:50});const content=r.status==='no_evidence'?'未检索到可用的已核验知识依据。请调整问题或补充审核后的资料。':`已通过 RRF 混合检索命中 ${r.total_hits} 条依据(关键词 ${r.lexical_total_hits},向量 ${r.vector_total_hits},${r.elapsed_ms.toFixed(1)} ms)。当前仅展示检索证据,尚未接入大语言模型生成回答。`;messages.value.push({role:'assistant',content,sources:r.results.map(hit=>({title:`${hit.title}:${hit.snippet}`,source_url:hit.source_url}))})}catch(e){messages.value.push({role:'assistant',content:e instanceof Error?e.message:'暂时无法连接混合检索服务。'})}finally{busy.value=false}}
function validUrl(value:string){try{if(value)new URL(value);return true}catch{return false}}
async function save(){error.value='';notice.value='';if(!form.value.title.trim()||!form.value.content.trim()){error.value='请填写知识标题和正文。';return}if(!validUrl(form.value.source_url)){error.value='来源网址必须是有效的 http(s) 地址。';return}try{await createKnowledge({...form.value,title:form.value.title.trim(),content:form.value.content.trim(),...(form.value.source_url?{source_url:form.value.source_url}:{})});form.value={title:'',category:'旅游资源',content:'',source_url:''};showForm.value=false;notice.value='已保存为待审核条目,尚未进入检索索引。';await refresh()}catch(e){error.value=e instanceof Error?e.message:'保存失败'}}
async function save(){
error.value='';notice.value=''
if(!form.value.title.trim()||!form.value.content.trim()){error.value='请填写知识标题和正文。';return}
if(!validUrl(form.value.source_url)){error.value='来源网址必须是有效的 http(s) 地址。';return}
try{
await createKnowledge({title:form.value.title.trim(),category:form.value.category,content:form.value.content.trim(),
...(form.value.source_url?{source_url:form.value.source_url}:{}),
...(form.value.collected_at?{collected_at:new Date(form.value.collected_at).toISOString()}:{}),
})
form.value={title:'',category:'旅游资源',content:'',source_url:'',collected_at:''}
showForm.value=false;notice.value='已保存为待审核条目,尚未进入检索索引。';await refresh()
}catch(e){error.value=e instanceof Error?e.message:'保存失败'}
}
</script>
<template><div class="shell"><aside class="sidebar"><div class="brand"><div class="mark"><Bot :size="23"/></div><div><b>边疆红驿</b><small>NALATI KNOWLEDGE HUB</small></div></div><p class="muted label">功能列表</p><nav><button v-for="n in visibleNav" :key="n.id" :class="{active:page===n.id}" @click="page=n.id"><component :is="n.icon" :size="18"/>{{n.label}}<ChevronRight v-if="page===n.id" class="chev" :size="15"/></button><button @click="router.push('/profile')"><ShieldCheck :size="18"/>个人中心</button><button v-if="currentUser?.role==='admin'" @click="router.push('/admin')"><Activity :size="18"/>用户管理</button></nav><div class="quick-actions"><p class="muted label">快捷操作</p><button v-if="page==='knowledge'" class="side-action" @click="showForm=true"><Plus :size="16"/>新增知识</button><button v-if="page==='sources'" class="side-action" @click="showSourceForm=true"><Plus :size="16"/>新增来源</button><p v-if="page==='chat'" class="side-hint">选择示例问题,或直接开始提问。</p><p v-if="page==='evaluation'" class="side-hint">实验结果只展示真实运行数据。</p></div><div class="side-bottom"><div class="tip"><ShieldCheck :size="17"/><div><b>{{currentUser?.role==='admin'?'管理员':'已登录用户'}}</b><p>{{currentUser?.nickname||currentUser?.username||'正在载入账户'}}</p></div></div><p class="api">● API {{apiStatus}} <span>v0.1.0</span></p></div></aside><main><header><span>边疆红驿 / {{nav.find(n=>n.id===page)?.label}}</span><div><span class="pill">● {{currentUser?.role==='admin'?'管理员':'用户'}}</span><i>研</i></div></header><section class="content">
<div v-if="page==='chat'"><div class="heading"><div><small class="eyebrow"><Sparkles :size="14"/> LOCAL KNOWLEDGE ASSISTANT</small><h1>文旅知识,问得明白</h1><p>面向那拉提文旅与民宿服务的知识问答原型</p></div><span class="chip"><ShieldCheck :size="15"/> 来源可追溯 · 设计目标</span></div><div class="chat-layout"><div class="card chat"><div class="chat-head"><div class="mark"><Bot :size="20"/></div><div><b>边疆红驿助手</b><small>问答服务原型</small></div></div><div class="messages"><div v-for="(m,i) in messages" :key="i" class="message" :class="m.role"><small>{{m.role==='assistant'?'智能助手':'你'}}</small><p>{{m.content}}</p><div v-if="m.sources?.length" class="sources"><b><Link2 :size="14"/> 参考来源</b><a v-for="s in m.sources" :key="s.title" :href="s.source_url ?? undefined" target="_blank">{{s.title}}</a></div></div><p v-if="busy">正在请求后端…</p></div><form class="composer" @submit.prevent="send"><textarea v-model="question" placeholder="输入你想了解的文旅问题…" rows="2" @keydown.enter.exact.prevent="send"/><div><small>当前为基础接口,尚未接入真实检索</small><button class="primary" :disabled="busy||!question.trim()"><Send :size="15"/> 发送</button></div></form></div><aside class="suggest"><b><Sparkles :size="16"/> 你可以这样问</b><button @click="question='那拉提有哪些值得了解的旅游资源?'"><Globe2/>旅游资源 <ChevronRight/></button><button @click="question='有哪些经过核验的红色文化资料?'"><BookOpen/>红色文化 <ChevronRight/></button><button @click="question='民宿服务信息应如何核验?'"><ShieldCheck/>民宿服务 <ChevronRight/></button><div class="notice"><b><AlertCircle :size="15"/> 信息说明</b><p>本项目仍处于原型阶段。演示内容不代表已核验的地方文旅事实。</p></div></aside></div></div>
<div v-else-if="page==='knowledge'"><div class="heading"><div><small class="eyebrow"><Database :size="14"/> KNOWLEDGE MANAGEMENT</small><h1>知识库管理</h1><p>集中管理领域知识、来源和审核状态。</p></div><button class="primary" @click="showForm=!showForm"><Plus :size="16"/> 新增知识</button></div><div v-if="showForm" class="card form-card"><b>新增知识条目</b><form class="knowledge-form" @submit.prevent="save"><label>标题<input v-model="form.title" required maxlength="250"/></label><label>分类<select v-model="form.category"><option>红色文化</option><option>旅游资源</option><option>民宿服务</option><option>交通出行</option><option>其他</option></select></label><label class="wide">正文<textarea v-model="form.content" required rows="3"/></label><label class="wide">来源网址(可选)<input v-model="form.source_url" type="url" placeholder="https://..."/></label><div class="wide actions"><button type="button" class="secondary" @click="showForm=false">取消</button><button class="primary">保存条目</button></div></form></div><div class="stats"><div class="card stat"><small>知识条目</small><strong>{{items.length}}</strong><span>来自当前数据库</span></div><div class="card stat"><small>已核验</small><strong>{{items.filter(x=>x.verification_status==='verified').length}}</strong><span>需经过真实审核</span></div><div class="card stat"><small>待审核</small><strong>{{items.filter(x=>x.verification_status==='pending').length}}</strong><span>尚未进入检索索引</span></div></div><p v-if="notice" class="success">{{notice}}</p><p v-if="error" class="error">{{error}}</p><div class="card table-card"><div class="table-head"><div><b>知识条目</b><p>列表来自后端接口,不含预设真实资料。</p></div><label class="search"><Search :size="15"/><input v-model="search" placeholder="搜索知识"/></label></div><div class="scroll"><table><thead><tr><th>标题</th><th>分类</th><th>来源</th><th>状态</th><th>创建时间</th></tr></thead><tbody><tr v-for="x in filtered" :key="x.id"><td><b>{{x.title}}</b><small>{{x.content.slice(0,45)}}</small></td><td>{{x.category}}</td><td><a v-if="x.source_url" :href="x.source_url" target="_blank">打开来源</a><span v-else>未填写</span></td><td><em>{{x.verification_status==='pending'?'待审核':x.verification_status}}</em></td><td>{{new Date(x.created_at).toLocaleDateString()}}</td></tr><tr v-if="!filtered.length"><td colspan="5" class="empty">暂无条目,点击“新增知识”添加。</td></tr></tbody></table></div></div></div>
<div v-else-if="page==='sources'"><div class="heading"><div><small class="eyebrow"><Globe2 :size="14"/> SOURCE OPERATIONS</small><h1>来源与更新</h1><p>登记可追溯来源,为文档采集和知识审核建立基础。</p></div><button class="primary" @click="showSourceForm=!showSourceForm"><Plus :size="16"/> 新增来源</button></div><div v-if="showSourceForm" class="card form-card"><b>登记来源</b><form class="knowledge-form" @submit.prevent="saveSource"><label>来源名称<input v-model="sourceForm.name" required maxlength="250" placeholder="例如:那拉提景区官网"/></label><label>来源网址(可选)<input v-model="sourceForm.source_url" type="url" placeholder="https://..."/></label><label class="wide">备注<textarea v-model="sourceForm.notes" rows="3" placeholder="记录来源用途、可靠性判断或待核验事项"/></label><div class="wide actions"><button type="button" class="secondary" @click="showSourceForm=false">取消</button><button class="primary">保存来源</button></div></form></div><p v-if="notice" class="success">{{notice}}</p><p v-if="error" class="error">{{error}}</p><div class="stats"><div class="card stat"><small>来源总数</small><strong>{{sources.length}}</strong><span>来自当前数据库</span></div><div class="card stat"><small>关联文档</small><strong>—</strong><span>文档工作台待接入</span></div><div class="card stat"><small>自动更新</small><strong class="pending">未启用</strong><span>当前不会自动抓取网页</span></div></div><div class="card table-card"><div class="table-head"><div><b>已登记来源</b><p>来源不是审核通过的证明,关键事实仍需交叉核验。</p></div><label class="search"><Search :size="15"/><input v-model="sourceSearch" placeholder="搜索名称或备注" @keyup.enter="refreshSources"/></label></div><div class="scroll"><table><thead><tr><th>名称</th><th>网址</th><th>备注</th><th>登记时间</th></tr></thead><tbody><tr v-for="source in sources" :key="source.id"><td><b>{{source.name}}</b></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>{{new Date(source.created_at).toLocaleDateString()}}</td></tr><tr v-if="!sources.length"><td colspan="4" class="empty">暂无来源,点击“新增来源”登记。</td></tr></tbody></table></div></div><div class="card roadmap"><b>来源处理边界</b><div><span>01 登记来源</span><ChevronRight/><span>02 人工核验</span><ChevronRight/><span>03 关联文档</span><ChevronRight/><span>04 后续检索</span></div></div></div>
<template><div class="shell"><aside class="sidebar"><div class="brand"><div class="mark"><Bot :size="23"/></div><div><b>边疆红驿</b><small>NALATI KNOWLEDGE HUB</small></div></div><p class="muted label">功能列表</p><nav><button v-for="n in visibleNav" :key="n.id" :class="{active:page===n.id}" @click="page=n.id"><component :is="n.icon" :size="18"/>{{n.label}}<ChevronRight v-if="page===n.id" class="chev" :size="15"/></button><button @click="router.push('/profile')"><ShieldCheck :size="18"/>个人中心</button><button v-if="currentUser?.role==='admin'" @click="router.push('/reviews')"><ShieldCheck :size="18"/>知识审核</button><button v-if="currentUser?.role==='admin'" @click="router.push('/documents')"><FileText :size="18"/>文档管理</button><button v-if="currentUser?.role==='admin'" @click="router.push('/indexes')"><Database :size="18"/>索引管理</button><button v-if="currentUser?.role==='admin'" @click="router.push('/admin')"><Activity :size="18"/>用户管理</button></nav><div class="quick-actions"><p class="muted label">快捷操作</p><button v-if="page==='knowledge'" class="side-action" @click="showForm=true"><Plus :size="16"/>新增知识</button><button v-if="page==='sources'" class="side-action" @click="showSourceForm=true"><Plus :size="16"/>新增来源</button><p v-if="page==='chat'" class="side-hint">选择示例问题,或直接开始提问。</p><p v-if="page==='evaluation'" class="side-hint">实验结果只展示真实运行数据。</p></div><div class="side-bottom"><div class="tip"><ShieldCheck :size="17"/><div><b>{{currentUser?.role==='admin'?'管理员':'已登录用户'}}</b><p>{{currentUser?.nickname||currentUser?.username||'正在载入账户'}}</p></div></div><p class="api">● API {{apiStatus}} <span>v0.1.0</span></p></div></aside><main><header><span>边疆红驿 / {{nav.find(n=>n.id===page)?.label}}</span><div><span class="pill">● {{currentUser?.role==='admin'?'管理员':'用户'}}</span><i>研</i></div></header><section class="content">
<div v-if="page==='chat'"><div class="heading"><div><small class="eyebrow"><Sparkles :size="14"/> LOCAL KNOWLEDGE ASSISTANT</small><h1>文旅知识,问得明白</h1><p>面向那拉提文旅与民宿服务的知识问答原型</p></div><span class="chip"><ShieldCheck :size="15"/> 来源可追溯 · 设计目标</span></div><div class="chat-layout"><div class="card chat"><div class="chat-head"><div class="mark"><Bot :size="20"/></div><div><b>边疆红驿助手</b><small>问答服务原型</small></div></div><div class="messages"><div v-for="(m,i) in messages" :key="i" class="message" :class="m.role"><small>{{m.role==='assistant'?'智能助手':'你'}}</small><p>{{m.content}}</p><div v-if="m.sources?.length" class="sources"><b><Link2 :size="14"/> 参考来源</b><a v-for="s in m.sources" :key="s.title" :href="s.source_url ?? undefined" target="_blank">{{s.title}}</a></div></div><p v-if="busy">正在请求后端…</p></div><form class="composer" @submit.prevent="send"><textarea v-model="question" placeholder="输入你想了解的文旅问题…" rows="2" @keydown.enter.exact.prevent="send"/><div><small>已接入 RRF 混合检索;当前仅展示证据</small><button class="primary" :disabled="busy||!question.trim()"><Send :size="15"/> 发送</button></div></form></div><aside class="suggest"><b><Sparkles :size="16"/> 你可以这样问</b><button @click="question='那拉提有哪些值得了解的旅游资源?'"><Globe2/>旅游资源 <ChevronRight/></button><button @click="question='有哪些经过核验的红色文化资料?'"><BookOpen/>红色文化 <ChevronRight/></button><button @click="question='民宿服务信息应如何核验?'"><ShieldCheck/>民宿服务 <ChevronRight/></button><div class="notice"><b><AlertCircle :size="15"/> 信息说明</b><p>本项目仍处于原型阶段。演示内容不代表已核验的地方文旅事实。</p></div></aside></div></div>
<div v-else-if="page==='knowledge'"><div class="heading"><div><small class="eyebrow"><Database :size="14"/> KNOWLEDGE MANAGEMENT</small><h1>知识库管理</h1><p>集中管理领域知识、来源和审核状态。</p></div><button class="primary" @click="showForm=!showForm"><Plus :size="16"/> 新增知识</button></div><div v-if="showForm" class="card form-card"><b>新增知识条目</b><form class="knowledge-form" @submit.prevent="save"><label>标题<input v-model="form.title" required maxlength="250"/></label><label>分类<select v-model="form.category"><option>红色文化</option><option>旅游资源</option><option>民宿服务</option><option>交通出行</option><option>其他</option></select></label><label class="wide">正文<textarea v-model="form.content" required rows="3"/></label><label class="wide">来源网址(可选)<input v-model="form.source_url" type="url" placeholder="https://..."/></label><label class="wide">采集时间(核验通过前必填)<input v-model="form.collected_at" type="datetime-local"/></label><div class="wide actions"><button type="button" class="secondary" @click="showForm=false">取消</button><button class="primary">保存条目</button></div></form></div><div class="stats"><div class="card stat"><small>知识条目</small><strong>{{items.length}}</strong><span>来自当前数据库</span></div><div class="card stat"><small>已核验</small><strong>{{items.filter(x=>x.verification_status==='verified').length}}</strong><span>需经过真实审核</span></div><div class="card stat"><small>待审核</small><strong>{{items.filter(x=>x.verification_status==='pending').length}}</strong><span>尚未进入检索索引</span></div></div><p v-if="notice" class="success">{{notice}}</p><p v-if="error" class="error">{{error}}</p><div class="card table-card"><div class="table-head"><div><b>知识条目</b><p>列表来自后端接口,不含预设真实资料。</p></div><label class="search"><Search :size="15"/><input v-model="search" placeholder="搜索知识"/></label></div><div class="scroll"><table><thead><tr><th>标题</th><th>分类</th><th>来源</th><th>状态</th><th>创建时间</th></tr></thead><tbody><tr v-for="x in filtered" :key="x.id"><td><b>{{x.title}}</b><small>{{x.content.slice(0,45)}}</small></td><td>{{x.category}}</td><td><a v-if="x.source_url" :href="x.source_url" target="_blank">打开来源</a><span v-else>未填写</span></td><td><em>{{x.verification_status==='pending'?'待审核':x.verification_status}}</em></td><td>{{new Date(x.created_at).toLocaleDateString()}}</td></tr><tr v-if="!filtered.length"><td colspan="5" class="empty">暂无条目,点击“新增知识”添加。</td></tr></tbody></table></div></div></div>
<div v-else-if="page==='sources'"><div class="heading"><div><small class="eyebrow"><Globe2 :size="14"/> SOURCE OPERATIONS</small><h1>来源与更新</h1><p>登记可追溯来源,为文档采集和知识审核建立基础。</p></div><button class="primary" @click="showSourceForm=!showSourceForm"><Plus :size="16"/> 新增来源</button></div><div v-if="showSourceForm" class="card form-card"><b>登记来源</b><form class="knowledge-form" @submit.prevent="saveSource"><label>来源名称<input v-model="sourceForm.name" required maxlength="250" placeholder="例如:那拉提景区官网"/></label><label>来源网址(可选)<input v-model="sourceForm.source_url" type="url" placeholder="https://..."/></label><label class="wide">备注<textarea v-model="sourceForm.notes" rows="3" placeholder="记录来源用途、可靠性判断或待核验事项"/></label><div class="wide actions"><button type="button" class="secondary" @click="showSourceForm=false">取消</button><button class="primary">保存来源</button></div></form></div><p v-if="notice" class="success">{{notice}}</p><p v-if="error" class="error">{{error}}</p><div class="stats"><div class="card stat"><small>来源总数</small><strong>{{sources.length}}</strong><span>来自当前数据库</span></div><div class="card stat"><small>关联文档</small><strong>—</strong><span>请到文档管理查看</span></div><div class="card stat"><small>自动更新</small><strong class="pending">未启用</strong><span>当前不会自动抓取网页</span></div></div><div class="card table-card"><div class="table-head"><div><b>已登记来源</b><p>来源不是审核通过的证明,关键事实仍需交叉核验。</p></div><label class="search"><Search :size="15"/><input v-model="sourceSearch" placeholder="搜索名称或备注" @keyup.enter="refreshSources"/></label></div><div class="scroll"><table><thead><tr><th>名称</th><th>网址</th><th>备注</th><th>登记时间</th></tr></thead><tbody><tr v-for="source in sources" :key="source.id"><td><b>{{source.name}}</b></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>{{new Date(source.created_at).toLocaleDateString()}}</td></tr><tr v-if="!sources.length"><td colspan="4" class="empty">暂无来源,点击“新增来源”登记。</td></tr></tbody></table></div></div><div class="card roadmap"><b>来源处理边界</b><div><span>01 登记来源</span><ChevronRight/><span>02 人工核验</span><ChevronRight/><span>03 关联文档</span><ChevronRight/><span>04 后续检索</span></div></div></div>
<div v-else><div class="heading"><div><small class="eyebrow"><Activity :size="14"/> EVALUATION WORKSPACE</small><h1>实验评估</h1><p>展示实验方案与指标规划;当前不显示虚构实验结果。</p></div><span class="pill">● 尚未开展实验</span></div><div class="notice wide"><b><ShieldCheck :size="16"/> 实验原则</b><p>所有指标应来自真实测试问题、运行日志和明确的人工评分规则。下方仅列评价维度,不包含实验数值。</p></div><div class="stats"><div class="card stat"><small>回答正确性</small><strong class="pending">待测试</strong><span>依据人工评分规范</span></div><div class="card stat"><small>来源可追溯性</small><strong class="pending">待测试</strong><span>核对来源是否支持答案</span></div><div class="card stat"><small>响应时间与成本</small><strong class="pending">待测试</strong><span>记录耗时与实际费用</span></div></div><div class="card roadmap"><b>计划中的对照方案</b><div class="methods"><p><b>A 基础大语言模型</b><span>不提供项目知识库检索结果</span></p><p><b>B 关键词检索 RAG</b><span>关键词检索后生成答案</span></p><p><b>C 混合检索 RAG</b><span>融合关键词与向量语义检索</span></p></div></div></div><footer>边疆红驿 · 那拉提文旅智能问答 <span>工程原型 v0.1.0 · 尚非完整系统</span></footer></section></main></div></template>
+4 -1
View File
@@ -5,13 +5,16 @@ import type { ChunkKeywordResult, HybridResult } from './services/hybrid'
export const getHealth = () => apiRequest<{status:string}>('/api/health')
export const askQuestion = (question:string) => apiRequest<{answer:string;status:string;sources:{title:string;source_url?:string|null}[]}>('/api/chat',{method:'POST',body:JSON.stringify({question})})
export const getKnowledge = () => apiRequest<{id:number;title:string;category:string;content:string;source_url:string|null;verification_status:string;version:number}[]>('/api/knowledge')
export const createKnowledge = (payload:{title:string;category:string;content:string;source_url?: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 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 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 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}`)}
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 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}}
+3
View File
@@ -10,6 +10,9 @@ const router=createRouter({history:createWebHistory(),routes:[
{path:'/register',component:()=>import('./views/RegisterView.vue'),meta:{public:true}},
{path:'/profile',component:()=>import('./views/ProfileView.vue')},
{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:'/documents',component:()=>import('./views/DocumentManagementView.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
View File
@@ -29,6 +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 v-if="error" role="alert">{{ error }}</p>
<p v-else-if="loading">正在读取用户列表…</p>
<template v-else>
@@ -0,0 +1,10 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue'
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({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>新增文档</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,86 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { buildIndex, getActiveIndex, getIndexVersions, publishIndex, type ActiveIndex, type IndexVersion } from '../services/indexes'
import { buildVectorIndex, getActiveVectors, getVectorVersions, publishVectorIndex, type ActiveVectors, type VectorVersion } from '../services/vectors'
const textActive = ref<ActiveIndex>()
const vectorActive = ref<ActiveVectors>()
const textVersions = ref<IndexVersion[]>([])
const vectorVersions = ref<VectorVersion[]>([])
const loading = ref(true)
const buildingText = ref(false)
const buildingVector = ref<string>()
const publishing = ref<string>()
const error = ref('')
const notice = ref('')
const config = ref({ chunk_size: 800, overlap: 120 })
async function refresh() {
loading.value = true
error.value = ''
try {
const [text, texts, vectors, vectorList] = await Promise.all([getActiveIndex(), getIndexVersions(), getActiveVectors(), getVectorVersions()])
textActive.value = text
textVersions.value = texts.items
vectorActive.value = vectors
vectorVersions.value = vectorList.items
} catch (cause) {
error.value = cause instanceof Error ? cause.message : '无法读取索引状态'
} finally {
loading.value = false
}
}
async function buildText() {
error.value = ''; notice.value = ''
if (config.value.chunk_size <= 0 || config.value.overlap < 0 || config.value.overlap >= config.value.chunk_size) {
error.value = '切分长度必须大于 0,且重叠长度必须小于切分长度。'
return
}
buildingText.value = true
try {
const version = await buildIndex(config.value)
notice.value = `文本索引 ${version.id} 已构建为 ready,需确认后再发布。`
await refresh()
} catch (cause) { error.value = cause instanceof Error ? cause.message : '构建文本索引失败' } finally { buildingText.value = false }
}
async function publishText(version: IndexVersion) {
publishing.value = `text:${version.id}`; error.value = ''; notice.value = ''
try { await publishIndex(version.id, textActive.value?.generation ?? 0); notice.value = '文本索引已发布。'; await refresh() }
catch (cause) { error.value = cause instanceof Error ? cause.message : '发布文本索引失败' } finally { publishing.value = undefined }
}
async function buildVector(version: IndexVersion) {
buildingVector.value = version.id; error.value = ''; notice.value = ''
try { const created = await buildVectorIndex(version.id); notice.value = `向量索引 ${created.id} 已构建为 ready。`; await refresh() }
catch (cause) { error.value = cause instanceof Error ? cause.message : '构建向量索引失败' } finally { buildingVector.value = undefined }
}
async function publishVector(version: VectorVersion) {
publishing.value = `vector:${version.id}`; error.value = ''; notice.value = ''
try { await publishVectorIndex(version.id, vectorActive.value?.generation ?? 0); notice.value = '向量索引已发布。'; await refresh() }
catch (cause) { error.value = cause instanceof Error ? cause.message : '发布向量索引失败' } finally { publishing.value = undefined }
}
onMounted(refresh)
</script>
<template>
<main>
<RouterLink to="/">返回工作台</RouterLink>
<h1>索引管理</h1>
<p>操作直接调用管理员受保护接口;构建与发布会以服务端当前审核语料和代数校验为准。</p>
<p v-if="error" role="alert">{{ error }}</p><p v-if="notice" role="status">{{ notice }}</p>
<p v-if="loading">正在读取索引状态…</p>
<template v-else>
<section><h2>文本索引</h2><p>当前版本:{{ textActive?.active_version?.id || '未发布' }};代数:{{ textActive?.generation ?? 0 }}</p>
<form @submit.prevent="buildText"><label>切分长度<input v-model.number="config.chunk_size" type="number" min="1" required /></label><label>重叠长度<input v-model.number="config.overlap" type="number" min="0" required /></label><button :disabled="buildingText">{{ buildingText ? '构建中…' : '构建文本索引' }}</button></form>
<table><thead><tr><th>版本</th><th>状态</th><th>条目/片段</th><th>创建时间</th><th>操作</th></tr></thead><tbody><tr v-for="version in textVersions" :key="version.id"><td>{{ version.id }}</td><td>{{ version.status }}</td><td>{{ version.item_count }} / {{ version.chunk_count }}</td><td>{{ new Date(version.created_at).toLocaleString() }}</td><td><button v-if="version.status==='ready'" :disabled="publishing===`text:${version.id}`" @click="publishText(version)">发布</button><button v-if="version.status==='published'" :disabled="buildingVector===version.id" @click="buildVector(version)">{{ buildingVector===version.id?'构建中…':'构建向量索引' }}</button></td></tr><tr v-if="!textVersions.length"><td colspan="5">暂无文本索引。</td></tr></tbody></table>
</section>
<section><h2>向量索引</h2><p>当前版本:{{ vectorActive?.active_version?.id || '未发布' }};代数:{{ vectorActive?.generation ?? 0 }}</p>
<table><thead><tr><th>版本</th><th>文本版本</th><th>状态</th><th>片段数</th><th>操作</th></tr></thead><tbody><tr v-for="version in vectorVersions" :key="version.id"><td>{{ version.id }}</td><td>{{ version.text_index_version_id }}</td><td>{{ version.status }}</td><td>{{ version.chunk_count }}</td><td><button v-if="version.status==='ready'" :disabled="publishing===`vector:${version.id}`" @click="publishVector(version)">发布</button></td></tr><tr v-if="!vectorVersions.length"><td colspan="5">暂无向量索引;需先发布文本索引并启用本地 Embedding。</td></tr></tbody></table>
</section>
</template>
</main>
</template>
@@ -0,0 +1,20 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { getKnowledge, reviewKnowledge } from '../api'
type Item = { id:number; version:number; title:string; category:string; content:string; source_url:string|null; verification_status:'pending'|'verified'|'rejected'; reviewer?:string|null; verification_notes?:string|null }
const items = ref<Item[]>([])
const selected = ref<Item>()
const reviewer = ref('')
const status = ref<'verified'|'rejected'>('verified')
const notes = ref('')
const loading = ref(true)
const submitting = ref(false)
const error = ref('')
const notice = ref('')
async function refresh(){ loading.value=true; error.value=''; try { items.value=(await getKnowledge()).filter(item=>item.verification_status==='pending') as Item[] } catch(cause){ error.value=cause instanceof Error?cause.message:'无法读取待审核知识' } finally { loading.value=false } }
function select(item:Item){ selected.value=item; reviewer.value=''; status.value='verified'; notes.value=''; error.value=''; notice.value='' }
async function submit(){ if(!selected.value)return; error.value=''; notice.value=''; if(!reviewer.value.trim()||!notes.value.trim()){error.value='请填写实际核验人和核验说明。';return} submitting.value=true; try { await reviewKnowledge(selected.value.id,{expected_version:selected.value.version,verification_status:status.value,reviewer:reviewer.value.trim(),verification_notes:notes.value.trim()}); notice.value='审核结果已保存。'; selected.value=undefined; await refresh() } catch(cause){error.value=cause instanceof Error?cause.message:'提交审核失败'} finally {submitting.value=false} }
onMounted(refresh)
</script>
<template><main><RouterLink to="/">返回工作台</RouterLink><h1>知识审核工作台</h1><p>只展示后端返回的待审核条目;提交会使用条目当前版本,避免覆盖他人更新。</p><p v-if="error" role="alert">{{error}}</p><p v-if="notice" role="status">{{notice}}</p><p v-if="loading">正在读取待审核条目…</p><template v-else><table><thead><tr><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><a v-if="item.source_url" :href="item.source_url" target="_blank" rel="noreferrer">打开来源</a><span v-else>未填写</span></td><td><button @click="select(item)">审核</button></td></tr><tr v-if="!items.length"><td colspan="4">暂无待审核条目。</td></tr></tbody></table><form v-if="selected" @submit.prevent="submit"><h2>审核:{{selected.title}}</h2><label>结论<select v-model="status"><option value="verified">通过核验</option><option value="rejected">驳回</option></select></label><label>核验人<input v-model.trim="reviewer" required maxlength="100" /></label><label>核验说明<textarea v-model.trim="notes" required maxlength="10000" rows="4" /></label><button type="button" @click="selected=undefined">取消</button><button :disabled="submitting">{{submitting?'提交中…':'保存审核'}}</button></form></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>