Compare commits

236 Commits

Author SHA1 Message Date
f4b2836c8a feat: improve chat feedback navigation 2026-08-31 11:41:56 +08:00
5740bd26e7 feat: improve audit exports and admin analytics 2026-08-25 18:49:56 +08:00
910e67bd89 feat: 完善人工关注与内容合规配置 2026-08-24 17:49:56 +08:00
6ffdd2c259 feat: 支持按权益名称导入用户 2026-08-19 15:11:49 +08:00
efe835be81 feat: 完善周期报告与用户行为分析
- 支持可配置周报月报模板与登录后异步补生成\n- 增加用户行为埋点和后台分析页面\n- 移除主题额度并保留实修回顾结算\n- 修复历史会话续聊上下文丢失
2026-08-19 11:53:34 +08:00
833763c461 feat: refine topic insights and learner experience 2026-08-17 12:42:16 +08:00
77399df060 feat: support configurable card variables and layouts 2026-08-14 13:36:53 +08:00
f952d6dc58 feat: 支持 Agent 并发批测与洞察导出 2026-08-13 17:25:22 +08:00
3faecab6ac feat: 增加 Agent Excel 批量测试 2026-08-12 17:00:50 +08:00
c4231f268b fix: 优化反馈日期筛选控件 2026-08-11 18:58:11 +08:00
83b259af2d feat: improve async feedback and export workflow 2026-08-11 18:45:39 +08:00
ff4ba1148b feat: reveal encrypted config secrets on demand 2026-08-11 18:20:22 +08:00
71541f9a22 fix: refresh voice config only on chat lifecycle 2026-08-11 17:49:00 +08:00
74213a363f fix: separate voice credentials and refresh feature toggle 2026-08-11 17:46:56 +08:00
fd2da26d10 feat: add admin permissions voice input analytics and feedback 2026-08-11 17:17:59 +08:00
3b9215cc3d feat: redesign system configuration workspace 2026-08-04 15:08:24 +08:00
c4de4e8c56 fix: optimize sso integration layout 2026-08-03 19:11:41 +08:00
cd3c875106 feat: isolate external application conversations 2026-08-03 18:57:19 +08:00
26109648af feat: 增加可信应用免登录接入 2026-08-03 18:28:40 +08:00
59c306f88f fix: 精简AI失败重试文案 2026-08-03 18:04:27 +08:00
1954f461af feat: 增加AI回复手动重试 2026-08-03 17:55:02 +08:00
8e6da99dcb feat: 提升并发容量默认配置 2026-08-03 17:42:17 +08:00
8ee4d65b7b feat: 增加内容生成配置管理 2026-08-03 17:23:42 +08:00
3f8ff30bbc fix: 完善个人中心卡片管理 2026-08-03 16:49:31 +08:00
8fb24f94ab fix: 优化个人中心卡片切换交互 2026-08-03 16:38:15 +08:00
861a0ae7d5 refactor: 拆分管理后台页面并按需加载 2026-08-03 16:22:03 +08:00
b2b56f43bb feat: 完成权益到期与续期闭环 2026-08-03 14:32:38 +08:00
20ed4875b8 feat: 新增用户端个人中心 2026-08-03 12:38:00 +08:00
7747334ea4 feat: 将成长档案调整为近期实修回顾 2026-08-03 12:25:51 +08:00
d2e08ab36c feat: 异步沉淀主题摘要和成长档案 2026-08-03 11:35:58 +08:00
a3946013f5 feat: 优化权益管理并完成一期验收 2026-07-31 18:49:48 +08:00
b6aff78aac feat: align agent preview with learner topics 2026-07-31 18:21:36 +08:00
ab2c945f0b feat: make chat model routing configurable 2026-07-31 17:57:26 +08:00
85a6da5949 feat: route fixed information chats by model 2026-07-31 17:42:25 +08:00
da313f88ed feat: route background ai workloads by model 2026-07-31 17:25:16 +08:00
0008903e8d feat: schedule periodic reports asynchronously 2026-07-31 17:11:21 +08:00
a589a25bdc feat: persist cleaned question insights 2026-07-31 16:50:11 +08:00
6c03faf10c feat: enrich question insights classification 2026-07-31 16:40:13 +08:00
99e23b33ec feat: add periodic practice reports 2026-07-31 16:37:25 +08:00
88ed92d78c feat: add admin user detail insights 2026-07-31 16:30:39 +08:00
763c78bd7e feat: expand user growth record view 2026-07-31 16:25:50 +08:00
a84650bb9e feat: add ai cost tracking foundation 2026-07-31 16:24:35 +08:00
ec9f8a015a feat: soften quota messaging 2026-07-31 16:10:35 +08:00
4e872febd5 feat: expose knowledge type priority traces 2026-07-31 16:09:27 +08:00
429636f3e8 feat: add class share drafts 2026-07-31 16:07:18 +08:00
d8fde93b69 feat: add teacher help cards 2026-07-31 15:53:47 +08:00
79094d0832 feat: align agent preview user context 2026-07-31 15:48:40 +08:00
3e60a6da42 feat: add topic summaries and growth profiles 2026-07-31 15:34:03 +08:00
884dc765ad feat: add entitlement plans and topic sessions 2026-07-31 15:22:11 +08:00
ad2161497f feat: add question insight analytics 2026-07-31 11:39:59 +08:00
e426f12e22 fix: highlight full knowledge search terms 2026-07-30 17:14:51 +08:00
1f82b89295 fix: improve chat detail page navigation 2026-07-30 15:32:56 +08:00
918ef5fa79 fix: speed up chat detail pagination 2026-07-30 15:21:26 +08:00
8e2dc1d781 fix: paginate chat detail messages 2026-07-30 15:09:05 +08:00
162658384f fix: deduplicate knowledge content search results 2026-07-30 14:45:20 +08:00
77a899e24c fix: align agent no-hit handling 2026-07-30 14:36:09 +08:00
d1f573108e feat: add knowledge content search 2026-07-30 11:02:04 +08:00
c2655361b5 feat: add agent response depth control 2026-07-20 11:36:18 +08:00
537978082a fix: preserve agent debug conversation context 2026-07-17 16:06:43 +08:00
cadf44e0b8 fix: allow direct knowledge deletion 2026-07-17 15:34:23 +08:00
821cb26930 feat: enforce knowledge type source hierarchy 2026-07-17 15:14:18 +08:00
d647a3d44b feat: apply knowledge type retrieval priority 2026-07-17 15:12:34 +08:00
9173267e09 fix(chat): refine markdown typography 2026-07-17 14:33:26 +08:00
c7bafda135 feat(agent): add debug output switches 2026-07-17 14:19:23 +08:00
a879dc2ff1 feat(agent): control reasoning visibility 2026-07-17 14:05:54 +08:00
bfaf2ebf67 feat(agent): stream admin debug preview 2026-07-17 13:47:05 +08:00
13fed0467a feat(chat): add configurable streaming output 2026-07-17 13:34:27 +08:00
f7076569a7 feat(knowledge): add batch synchronization 2026-07-17 13:20:41 +08:00
dde28aba83 fix(rag): route exercise titles to knowledge base 2026-07-17 12:31:56 +08:00
6f07c70f1f fix(rag): keep practice headings with details 2026-07-17 12:16:30 +08:00
e3634eb26c refactor(knowledge): keep latest version only 2026-07-16 18:24:49 +08:00
9ba8fb6e0c feat(knowledge): auto-publish successful syncs 2026-07-16 18:07:17 +08:00
921b56cfb9 fix(agent): keep response policy in managed prompt 2026-07-16 17:16:51 +08:00
1b5fcc4d52 fix(agent): enforce complete homework answers 2026-07-16 17:03:04 +08:00
6c02662d3b feat(agent): persist production generation settings 2026-07-16 16:07:11 +08:00
245b6b3eca fix(rag): recall complete course homework lists 2026-07-16 15:48:40 +08:00
cbbdb41b80 fix(admin): restore knowledge preview scrolling 2026-07-16 14:19:33 +08:00
c3f24d0ccd feat(admin): add knowledge content preview 2026-07-16 10:39:46 +08:00
56c9f2f359 fix(rag): retrieve complete course assignments 2026-07-15 22:09:52 +08:00
ba1840233c refactor(chat): simplify thinking status text 2026-07-13 20:18:42 +08:00
e4a391aa08 fix(agent): serialize retrieval trace responses 2026-07-13 19:37:12 +08:00
128d444a76 fix(admin): correct login page copyright 2026-07-13 17:32:39 +08:00
653317ca7d feat(admin): redesign branded login page 2026-07-13 17:28:27 +08:00
971440ebff fix: 修复添加学员启用开关布局 2026-07-13 16:05:28 +08:00
bbd19cf9ed fix: 修复会话上下文记忆链路 2026-07-13 15:43:29 +08:00
dda2e040ca feat: explain agent generation parameters 2026-07-13 15:17:09 +08:00
a8bb8b068c fix: show agent generation parameters directly 2026-07-13 14:47:56 +08:00
44ab895149 feat: add prompt history management 2026-07-13 14:06:45 +08:00
cb3b91b101 fix: move storage stats to dashboard 2026-07-13 13:32:41 +08:00
750a57a390 fix: invalidate agent cache on knowledge changes 2026-07-13 13:18:52 +08:00
00d53c2569 perf: paginate admin audit lists 2026-07-13 13:09:15 +08:00
0864f9305b fix: prevent agent debug from blocking chat 2026-07-13 12:27:07 +08:00
2453816364 fix: stabilize agent knowledge dropdown 2026-07-13 12:09:43 +08:00
120fe185f9 fix: complete admin knowledge audit improvements 2026-07-13 11:40:34 +08:00
82e0c02bac docs: 补充知识库交互修复验证结果 2026-07-12 15:56:02 +08:00
b5047ca7e4 fix: 恢复知识库开关并修复弹层组件 2026-07-12 15:51:45 +08:00
390f0a0cbf docs: 记录知识库开关与弹层交互修复 2026-07-12 15:47:34 +08:00
64a5052351 docs: 记录知识库管理闭环修复结果 2026-07-12 15:35:25 +08:00
e337897b7a fix: 修复知识库唯一约束迁移兼容性 2026-07-12 15:31:17 +08:00
8266d845c0 fix: 补齐知识库元数据管理与生命周期校验 2026-07-12 15:30:23 +08:00
c3639fe30d docs: 补充知识库管理操作闭环设计 2026-07-12 15:18:55 +08:00
16fa9d06c2 chore: 完成知识库迁移与检索验证收口 2026-07-11 19:30:08 +08:00
872233adc6 feat: 完善知识库版本管理与检索审计后台 2026-07-11 19:22:18 +08:00
6cadca8c8e feat: 切换Agent自主知识工具检索链路 2026-07-11 19:14:17 +08:00
d2d43de6d3 feat: 实现知识同步审核发布生命周期 2026-07-11 19:04:16 +08:00
73fdfed0e5 feat: 建立知识版本与检索追踪数据模型 2026-07-11 18:59:29 +08:00
0199dd8242 docs: 完成知识库与Agent重构设计 2026-07-11 18:55:10 +08:00
9bce095583 docs: 完成用户有效期控件验收记录 2026-07-11 17:05:55 +08:00
70be6ab657 fix: 统一用户有效期日期选择控件 2026-07-11 17:05:31 +08:00
85208972b7 docs: 记录用户有效期日期控件统一方案 2026-07-11 17:03:30 +08:00
3393d1a809 fix: admin-web vite.config 补充 API proxy 配置 2026-07-11 17:01:10 +08:00
a81f16f298 docs: 归档登录页设计验收记录 2026-07-11 16:44:58 +08:00
da795b10c3 docs: 完成用户端聊天页设计验收 2026-07-11 16:44:13 +08:00
10c829e62d fix: 修正用户端热更新连接配置 2026-07-11 16:44:05 +08:00
603fb70715 feat: 重构移动端用户聊天页面 2026-07-11 16:40:40 +08:00
4f75e11a8d docs: 记录用户端聊天页重构方案 2026-07-11 16:26:40 +08:00
d0d25d9e17 fix: expand ai request log text fields 2026-07-10 18:46:41 +08:00
b3cb4a1ca9 fix: add chat session summary migration 2026-07-10 18:34:08 +08:00
5844741f24 fix: Vite HMR WebSocket 通过 nginx 代理,修复域名访问时的 ws 连接失败 2026-07-10 18:01:08 +08:00
d9a8f42522 docs: 完成登录页设计验收记录 2026-07-10 17:21:37 +08:00
dc4b1bfce9 fix: 恢复登录手机号和短信倒计时 2026-07-10 17:13:21 +08:00
fc8a593393 fix: 修复登录表单移动端横向溢出 2026-07-10 17:11:22 +08:00
2e274dea51 feat: 重构用户端手机号验证码登录页 2026-07-10 17:08:31 +08:00
808ba74fc5 feat: 支持按需触发短信图形验证 2026-07-10 17:02:50 +08:00
1ab834733e docs: 记录用户端登录页正式改造方案 2026-07-10 17:01:56 +08:00
851c2a532a Revert "feat: 重构学员端登录页面——专业移动端H5登录体验"
This reverts commit b82a5a464f.
2026-07-10 14:35:53 +08:00
b82a5a464f feat: 重构学员端登录页面——专业移动端H5登录体验
LoginPanel.vue 完全重写:
- 品牌标题区:绿色圆角图标+主标题+副标题+装饰盾牌
- 表单卡片:白色圆角卡片承载手机号+短信验证码
- 图形验证码按需弹出:点击发送验证码时弹出模态弹窗
- 用户协议:自定义checkbox+协议弹窗(Tab切换)
- 倒计时持久化:localStorage按手机号存储,刷新不重置
- Toast提示:发送成功/失败/登录错误
- 辅助入口:收不到验证码/登录遇到问题弹窗
- 底部安全说明:盾牌图标+信任提示
- 输入过滤:只允许数字,16px字号防iOS缩放
- 响应式:最大420px居中,safe-area适配

styles.css:移除旧登录样式,保留全局基础
2026-07-10 14:34:11 +08:00
5ead280df0 fix: Vite dev server 配置 API 代理,解决 405 Method Not Allowed 2026-07-10 14:23:57 +08:00
83f67936c8 feat: 学员端短信验证码发送增加60秒倒计时 2026-07-10 13:51:44 +08:00
085deef9d3 docs: 完成第二批上线优化实施记录 2026-07-10 12:19:20 +08:00
75c54466d4 test: 增加生产环境冒烟检查 2026-07-10 12:18:35 +08:00
367127504f fix: 兼容最小权限数据库备份校验 2026-07-10 12:17:18 +08:00
6d164a77fe refactor: 拆分后台系统配置定义 2026-07-10 12:16:01 +08:00
0bc767933e feat: 完善数据库备份恢复流程 2026-07-10 12:15:25 +08:00
d7d9514348 feat: 增加结构化日志和服务就绪检查 2026-07-10 12:14:19 +08:00
ebaf3defa2 docs: 记录第二批上线优化和非向量检索决策 2026-07-10 12:12:42 +08:00
b759409580 docs: 完成生产上线风险修复记录 2026-07-10 12:02:31 +08:00
076fcb9f02 refactor: 拆分后台登录页面职责 2026-07-10 11:55:48 +08:00
7f835ec699 test: 补齐上线关键链路自动验证 2026-07-10 11:54:49 +08:00
fb39c9c543 feat: 增加生产环境容器编排 2026-07-10 11:50:45 +08:00
9fb3dad5bd feat: 加密并隐藏系统敏感配置 2026-07-10 11:49:21 +08:00
aedecb3aab feat: 增加登录限流和跨进程退出机制 2026-07-10 11:47:28 +08:00
5aca872129 fix: 修复聊天排队和每日额度重置 2026-07-10 11:45:37 +08:00
5819830e33 docs: 记录生产上线风险修复计划 2026-07-10 11:44:59 +08:00
f07335dab4 chore: 本地调试去掉 frpc sidecar 2026-07-09 19:00:26 +08:00
ef24868105 fix: 移除后台登录默认凭据 2026-07-09 16:45:09 +08:00
55333445f4 docs: 补充生产管理员初始化配置说明 2026-07-09 16:39:54 +08:00
53febbbf91 docs: 记录后台登录上线安全要求 2026-07-09 16:38:35 +08:00
7587da6d18 Add single domain gateway routing 2026-07-09 16:03:29 +08:00
664d1acbce Document single domain gateway routing 2026-07-09 15:56:54 +08:00
e661e53d0b ui: 学员端登录页删除logo 2026-07-09 15:16:48 +08:00
5bd737b064 Remove legacy hy_qa_asset_backend directory 2026-07-09 15:12:47 +08:00
769bd2b7b4 Replace dashboard date range picker 2026-07-09 15:11:24 +08:00
0b0ff6b9dc Document dashboard date control update 2026-07-09 15:09:13 +08:00
34e740bbda Replace record audit date picker controls 2026-07-09 15:06:26 +08:00
d9c7f9a309 Document record audit date control update 2026-07-09 15:01:20 +08:00
0ea75dea1d feat: 看板优化——中文标题+Token消耗+时间段筛选+品牌名统一
- 学员端:LoginPanel/App.vue 描述文案优化,去掉生硬表述
- 后台品牌统一:index.html/App.vue 标题改为'大本营千问千答'
- 看板tab:Dashboard -> 数据看板
- 看板新增:输入Token、输出Token、总Token 统计
- 看板支持按时间段筛选:后端API增加start/end参数,前端加日期选择器
- stats-grid布局从5列调整为4列,响应式改为2列
2026-07-09 14:58:45 +08:00
facb3a6a56 Rename product to Dabenying QA assistant 2026-07-09 12:28:55 +08:00
6c53566a4e Update assistant naming scope documentation 2026-07-09 12:25:38 +08:00
532d51370e Document Dabenying assistant naming update 2026-07-09 12:24:40 +08:00
cde7da572b Store login verification codes in Redis 2026-07-09 12:16:21 +08:00
c56a4b49e1 Show dialog for user SMS login errors 2026-07-09 12:12:15 +08:00
34d6dd01c7 Add graphic captcha before user SMS login 2026-07-09 12:09:10 +08:00
0e2706a4f4 Document user login graphic captcha requirement 2026-07-09 11:58:38 +08:00
2dcb43b717 feat: 添加 frpc sidecar 容器(内网穿透,随项目自动启动)
- docker-compose.dev.yml: 新增 frpc 服务,通过 .env 环境变量配置
- deploy/frpc.Dockerfile: 基于 Alpine 构建,下载 frp 0.68.1 二进制
- deploy/frpc-entrypoint.sh: 由环境变量生成 frpc.toml 配置文件
- .env.example: 环境变量说明(占位值,不含真实凭证)
- .gitignore: 忽略 .env 文件,防止敏感信息提交
2026-07-09 11:53:04 +08:00
c86b517063 Add Aliyun SMS settings and refresh admin login 2026-07-09 11:02:26 +08:00
d78d0ea75f Document Aliyun SMS and admin login redesign 2026-07-09 10:52:47 +08:00
e1a130177c Add AI request audit detail drawer 2026-07-09 10:44:52 +08:00
42569a1057 Document AI request detail audit interaction 2026-07-09 10:41:16 +08:00
f39e51a4d4 Show retrieved knowledge chunks in audit logs 2026-07-09 10:29:58 +08:00
d6b7ec1e52 Document AI retrieval chunk audit requirement 2026-07-09 10:27:03 +08:00
1c73e8dee6 ui: 知识库导入区域增加间距样式 2026-07-09 10:19:21 +08:00
67048a14e8 feat: 简化知识库导入——只输入NodeToken,自动获取标题和SpaceID
- admin_knowledge.py: /knowledge/import 改为单条导入,自动获取 title+space_id
- feishu_service.py: 新增 get_node_info() 公开方法
- App.vue: 简化 UI,只保留 NodeToken 输入框和导入按钮
- api.ts: 更新类型定义
2026-07-09 10:13:09 +08:00
76da6dd65a feat: 递归导入只需输入NodeToken,自动获取SpaceID
- feishu_service.py: import_nodes_from_parent 去掉 space_id 参数,
  通过 get_node_info 自动获取 space_id
- admin_knowledge.py: import API 去掉 spaceId 参数
- App.vue: 前端去掉 SpaceID 输入框,只需输入 NodeToken
2026-07-08 18:54:09 +08:00
3598e34819 feat: 知识库递归导入(从指定父节点,不需要空间权限)
- feishu_service.py: import_space_nodes 改为 import_nodes_from_parent
  从指定父节点递归获取子节点,不需要空间级别权限
- admin_knowledge.py: import API 增加 parent_node_token 参数
- api.ts/App.vue: 前端增加父节点 NodeToken 输入框
2026-07-08 18:50:46 +08:00
f4196612d4 ui: 刷新飞书缓存按钮移到知识库管理页面 2026-07-08 18:01:24 +08:00
5dfff52b04 feat: 后台一键刷新飞书缓存
- feishu_service.py: FeishuKnowledgeService 新增 clear_cache() 方法
- admin_settings.py: 新增 POST /admin/feishu/cache/clear API
- api.ts: 前端封装 clearFeishuCache API
- App.vue: Agent管理页面新增'刷新飞书缓存'按钮

管理员更新飞书文档后,点击按钮即可清空服务端内存缓存,
客户端下次提问时会重新读取最新内容。
2026-07-08 17:57:26 +08:00
61db0aa9b8 feat: 对话摘要+段落按需读取+think标签解析修复
- chat.py: ChatSession 新增 summary/summary_up_to_message_id 字段
- rag_service.py: Prompt 支持历史摘要+最近5轮原文拼接
- model_service.py: 新增 summarize() 方法,复用已启用模型生成摘要
- chat_service.py: 集成对话历史传递,8轮后触发摘要生成
- feishu_service.py: 段落级按需读取,按标题匹配+关键词打分+top5文档

摘要策略:
- 保留最近5轮原文保证追问连续性
- 更早历史由模型压缩成摘要(<=300字)
- 摘要失败不阻断主流程

飞书读取优化:
- 每个知识库最多读5个相关文档
- 每文档提取top10相关段落
- 按原文顺序拼接,不截断

Fix: _extract_openai_answer 重命名为 _extract_gemini_answer
避免同名函数覆盖导致MiniMax模型被错误解析为Gemini响应
2026-07-08 17:49:52 +08:00
efd03ff9df Add deployment tuning and chat load test 2026-07-08 17:45:18 +08:00
6464c61396 Document deployment tuning phase 2026-07-08 17:39:35 +08:00
c68af6f806 Cache Feishu retrieval data in Redis 2026-07-08 17:39:04 +08:00
70466a6a31 Document Redis Feishu cache scope 2026-07-08 17:36:44 +08:00
23d5f42972 Add Redis-backed chat queue 2026-07-08 17:36:20 +08:00
db1d83631b Document Redis concurrency phase 2026-07-08 17:28:02 +08:00
812ae80536 Add async Feishu retrieval for chat RAG 2026-07-08 17:26:10 +08:00
7a6068590e Document async Feishu retrieval plan 2026-07-08 17:21:56 +08:00
72bd2e1e02 Use async model streaming for chat 2026-07-08 17:20:29 +08:00
1aab53cb37 Document async model streaming plan 2026-07-08 17:16:38 +08:00
211cb6207e Document async optimization split 2026-07-08 17:15:15 +08:00
465a481e7a Stream chat completions from model providers 2026-07-08 17:13:53 +08:00
d476c54580 Document streaming optimization phase 2026-07-08 17:07:12 +08:00
6f3bbd3803 Implement chat queue fallback 2026-07-08 16:57:40 +08:00
10b6de0622 Document concurrency optimization plan 2026-07-08 16:48:51 +08:00
925ef0238d Support Feishu file node retrieval 2026-07-08 12:29:21 +08:00
7811e3ed1f Document Feishu file node retrieval issue 2026-07-08 12:26:22 +08:00
13185ab42b Align user chat with agent model flow 2026-07-08 12:18:38 +08:00
35011d2679 Document user chat agent alignment 2026-07-08 12:15:07 +08:00
fb0bbc346b Fix Feishu wiki child node retrieval 2026-07-08 12:08:23 +08:00
7be5dde1db Support Feishu credentials in system settings 2026-07-08 12:03:37 +08:00
aed6d8ffbf Document Feishu credentials in settings 2026-07-08 12:00:41 +08:00
9c76be70fe Document Feishu credential configuration 2026-07-08 11:59:04 +08:00
eda2c5af76 Clarify chat retrieval failures 2026-07-08 11:55:22 +08:00
ac72b6a94a Implement scoped real knowledge retrieval 2026-07-08 11:46:09 +08:00
ca50fa437c Document scoped real knowledge retrieval 2026-07-08 11:42:59 +08:00
6ecdc8011d Show all system settings explicitly 2026-07-08 11:35:52 +08:00
4b3ea7baa4 Document explicit system settings page 2026-07-08 11:34:15 +08:00
cd01e45fbd Implement admin Excel import settings and chat preview 2026-07-07 19:05:52 +08:00
d18d9fe9d8 Document admin import settings and preview changes 2026-07-07 18:53:19 +08:00
29f25dd767 Implement student roster login gating 2026-07-07 18:45:07 +08:00
4f11d8632c Document student roster login requirement 2026-07-07 18:40:21 +08:00
84da551cbe Fix model delete confirmation dialog styles 2026-07-07 18:35:19 +08:00
03b1810a90 Implement open knowledge access scope 2026-07-07 18:32:01 +08:00
8d5d36c5eb Document open knowledge access scope 2026-07-07 18:26:06 +08:00
7be9847d65 Complete admin operations management 2026-07-07 13:55:10 +08:00
da262f4596 Separate agent tuning from model setup 2026-07-07 13:31:20 +08:00
dada4aafcd Add quick LLM provider setup 2026-07-07 13:12:27 +08:00
bcdf91c85d Optimize admin web bundle 2026-07-07 13:03:00 +08:00
55b7c5b0ea Complete admin model management 2026-07-07 12:58:26 +08:00
08c301c136 Complete admin chat records 2026-07-07 12:17:23 +08:00
08dd371480 Add V2 phase six verification assets 2026-07-06 18:14:36 +08:00
62eeb3186c Add V2 admin console 2026-07-06 18:13:08 +08:00
301661e7b4 Add V2 external service providers 2026-07-06 18:03:29 +08:00
86cbed2437 Complete V2 user H5 flow 2026-07-06 18:00:21 +08:00
207340973c Add V2 RAG answer skeleton 2026-07-06 17:55:22 +08:00
96742de07b Add V2 local dev compose 2026-07-06 17:45:24 +08:00
e9f3db9ab2 Add Vue user H5 client 2026-07-06 17:34:37 +08:00
17cba7cf61 Scaffold V2 backend foundation 2026-07-06 17:25:06 +08:00
44af745828 Record Vue 3 client decision 2026-07-06 17:14:06 +08:00
b7dfa13872 Plan V2 phase one architecture 2026-07-06 17:09:33 +08:00
dbd59a84df Document V2 plan in Chinese 2026-07-06 17:02:16 +08:00
4eac7351a9 Start AI knowledge base v2 workspace 2026-07-06 16:55:03 +08:00
708e0a424e Hide H5 auth and document phase one scope 2026-07-05 19:55:22 +08:00
fa2d82333c Add WeChat auth entry for H5 2026-07-05 19:48:32 +08:00
a4b59add07 Use backend callable QA for H5 answers 2026-07-05 18:53:46 +08:00
cd73ed3001 Enhance H5 QA session interactions 2026-07-05 18:47:59 +08:00
34aeb22760 Add mobile H5 QA experience 2026-07-05 18:32:28 +08:00
483 changed files with 71639 additions and 6546 deletions

View File

@@ -0,0 +1,66 @@
# ============================================
# 飞书知识库配置
# ============================================
FEISHU_APP_ID=
FEISHU_APP_SECRET=
FEISHU_SEARCH_URL=
# ============================================
# 管理后台首个管理员初始化
# 生产环境必须替换为安全账号密码,不能使用 admin123456
# ============================================
APP_ENV=production
MYSQL_ROOT_PASSWORD=replace-with-strong-root-password
MYSQL_PASSWORD=replace-with-strong-database-password
REDIS_PASSWORD=replace-with-strong-redis-password
DATABASE_URL=mysql+pymysql://ai_kb:URL编码后的数据库密码@mysql:3306/ai_knowledge_base_v2?charset=utf8mb4
REDIS_URL=redis://:URL编码后的Redis密码@redis:6379/0
JWT_SECRET_KEY=replace-with-at-least-32-random-characters
# 使用命令生成python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
CONFIG_ENCRYPTION_KEY=replace-with-generated-fernet-key
BOOTSTRAP_ADMIN_USERNAME=admin
BOOTSTRAP_ADMIN_PASSWORD=replace-with-strong-password
BOOTSTRAP_ADMIN_NAME=系统管理员
PERIODIC_REPORT_WORKER_ENABLED=true
PERIODIC_REPORT_POLL_SECONDS=5
PERIODIC_REPORT_STALE_MINUTES=30
PERIODIC_REPORT_MAX_ATTEMPTS=3
PERIODIC_REPORT_WEEKLY_ENABLED=true
PERIODIC_REPORT_MONTHLY_ENABLED=true
PERIODIC_REPORT_TIMEZONE=Asia/Shanghai
PERIODIC_REPORT_GLOBAL_SCHEDULE_ENABLED=false
PERIODIC_REPORT_LAZY_CHECK_ENABLED=true
PERIODIC_REPORT_LAZY_CHECK_LOCK_SECONDS=60
PERIODIC_REPORT_WEEKLY_BACKFILL_LIMIT=26
PERIODIC_REPORT_MONTHLY_BACKFILL_LIMIT=6
PERIODIC_REPORT_FEATURE_START=2026-07-31T00:00:00+08:00
PERIODIC_REPORT_SOURCE_CHUNK_CHARS=18000
USER_BEHAVIOR_RETENTION_DAYS=30
TOPIC_SETTLEMENT_WORKER_ENABLED=true
TOPIC_SETTLEMENT_POLL_SECONDS=2
TOPIC_SETTLEMENT_STALE_MINUTES=30
TOPIC_SETTLEMENT_MAX_ATTEMPTS=3
AGENT_BATCH_WORKER_ENABLED=true
AGENT_BATCH_POLL_SECONDS=2
AGENT_BATCH_STALE_MINUTES=30
# 单个后端进程同时执行的批量测试问题上限;任务自身还受页面选择的 2-10 路限制。
AGENT_BATCH_WORKER_CONCURRENCY=10
# ============================================
# 内网穿透frpc sidecar
# 启动 docker compose 前,拷贝本文件为 .env 并填写
# 或直接在 shell 中 export 这些变量
# ============================================
FRP_SERVER_ADDR=your-server-ip
FRP_SERVER_PORT=7000
FRP_AUTH_TOKEN=your-auth-token
FRP_PROXY_NAME=qa-web
# 默认穿透统一入口 gateway:80由 gateway 按路径转发到用户端、后台和 API
FRP_LOCAL_IP=gateway
FRP_LOCAL_PORT=80
# 自定义域名逗号分隔qa.huiyushuyuan.cn
FRP_CUSTOM_DOMAINS=qa.huiyushuyuan.cn
ALIYUN_NLS_APP_KEY=
ALIYUN_NLS_ACCESS_KEY_ID=
ALIYUN_NLS_ACCESS_KEY_SECRET=
ALIYUN_NLS_ENDPOINT=https://nls-gateway-cn-shanghai.aliyuncs.com/stream/v1/asr

2
ai_knowledge_base_v2/.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
.env
outputs/

View File

@@ -0,0 +1,51 @@
# AI 知识库系统 V2 工作区
这个目录用于承载新需求版本:**AI 企业知识库问答系统**。
它和旧的 `hy_qa_asset_backend/docs` 目录刻意分开。旧项目的目标是“答疑资产后台系统”,核心是把大本营答疑内容清洗、审核、沉淀成可调用问答;新需求的目标是“企业飞书知识库 AI 问答系统”,核心是登录、权限、知识库、聊天会话、实时飞书检索和 RAG 问答。
## 目录说明
- `source_documents/`:保存原始需求资料,包括 PRD、SRS、API、数据库、RAG、后台原型、实施计划、测试、验收、部署、字段字典、权限矩阵。
- `extracted/`:从原始 Word/Excel 中抽取出的 Markdown主要用于开发检索和上下文阅读。
- `development_records/`:保存新项目开发过程记录、范围决策、架构决策、实施计划和阶段性说明。
- `reports/`:保存评估报告、方案报告和后续技术调研结论。
## 当前目标
按照已上传的 AI 知识库系统需求包,重新规划和开发一版新系统:
- 用户端手机号验证码登录、学员名单准入、AI 聊天、多会话、历史聊天、流式回答、停止生成、Markdown 渲染、开放知识库过滤。
- 管理后台管理员登录、用户管理、学员手动添加、学员批量导入、管理员管理、知识库管理、Prompt 管理、模型管理、聊天记录、数据统计、系统配置、操作日志。
- AI/RAG一期以飞书知识库作为唯一知识来源每次提问实时检索按知识库开放状态过滤已开放知识库默认所有用户可见组装 Prompt调用大模型通过 SSE 流式输出;记录 AI 请求日志。
- 一期不做文件上传、图片理解、OCR、联网搜索、向量数据库同步、支付系统、课程系统、自动授权、多租户、多 Prompt、重新生成、编辑消息。
## 工作原则
- V2 先作为独立工作区推进,避免和旧项目文档混在一起。
- 上传的原始文档是 V2 的需求依据。
- 给用户、团队、交接和评审看的文档统一使用中文。
- `extracted/` 里的机器抽取文档可以保留原始语言或工程化表达,优先服务开发检索。
- 旧代码只在领域边界匹配时复用,不把旧的“答疑资产审核模型”硬套到新的“开放知识库问答模型”上。
## 本地开发启动
阶段二已经提供本地开发编排:
```bash
cd ai_knowledge_base_v2
docker compose -f docker-compose.dev.yml up --build
```
默认访问地址:
- 统一入口:`http://127.0.0.1:8080/`
- 用户端 H5`http://127.0.0.1:8080/`
- 管理后台:`http://127.0.0.1:8080/login`
- 后端 API`http://127.0.0.1:8080/api/health`
- 用户端直连调试:`http://127.0.0.1:5173/`
- 管理后台直连调试:`http://127.0.0.1:5174/login/`
- 后端直连调试:`http://127.0.0.1:8100/api/health`
- MySQL`127.0.0.1:3307`
开发阶段 mock 短信验证码为 `123456`

View File

@@ -0,0 +1,5 @@
node_modules/
dist/
.env
.env.local
*.tsbuildinfo

View File

@@ -0,0 +1 @@
VITE_API_BASE_URL=/api

View File

@@ -0,0 +1,5 @@
node_modules/
dist/
*.tsbuildinfo
.env
.env.local

View File

@@ -0,0 +1,12 @@
FROM node:22-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
EXPOSE 5174
CMD ["npm", "run", "dev", "--", "--host", "0.0.0.0", "--port", "5174"]

View File

@@ -0,0 +1,12 @@
FROM node:22-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM nginx:1.27-alpine
COPY nginx.prod.conf /etc/nginx/conf.d/default.conf
COPY --from=builder /app/dist /usr/share/nginx/html
EXPOSE 80

View File

@@ -0,0 +1,33 @@
# 大本营答疑助手管理后台
这是大本营答疑助手的管理后台,使用 Vue 3、Vite、TypeScript 和 Element Plus。
## 本地启动
```bash
npm install
npm run dev -- --port 5174
```
默认访问地址:
```text
http://127.0.0.1:8080/login
```
默认后端地址:
```text
/api
```
## 管理员初始化
开发阶段后端会在首次管理员登录时自动创建默认管理员:
```text
账号admin
密码admin123456
```
管理后台登录页不会预填账号密码。生产环境必须通过环境变量配置安全的首个管理员账号密码,不能使用开发默认密码。

View File

@@ -0,0 +1,12 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>大本营千问千答</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

View File

@@ -0,0 +1,15 @@
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
location / {
try_files $uri $uri/ /index.html;
}
location ~* \.(?:js|css|png|jpg|jpeg|gif|svg|ico|woff2?)$ {
expires 7d;
add_header Cache-Control "public, immutable";
try_files $uri =404;
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,24 @@
{
"name": "ai-kb-admin-web",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite --host 127.0.0.1",
"build": "vue-tsc -b && vite build",
"preview": "vite preview --host 127.0.0.1"
},
"dependencies": {
"@vitejs/plugin-vue": "^6.0.1",
"echarts": "^6.1.0",
"element-plus": "^2.11.0",
"markdown-it": "^14.3.0",
"vite": "^7.1.0",
"vue": "^3.5.0"
},
"devDependencies": {
"@types/markdown-it": "^14.1.2",
"typescript": "^5.9.0",
"vue-tsc": "^3.1.0"
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,136 @@
<script setup lang="ts">
import { reactive } from "vue";
defineProps<{ loading: boolean }>();
const emit = defineEmits<{
submit: [credentials: { username: string; password: string }];
}>();
const form = reactive({ username: "", password: "" });
function submit() {
emit("submit", { username: form.username.trim(), password: form.password });
}
</script>
<template>
<main class="login-page">
<section class="login-hero" aria-label="大本营千问千答后台登录">
<section class="login-hero-main" aria-label="产品介绍">
<header class="login-brand">
<span class="login-brand-mark" aria-hidden="true">
<svg viewBox="0 0 64 64" role="presentation">
<circle cx="32" cy="32" r="28" />
<path d="M32 9c4 7 8 10 15 11-1 7 1 12 7 17-6 4-9 9-9 16-7-1-12 1-17 6-4-6-9-9-16-9 1-7-1-12-6-17 6-4 9-9 9-16 7 1 12-1 17-6 4 6 9 9 16 9Z" />
<path d="M32 19c2.5 5 5.5 7.5 11 8-4 3.5-5.5 7.5-4 13-4.7-2.5-8.7-2.5-13 0 1.5-5.5 0-9.5-4-13 5.5-.5 8.5-3 10-8Z" />
<circle cx="32" cy="32" r="3" />
</svg>
</span>
<span class="login-brand-copy">
<strong>大本营千问千答</strong>
<small>智能知识管理与问答系统</small>
</span>
</header>
<div class="login-orbit" aria-hidden="true"><i></i></div>
<div class="login-dot-matrix" aria-hidden="true"></div>
<div class="login-brand-content">
<h1>
把大本营答疑知识库和学员<br />
运营放在<span>同一个控制台</span>
</h1>
<p>
统一维护学员名单开放知识库模型接入Agent 调试问答记录和系统运行参数让日常运营更清晰也让排查与交接更从容
</p>
<div class="login-highlights" aria-label="核心能力">
<div class="login-highlight-item">
<span aria-hidden="true">
<svg viewBox="0 0 24 24"><rect x="5" y="3.5" width="13" height="17" rx="2" /><path d="M8.5 7.5h5M8.5 11.5h6M8.5 15.5h3.5M18 7h2v11a2 2 0 0 1-2 2" /></svg>
</span>
<strong>大本营知识库</strong>
</div>
<div class="login-highlight-item">
<span aria-hidden="true">
<svg viewBox="0 0 24 24"><rect x="4" y="7" width="16" height="12" rx="4" /><path d="M9 12h.01M15 12h.01M8 16h8M12 7V4M10.5 3h3" /></svg>
</span>
<strong>答疑 Agent 调试</strong>
</div>
<div class="login-highlight-item">
<span aria-hidden="true">
<svg viewBox="0 0 24 24"><path d="M5 5.5h14v11H9l-4 3v-14Z" /><path d="M8.5 9.5h7M8.5 13h4" /></svg>
</span>
<strong>问答记录审计</strong>
</div>
<div class="login-highlight-item">
<span aria-hidden="true">
<svg viewBox="0 0 24 24"><path d="M12 3.5 15 5l3.3-.2.2 3.3 1.7 2.9-2 2.7-.5 3.3-3.3.5-2.4 2.3-2.4-2.3-3.3-.5-.5-3.3-2-2.7 1.7-2.9.2-3.3L9 5l3-1.5Z" /><circle cx="12" cy="11.7" r="3" /></svg>
</span>
<strong>系统运行管理</strong>
</div>
</div>
</div>
<svg class="login-wave" viewBox="0 0 920 250" preserveAspectRatio="none" aria-hidden="true">
<path d="M-20 45C105 25 110 150 250 151S410 85 530 118s185 97 410 18" />
<path d="M-20 62C95 40 127 169 264 167s160-64 277-28 180 82 399 18" />
<path d="M-20 80C90 57 140 188 278 184s159-60 273-23 176 69 389 17" />
<path d="M-20 105C88 78 142 209 295 202s168-54 278-16 165 56 367 13" />
<path d="M-20 133C95 102 157 224 315 220s164-46 282-11 158 43 343 12" />
</svg>
<svg class="login-lotus" viewBox="0 0 360 300" aria-hidden="true">
<g>
<path d="M180 258C126 219 119 159 180 72c61 87 54 147 0 186Z" />
<path d="M177 260C112 244 78 196 99 99c75 58 98 108 78 161Z" />
<path d="M183 260c65-16 99-64 78-161-75 58-98 108-78 161Z" />
<path d="M162 264C92 266 47 232 34 143c78 30 119 70 128 121Z" />
<path d="M198 264c70 2 115-32 128-121-78 30-119 70-128 121Z" />
<path d="M119 275c-45 5-80-8-111-41 56 3 97 15 111 41ZM241 275c45 5 80-8 111-41-56 3-97 15-111 41Z" />
</g>
</svg>
</section>
<section class="login-card" aria-labelledby="login-title">
<div class="login-card-inner">
<div class="login-card-head">
<span class="login-card-eyebrow">ADMIN CONSOLE</span>
<h2 id="login-title">欢迎登录</h2>
<p>登录大本营答疑助手后台管理系统</p>
</div>
<el-form label-position="top" autocomplete="off" @submit.prevent="submit">
<el-form-item label="管理员账号">
<el-input v-model="form.username" size="large" placeholder="请输入管理员账号" autocomplete="off">
<template #prefix>
<svg class="login-input-icon" viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="8" r="3.5" /><path d="M5.5 19c.6-4 2.8-6 6.5-6s5.9 2 6.5 6" /></svg>
</template>
</el-input>
</el-form-item>
<el-form-item label="密码">
<el-input
v-model="form.password"
size="large"
type="password"
placeholder="请输入密码"
autocomplete="new-password"
show-password
>
<template #prefix>
<svg class="login-input-icon" viewBox="0 0 24 24" aria-hidden="true"><rect x="5" y="10" width="14" height="10" rx="2" /><path d="M8 10V7a4 4 0 0 1 8 0v3M12 14v2.5" /></svg>
</template>
</el-input>
</el-form-item>
<el-button type="primary" native-type="submit" :loading="loading" class="full-btn login-submit">
进入答疑后台
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="m9 6 6 6-6 6" /></svg>
</el-button>
</el-form>
<p class="login-security-note"><span aria-hidden="true"></span>仅限已授权的后台管理员访问</p>
</div>
</section>
</section>
<p class="login-copyright">© 2026 深圳市慧愈文化科技有限公司</p>
</main>
</template>

View File

@@ -0,0 +1,109 @@
<script setup lang="ts">
import { ElMessage, ElMessageBox, type ElTree } from "element-plus";
import { nextTick, onMounted, reactive, ref } from "vue";
import { api } from "../services/api";
import type { ManagedAdmin, PermissionNode } from "../types/api";
const admins = ref<ManagedAdmin[]>([]);
const tree = ref<PermissionNode[]>([]);
const permissionTree = ref<InstanceType<typeof ElTree>>();
const dialogVisible = ref(false);
const saving = ref(false);
const editingId = ref<number | null>(null);
const form = reactive({ username: "", name: "", initialPassword: "", resetPassword: "", status: 1 });
async function load() {
[admins.value, tree.value] = await Promise.all([api.administrators(), api.permissionTree()]);
}
function openCreate() {
editingId.value = null;
Object.assign(form, { username: "", name: "", initialPassword: "", resetPassword: "", status: 1 });
dialogVisible.value = true;
nextTick(() => permissionTree.value?.setCheckedKeys([]));
}
function openEdit(item: ManagedAdmin) {
editingId.value = item.id;
Object.assign(form, { username: item.username, name: item.name, initialPassword: "", resetPassword: "", status: item.status });
dialogVisible.value = true;
nextTick(() => permissionTree.value?.setCheckedKeys(item.permissions));
}
function selectedPermissions() {
return (permissionTree.value?.getCheckedKeys(false) ?? []).map(String).filter((code) => code.includes("."));
}
async function save() {
if (!form.name.trim() || (!editingId.value && (!form.username.trim() || form.initialPassword.length < 8))) {
ElMessage.warning("请完整填写账号、姓名和至少 8 位的初始密码");
return;
}
saving.value = true;
try {
const permissions = selectedPermissions();
if (editingId.value) {
await api.updateAdministrator(editingId.value, { name: form.name, status: form.status, permissions, resetPassword: form.resetPassword || null });
} else {
await api.createAdministrator({ username: form.username, name: form.name, initialPassword: form.initialPassword, status: form.status, permissions });
}
ElMessage.success(editingId.value ? "管理员已更新" : "管理员已创建");
dialogVisible.value = false;
await load();
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : "保存失败");
} finally { saving.value = false; }
}
async function remove(item: ManagedAdmin) {
await ElMessageBox.confirm(`确定删除管理员“${item.name}”?删除后将无法登录。`, "删除管理员", { type: "warning", confirmButtonText: "确定删除" });
await api.deleteAdministrator(item.id);
ElMessage.success("管理员已删除");
await load();
}
onMounted(load);
</script>
<template>
<div class="admin-management">
<div class="page-head inline">
<div><h2>管理员与权限</h2><p>按页面和功能分配权限未授权的页面不会出现后端也会拒绝越权请求</p></div>
<el-button type="primary" @click="openCreate">+新增管理员</el-button>
</div>
<el-card shadow="never">
<el-table :data="admins" stripe>
<el-table-column prop="name" label="管理员" min-width="150" />
<el-table-column prop="username" label="登录账号" min-width="160" />
<el-table-column label="类型" width="130"><template #default="{ row }"><el-tag :type="row.isSuperAdmin ? 'danger' : 'info'">{{ row.isSuperAdmin ? '超级管理员' : '普通管理员' }}</el-tag></template></el-table-column>
<el-table-column label="权限" min-width="150"><template #default="{ row }">{{ row.isSuperAdmin ? '全部权限' : `${row.permissions.length} 项功能` }}</template></el-table-column>
<el-table-column label="状态" width="120"><template #default="{ row }"><el-tag :type="row.status === 1 ? 'success' : 'info'">{{ row.status === 1 ? '正常' : '已停用' }}</el-tag></template></el-table-column>
<el-table-column label="密码状态" width="150"><template #default="{ row }">{{ row.isSuperAdmin ? '环境变量托管' : (row.mustChangePassword ? '待首次修改' : '已修改') }}</template></el-table-column>
<el-table-column label="操作" width="150" fixed="right"><template #default="{ row }"><template v-if="!row.isSuperAdmin"><el-button link type="primary" @click="openEdit(row)">编辑</el-button><el-button link type="danger" @click="remove(row)">删除</el-button></template><span v-else class="locked">系统托管</span></template></el-table-column>
</el-table>
</el-card>
<el-dialog v-model="dialogVisible" class="admin-editor-dialog" :title="editingId ? '编辑管理员' : '新增管理员'" width="820px" destroy-on-close>
<el-form class="admin-editor-form" label-position="top">
<section class="form-section">
<div class="section-title"><strong>基本信息</strong><span>用于登录和识别管理员</span></div>
<div class="form-grid">
<el-form-item label="登录账号"><el-input v-model="form.username" :disabled="!!editingId" placeholder="英文、数字、点、下划线或短横线" /></el-form-item>
<el-form-item label="管理员姓名"><el-input v-model="form.name" placeholder="请输入真实姓名或职位名称" /></el-form-item>
<el-form-item :label="editingId ? '重置密码(可选)' : '初始密码'"><el-input v-if="editingId" v-model="form.resetPassword" show-password placeholder="留空表示不重置" /><el-input v-else v-model="form.initialPassword" show-password placeholder="至少 8 位,首次登录后强制修改" /></el-form-item>
<el-form-item label="账号状态"><div class="status-control"><el-switch v-model="form.status" :active-value="1" :inactive-value="0" /><span>{{ form.status === 1 ? '正常,可以登录' : '已停用,无法登录' }}</span></div></el-form-item>
</div>
</section>
<section class="form-section permission-section">
<div class="section-title"><strong>功能权限</strong><span>勾选一级菜单可快速全选也可逐项精细分配</span></div>
<div class="permission-box"><el-tree ref="permissionTree" :data="tree.filter(n => !n.superOnly)" node-key="code" show-checkbox default-expand-all :props="{ label: 'name', children: 'children' }" /></div>
</section>
</el-form>
<template #footer><el-button @click="dialogVisible = false">取消</el-button><el-button type="primary" :loading="saving" @click="save">保存管理员</el-button></template>
</el-dialog>
</div>
</template>
<style scoped>
.admin-editor-form{display:grid;gap:22px}.form-section{padding:18px 20px;border:1px solid #e3e9e6;border-radius:12px;background:#fff}.section-title{display:flex;align-items:baseline;gap:10px;margin-bottom:18px}.section-title strong{font-size:16px}.section-title span{color:#7c8d87;font-size:13px}.form-grid{display:grid;grid-template-columns:1fr 1fr;column-gap:24px;row-gap:18px}.admin-editor-form :deep(.el-form-item){margin-bottom:0}.admin-editor-form :deep(.el-form-item__label){height:auto;padding:0 0 8px;color:#465650;font-weight:600;line-height:1.4}.status-control{display:flex;align-items:center;gap:10px;height:40px}.status-control span{color:#687a74;font-size:13px}.permission-section{background:#f8faf9}.permission-box{width:100%;max-height:390px;overflow:auto;padding:12px 14px;border:1px solid #dfe6e3;border-radius:9px;background:#fff}.locked{color:#909399}.admin-management :deep(.el-tree-node__content){height:34px;border-radius:6px}.admin-management :deep(.el-tree-node__content:hover){background:#eef6f3}.admin-management :deep(.el-dialog__body){max-height:calc(100vh - 190px);padding:20px 24px;overflow:auto}.admin-management :deep(.el-dialog__header){padding:22px 24px 16px;border-bottom:1px solid #edf1ef}.admin-management :deep(.el-dialog__footer){padding:16px 24px 20px;border-top:1px solid #edf1ef}@media(max-width:760px){.form-grid{grid-template-columns:1fr}.section-title{align-items:flex-start;flex-direction:column;gap:4px}}
</style>

View File

@@ -0,0 +1,22 @@
<script setup lang="ts">
defineProps<{ page: number; pageSize: number; total: number }>();
const emit = defineEmits<{ change: [page: number, pageSize: number] }>();
function changePage(page: number, pageSize: number) { emit("change", page, pageSize); }
function changeSize(pageSize: number) { emit("change", 1, pageSize); }
</script>
<template>
<div class="admin-pagination">
<el-pagination
background
:current-page="page"
:page-size="pageSize"
:page-sizes="[10, 20, 50, 100]"
:total="total"
layout="total, sizes, prev, pager, next, jumper"
@current-change="changePage($event, pageSize)"
@size-change="changeSize"
/>
</div>
</template>

View File

@@ -0,0 +1,242 @@
<script setup lang="ts">
import { ElMessage, ElMessageBox } from "element-plus";
import { computed, onBeforeUnmount, onMounted, reactive, ref } from "vue";
import { api } from "../services/api";
import type { AgentBatchItem, AgentBatchJob, AgentBatchJobDetail } from "../types/api";
import AdminPagination from "./AdminPagination.vue";
import { formatDateTime } from "../utils/dateTime";
const props = defineProps<{
config: Record<string, unknown>;
modelName: string;
knowledgeSummary: string;
}>();
const fileInput = ref<HTMLInputElement | null>(null);
const selectedFile = ref<File | null>(null);
const taskName = ref("");
const executionMode = ref<"sequential" | "concurrent">("sequential");
const concurrencyLimit = ref(5);
const creating = ref(false);
const loading = ref(false);
const exportingId = ref<number | null>(null);
const jobs = ref<AgentBatchJob[]>([]);
const pager = reactive({ page: 1, pageSize: 10, total: 0 });
const detailOpen = ref(false);
const detailLoading = ref(false);
const selectedJob = ref<AgentBatchJobDetail | null>(null);
const detailPager = reactive({ page: 1, pageSize: 20, total: 0 });
let pollTimer: number | null = null;
const hasRunningJob = computed(() => jobs.value.some((job) => ["pending", "running"].includes(job.status)));
onMounted(() => void loadJobs());
onBeforeUnmount(stopPolling);
async function loadJobs(page = pager.page, pageSize = pager.pageSize, quiet = false) {
if (!quiet) loading.value = true;
try {
const result = await api.agentBatchJobs({ page, pageSize });
jobs.value = result.items;
Object.assign(pager, { page: result.page, pageSize: result.pageSize, total: result.total });
schedulePolling();
if (selectedJob.value && detailOpen.value) await loadDetail(selectedJob.value.id, detailPager.page, detailPager.pageSize, true);
} catch (error) {
if (!quiet) ElMessage.error(errorMessage(error, "批量测试任务加载失败"));
} finally {
if (!quiet) loading.value = false;
}
}
function schedulePolling() {
stopPolling();
if (!hasRunningJob.value) return;
pollTimer = window.setTimeout(() => void loadJobs(pager.page, pager.pageSize, true), 2500);
}
function stopPolling() {
if (pollTimer !== null) window.clearTimeout(pollTimer);
pollTimer = null;
}
function selectFile(event: Event) {
const input = event.target as HTMLInputElement;
selectedFile.value = input.files?.[0] || null;
if (selectedFile.value && !taskName.value.trim()) taskName.value = selectedFile.value.name.replace(/\.xlsx$/i, "");
}
function clearFile() {
selectedFile.value = null;
if (fileInput.value) fileInput.value.value = "";
}
async function downloadTemplate() {
try {
await api.downloadAgentBatchTemplate();
} catch (error) {
ElMessage.error(errorMessage(error, "模板下载失败"));
}
}
async function createJob() {
if (!selectedFile.value) return ElMessage.warning("请先选择填写好的 Excel 文件");
if (!String(props.config.modelId || "")) return ElMessage.warning("请先在调试配置中选择模型");
creating.value = true;
try {
const created = await api.createAgentBatch(selectedFile.value, {
...props.config,
name: taskName.value.trim(),
executionMode: executionMode.value,
concurrencyLimit: executionMode.value === "sequential" ? 1 : concurrencyLimit.value,
});
ElMessage.success(`已创建批量测试任务,共 ${created.totalCount} 个问题`);
clearFile();
taskName.value = "";
await loadJobs(1, pager.pageSize);
await openDetail(created);
} catch (error) {
ElMessage.error(errorMessage(error, "批量测试任务创建失败"));
} finally {
creating.value = false;
}
}
async function openDetail(job: AgentBatchJob) {
detailOpen.value = true;
detailPager.page = 1;
await loadDetail(job.id, 1, detailPager.pageSize);
}
async function loadDetail(jobId: number, page = detailPager.page, pageSize = detailPager.pageSize, quiet = false) {
if (!quiet) detailLoading.value = true;
try {
const result = await api.agentBatchJob(jobId, { page, pageSize });
selectedJob.value = result;
Object.assign(detailPager, { page: result.items.page, pageSize: result.items.pageSize, total: result.items.total });
} catch (error) {
if (!quiet) ElMessage.error(errorMessage(error, "任务详情加载失败"));
} finally {
if (!quiet) detailLoading.value = false;
}
}
async function exportJob(job: AgentBatchJob) {
exportingId.value = job.id;
try {
await api.exportAgentBatch(job.id);
ElMessage.success("测试结果已导出");
} catch (error) {
ElMessage.error(errorMessage(error, "结果导出失败"));
} finally {
exportingId.value = null;
}
}
async function cancelJob(job: AgentBatchJob) {
try {
await ElMessageBox.confirm("取消后尚未开始的问题不会继续生成,已完成结果仍可导出。", "取消批量测试", {
type: "warning", confirmButtonText: "确认取消", cancelButtonText: "继续执行",
});
} catch {
return;
}
try {
await api.cancelAgentBatch(job.id);
ElMessage.success("批量测试已取消");
await loadJobs(pager.page, pager.pageSize);
} catch (error) {
ElMessage.error(errorMessage(error, "取消失败"));
}
}
function statusLabel(status: string) {
return ({ pending: "等待执行", running: "生成中", completed: "已完成", completed_with_errors: "部分失败", cancelled: "已取消", success: "成功", failed: "失败" } as Record<string, string>)[status] || status;
}
function statusType(status: string) {
if (["completed", "success"].includes(status)) return "success";
if (["completed_with_errors", "failed"].includes(status)) return "danger";
if (status === "running") return "primary";
if (status === "cancelled") return "info";
return "warning";
}
function executionLabel(job: AgentBatchJob) {
return job.executionMode === "concurrent" ? `并发 · ${job.concurrencyLimit}` : "逐条串行";
}
function itemDuration(item: AgentBatchItem) {
return item.durationMs == null ? "—" : `${(item.durationMs / 1000).toFixed(1)}`;
}
function errorMessage(error: unknown, fallback: string) {
return error instanceof Error ? error.message : fallback;
}
</script>
<template>
<div class="agent-batch-panel">
<div class="agent-section-intro">
<div><h3>Excel 批量测试</h3><p>普通管理员仅看到自己的任务超级管理员可统一审计创建时会固化当前提示词模型知识库和生成参数</p></div>
<el-button @click="downloadTemplate">下载导入模板</el-button>
</div>
<section class="agent-batch-create-card">
<div class="agent-batch-config-summary">
<span><small>模型</small><strong>{{ modelName }}</strong></span>
<span><small>知识库</small><strong>{{ knowledgeSummary }}</strong></span>
<span><small>单次上限</small><strong>200 个问题</strong></span>
</div>
<div class="agent-batch-mode-setting">
<div class="agent-batch-mode-copy"><strong>执行方式</strong><span>串行适合日常回归并发适合压测吞吐与稳定性并发数会被系统安全上限约束</span></div>
<el-radio-group v-model="executionMode" class="agent-batch-mode-options">
<el-radio-button value="sequential">逐条串行</el-radio-button>
<el-radio-button value="concurrent">并发测试</el-radio-button>
</el-radio-group>
<label v-if="executionMode === 'concurrent'" class="agent-batch-concurrency"><span>并发数</span><el-input-number v-model="concurrencyLimit" :min="2" :max="10" controls-position="right" /></label>
</div>
<div class="agent-batch-upload-row">
<label class="agent-batch-name"><span>任务名称</span><input v-model="taskName" maxlength="160" placeholder="例如8月主提示词回归测试" /></label>
<label class="agent-batch-file" :class="{ selected: selectedFile }">
<input ref="fileInput" type="file" accept=".xlsx" @change="selectFile" />
<span>{{ selectedFile?.name || "选择填写好的 Excel 文件" }}</span>
<small>{{ selectedFile ? `${(selectedFile.size / 1024).toFixed(1)} KB` : "仅支持 .xlsx最大 5MB" }}</small>
</label>
<el-button v-if="selectedFile" @click="clearFile">移除</el-button>
<el-button type="primary" :loading="creating" :disabled="!selectedFile" @click="createJob">开始批量测试</el-button>
</div>
</section>
<div class="agent-batch-list-head"><div><h4>测试记录</h4><p>任务在后台执行离开页面不会中断</p></div><el-button :loading="loading" @click="loadJobs()">刷新</el-button></div>
<el-table v-loading="loading" :data="jobs" stripe>
<el-table-column label="任务" min-width="190"><template #default="{ row }"><strong>{{ row.name }}</strong><small class="agent-batch-cell-note">#{{ row.id }} · {{ formatDateTime(row.createdAt, "—") }}</small></template></el-table-column>
<el-table-column label="配置快照" min-width="180"><template #default="{ row }"><span>{{ row.modelName }}</span><small class="agent-batch-cell-note">{{ row.knowledgeNames.join('、') }}</small></template></el-table-column>
<el-table-column label="进度" min-width="190"><template #default="{ row }"><el-progress :percentage="row.progress" :status="row.status === 'completed' ? 'success' : undefined" /><small class="agent-batch-cell-note">{{ row.processedCount }}/{{ row.totalCount }} · 成功 {{ row.successCount }} · 失败 {{ row.failedCount }}</small></template></el-table-column>
<el-table-column label="执行方式" width="118"><template #default="{ row }"><el-tag type="info">{{ executionLabel(row) }}</el-tag></template></el-table-column>
<el-table-column label="状态" width="108"><template #default="{ row }"><el-tag :type="statusType(row.status)">{{ statusLabel(row.status) }}</el-tag></template></el-table-column>
<el-table-column label="操作" width="190" fixed="right"><template #default="{ row }"><el-button link type="primary" @click="openDetail(row)">查看</el-button><el-button link type="primary" :loading="exportingId === row.id" @click="exportJob(row)">导出</el-button><el-button v-if="['pending','running'].includes(row.status)" link type="danger" @click="cancelJob(row)">取消</el-button></template></el-table-column>
</el-table>
<AdminPagination :page="pager.page" :page-size="pager.pageSize" :total="pager.total" @change="loadJobs" />
</div>
<el-dialog v-model="detailOpen" width="min(1100px, 94vw)" title="批量测试详情" destroy-on-close>
<div v-loading="detailLoading" class="agent-batch-detail">
<template v-if="selectedJob">
<div class="agent-batch-detail-head">
<div><strong>{{ selectedJob.name }}</strong><span>{{ selectedJob.modelName }} · {{ selectedJob.knowledgeNames.join('、') }} · {{ executionLabel(selectedJob) }}</span></div>
<el-tag :type="statusType(selectedJob.status)">{{ statusLabel(selectedJob.status) }}</el-tag>
</div>
<el-table :data="selectedJob.items.items" stripe max-height="520">
<el-table-column prop="externalNo" label="序号" width="76" />
<el-table-column prop="question" label="问题" min-width="230" show-overflow-tooltip />
<el-table-column label="答案" min-width="360"><template #default="{ row }"><div class="agent-batch-answer">{{ row.answer || row.errorMessage || statusLabel(row.status) }}</div></template></el-table-column>
<el-table-column label="状态" width="90"><template #default="{ row }"><el-tag size="small" :type="statusType(row.status)">{{ statusLabel(row.status) }}</el-tag></template></el-table-column>
<el-table-column label="耗时" width="92"><template #default="{ row }">{{ itemDuration(row) }}</template></el-table-column>
</el-table>
<AdminPagination :page="detailPager.page" :page-size="detailPager.pageSize" :total="detailPager.total" @change="(page, pageSize) => loadDetail(selectedJob!.id, page, pageSize)" />
</template>
</div>
<template #footer><el-button @click="detailOpen = false">关闭</el-button><el-button v-if="selectedJob" type="primary" :loading="exportingId === selectedJob.id" @click="exportJob(selectedJob)">导出 Excel</el-button></template>
</el-dialog>
</template>

View File

@@ -0,0 +1,49 @@
<script setup lang="ts">
defineProps<{
temperature: number | null;
topP: number | null;
topK: number | null;
presencePenalty: number | null;
frequencyPenalty: number | null;
maxToken: number | null;
disabled?: boolean;
}>();
defineEmits<{
"update:temperature": [value: number | null];
"update:topP": [value: number | null];
"update:topK": [value: number | null];
"update:presencePenalty": [value: number | null];
"update:frequencyPenalty": [value: number | null];
"update:maxToken": [value: number | null];
}>();
</script>
<template>
<div class="agent-config-grid">
<el-form-item>
<template #label><span class="agent-parameter-label">回答随机性<el-tooltip placement="top" :show-after="200" content="取值 02。数值越小回答越稳定、一致数值越大表达越多样但也更容易发散。留空表示使用模型供应商默认值做同题对比时建议设为 0。"><button type="button" class="agent-parameter-help" aria-label="回答随机性说明">?</button></el-tooltip></span></template>
<el-input-number :model-value="temperature" :disabled="disabled" :min="0" :max="2" :step="0.1" @update:model-value="$emit('update:temperature', $event)" />
</el-form-item>
<el-form-item>
<template #label><span class="agent-parameter-label">核采样概率<el-tooltip placement="top" :show-after="200" content="取值 01。模型只从累计概率达到该数值的候选词中选择。数值越小越专注越大越多样。留空表示使用模型供应商默认值。"><button type="button" class="agent-parameter-help" aria-label="核采样概率说明">?</button></el-tooltip></span></template>
<el-input-number :model-value="topP" :disabled="disabled" :min="0" :max="1" :step="0.05" @update:model-value="$emit('update:topP', $event)" />
</el-form-item>
<el-form-item>
<template #label><span class="agent-parameter-label">候选词数量<el-tooltip placement="top" :show-after="200" content="取值 11000。每次生成只从概率最高的前 K 个候选词中选择。数值越小越保守,越大选择越多。留空表示使用供应商默认值,部分模型可能不支持。"><button type="button" class="agent-parameter-help" aria-label="候选词数量说明">?</button></el-tooltip></span></template>
<el-input-number :model-value="topK" :disabled="disabled" :min="1" :max="1000" @update:model-value="$emit('update:topK', $event)" />
</el-form-item>
<el-form-item>
<template #label><span class="agent-parameter-label">最大输出长度<el-tooltip placement="top" :show-after="200" content="限制模型本次最多生成的 Token 数,不等于字数。建议正式答疑使用 40968192数值越大允许回答越长也会增加耗时和费用最终仍受模型自身上限约束。"><button type="button" class="agent-parameter-help" aria-label="最大输出长度说明">?</button></el-tooltip></span></template>
<el-input-number :model-value="maxToken" :disabled="disabled" :min="256" :max="100000" :step="256" @update:model-value="$emit('update:maxToken', $event)" />
</el-form-item>
<el-form-item>
<template #label><span class="agent-parameter-label">主题重复惩罚<el-tooltip placement="top" :show-after="200" content="取值 -220 表示不调整。正数会降低已出现内容再次出现的概率,鼓励引入新主题;负数会更容易重复已出现内容。数值过高可能导致回答跳跃。"><button type="button" class="agent-parameter-help" aria-label="主题重复惩罚说明">?</button></el-tooltip></span></template>
<el-input-number :model-value="presencePenalty" :disabled="disabled" :min="-2" :max="2" :step="0.1" @update:model-value="$emit('update:presencePenalty', $event)" />
</el-form-item>
<el-form-item>
<template #label><span class="agent-parameter-label">高频重复惩罚<el-tooltip placement="top" :show-after="200" content="取值 -220 表示不调整。正数会根据词语已出现的次数降低其再次出现的概率,用于减少反复表达;负数会增加重复倾向。数值过高可能影响语句连贯性。"><button type="button" class="agent-parameter-help" aria-label="高频重复惩罚说明">?</button></el-tooltip></span></template>
<el-input-number :model-value="frequencyPenalty" :disabled="disabled" :min="-2" :max="2" :step="0.1" @update:model-value="$emit('update:frequencyPenalty', $event)" />
</el-form-item>
</div>
</template>

View File

@@ -0,0 +1,764 @@
<script setup lang="ts">
import { ElMessage, ElMessageBox } from "element-plus";
import { computed, nextTick, onMounted, reactive, ref, watch } from "vue";
import { api, streamDebugAgent } from "../services/api";
import type { AdminUser, AgentRuntimeConfig, KnowledgeItem, ModelItem, PromptDetail, PromptHistoryItem, TopicSessionRecord } from "../types/api";
import AgentGenerationParameters from "./AgentGenerationParameters.vue";
import AgentBatchTestPanel from "./AgentBatchTestPanel.vue";
import AgentResponseDepthControl from "./AgentResponseDepthControl.vue";
import AdminPagination from "./AdminPagination.vue";
import StreamingMarkdownMessage from "./StreamingMarkdownMessage.vue";
import { formatDateTime } from "../utils/dateTime";
const props = withDefaults(defineProps<{ previewKnowledgeId?: number | null; canBatch?: boolean }>(), { canBatch: false });
const emit = defineEmits<{ consumedPreview: [] }>();
const loading = ref(false);
const promptSaving = ref(false);
const runtimeSaving = ref(false);
const historyLoading = ref(false);
const agentDebugging = ref(false);
const agentDebugAbortController = ref<AbortController | null>(null);
const agentPreviewChat = ref<HTMLElement | null>(null);
const activeConfigTab = ref("prompt");
const prompt = ref<PromptDetail | null>(null);
const promptContent = ref("");
const savedPromptContent = ref("");
const models = ref<ModelItem[]>([]);
const knowledge = ref<KnowledgeItem[]>([]);
const debugUsers = ref<AdminUser[]>([]);
const debugUserLoading = ref(false);
const debugTopics = ref<TopicSessionRecord[]>([]);
const debugTopicLoading = ref(false);
const history = ref<PromptHistoryItem[]>([]);
const historyPager = reactive({ page: 1, pageSize: 10, total: 0 });
const historyDetailOpen = ref(false);
const selectedHistory = ref<PromptDetail | null>(null);
const historyDetailLoading = ref(false);
const agentDebugTrace = ref<Record<string, any>[]>([]);
const lastDebugRoute = ref<{ modelName?: string; routeReason?: string } | null>(null);
const DEFAULT_AGENT_PREVIEW_MESSAGE = "选择模型和知识库后,可以在这里调试 Agent 的真实问答效果。";
const agentPreviewMessages = ref<{
role: "user" | "assistant" | "system";
content: string;
reasoning?: string;
showReasoning?: boolean;
streaming?: boolean;
}[]>([
{ role: "assistant", content: DEFAULT_AGENT_PREVIEW_MESSAGE },
]);
const agentForm = reactive({
modelId: undefined as number | undefined,
userId: undefined as number | undefined,
topicSessionId: undefined as number | undefined,
knowledgeIds: [] as number[],
temperature: 0.2 as number | null,
topP: null as number | null,
topK: null as number | null,
presencePenalty: null as number | null,
frequencyPenalty: null as number | null,
maxToken: 8192 as number | null,
streamEnabled: 1,
reasoningVisible: 0,
responseDepth: 35,
question: "",
});
const runtimeConfig = ref<AgentRuntimeConfig | null>(null);
const runtimeForm = reactive({
temperature: null as number | null,
topP: null as number | null,
topK: null as number | null,
presencePenalty: null as number | null,
frequencyPenalty: null as number | null,
maxToken: 8192 as number | null,
streamEnabled: 1,
reasoningVisible: 0,
responseDepth: 35,
});
const promptDirty = computed(() => promptContent.value !== savedPromptContent.value);
const selectedModelName = computed(() => {
const model = models.value.find((item) => item.id === agentForm.modelId);
return model?.displayName || model?.modelName || "未选择模型";
});
const selectedKnowledgeSummary = computed(() => {
if (!agentForm.knowledgeIds.length) return "默认使用全部正式开放知识库";
if (agentForm.knowledgeIds.length === 1) {
return knowledge.value.find((item) => item.id === agentForm.knowledgeIds[0])?.name || "1 个知识库";
}
return `${agentForm.knowledgeIds.length} 个知识库`;
});
const selectedDebugUserLabel = computed(() => {
if (!agentForm.userId) return "未模拟学员";
const user = debugUsers.value.find((item) => item.id === agentForm.userId);
return user ? `${user.name || user.nickname || user.phone} · #${user.id}` : `学员 #${agentForm.userId}`;
});
const selectedDebugTopicLabel = computed(() => {
if (!agentForm.topicSessionId) return "未指定主题";
const topic = debugTopics.value.find((item) => item.id === agentForm.topicSessionId);
return topic ? topic.title : `主题 #${agentForm.topicSessionId}`;
});
const batchConfig = computed(() => ({
promptContent: promptContent.value,
modelId: agentForm.modelId,
knowledgeIds: [...agentForm.knowledgeIds],
temperature: agentForm.temperature,
topP: agentForm.topP,
topK: agentForm.topK,
presencePenalty: agentForm.presencePenalty,
frequencyPenalty: agentForm.frequencyPenalty,
maxToken: agentForm.maxToken,
streamEnabled: agentForm.streamEnabled,
responseDepth: agentForm.responseDepth,
}));
onMounted(load);
watch(() => props.previewKnowledgeId, (knowledgeId) => {
if (knowledgeId && knowledge.value.length) applyPreviewKnowledge(knowledgeId);
});
async function load() {
loading.value = true;
try {
const [promptResult, modelRows, knowledgeRows, formalConfig] = await Promise.all([
api.prompt(), api.agentModelOptions(), api.agentKnowledgeOptions(), api.agentRuntimeConfig(),
]);
applyPrompt(promptResult);
models.value = modelRows;
knowledge.value = knowledgeRows;
agentForm.modelId = modelRows.find((item) => item.isDefault === 1)?.id
?? modelRows.find((item) => item.enabled === 1)?.id
?? modelRows[0]?.id;
applyRuntimeConfig(formalConfig);
applyDebugModelDefaults(agentForm.modelId);
// 空选择由后端使用和用户端一致的“正式开放知识库”规则。
// 只有管理员显式勾选时,才进入指定知识库的预览模式。
agentForm.knowledgeIds = [];
if (props.previewKnowledgeId) applyPreviewKnowledge(props.previewKnowledgeId);
void searchDebugUsers("");
await loadHistory();
} catch (error) {
ElMessage.error(errorMessage(error, "Agent 配置加载失败"));
} finally {
loading.value = false;
}
}
async function searchDebugUsers(keyword: string) {
debugUserLoading.value = true;
try {
const result = await api.users({ keyword, page: 1, pageSize: 20 });
debugUsers.value = result.items;
} catch {
debugUsers.value = [];
} finally {
debugUserLoading.value = false;
}
}
async function handleDebugUserChange(userId?: number) {
agentForm.topicSessionId = undefined;
debugTopics.value = [];
clearAgentPreview("模拟学员已切换,可以选择其中一个主题继续调试。");
if (!userId) return;
debugTopicLoading.value = true;
try {
await loadDebugTopics(userId);
} catch (error) {
ElMessage.error(errorMessage(error, "学员主题加载失败"));
} finally {
debugTopicLoading.value = false;
}
}
async function loadDebugTopics(userId: number, keyword = "") {
const selected = debugTopics.value.find((item) => item.id === agentForm.topicSessionId);
const result = await api.userTopics(userId, { keyword, page: 1, pageSize: 100 });
debugTopics.value = selected && !result.items.some((item) => item.id === selected.id)
? [selected, ...result.items]
: result.items;
}
async function searchDebugTopics(keyword: string) {
if (!agentForm.userId) return;
debugTopicLoading.value = true;
try {
await loadDebugTopics(agentForm.userId, keyword);
} catch (error) {
ElMessage.error(errorMessage(error, "学员主题搜索失败"));
} finally {
debugTopicLoading.value = false;
}
}
function handleDebugTopicChange() {
clearAgentPreview(
agentForm.topicSessionId
? "模拟主题已切换,下一条调试会加载该主题的摘要和最近对话。"
: "已恢复通用学员模拟,不加载具体主题对话。",
);
}
function debugTopicOptionLabel(topic: TopicSessionRecord) {
const statusLabel = topic.status === "active" ? "进行中" : topic.status === "completed" ? "已完成" : topic.status;
const summaryLabel = topic.summaryAvailable ? "有摘要" : "无摘要";
return `${topic.title} · ${statusLabel} · ${topic.messageCount} 条消息 · ${summaryLabel}`;
}
function applyRuntimeConfig(value: AgentRuntimeConfig) {
runtimeConfig.value = value;
Object.assign(runtimeForm, {
temperature: value.temperature,
topP: value.topP,
topK: value.topK,
presencePenalty: value.presencePenalty,
frequencyPenalty: value.frequencyPenalty,
maxToken: value.maxToken,
streamEnabled: value.streamEnabled,
reasoningVisible: value.reasoningVisible,
responseDepth: value.responseDepth ?? 35,
});
}
function applyDebugModelDefaults(modelId?: number) {
const model = models.value.find((item) => item.id === modelId);
if (!model) return;
Object.assign(agentForm, {
temperature: model.temperature ?? null,
topP: model.topP ?? null,
topK: model.topK ?? null,
presencePenalty: model.presencePenalty ?? null,
frequencyPenalty: model.frequencyPenalty ?? null,
maxToken: model.maxToken ?? (model.enabled === 1 ? runtimeConfig.value?.maxToken : null) ?? 8192,
streamEnabled: model.streamEnabled ?? 1,
reasoningVisible: runtimeConfig.value?.reasoningVisible ?? 0,
responseDepth: runtimeConfig.value?.responseDepth ?? 35,
});
}
async function saveRuntimeConfig() {
if (!runtimeConfig.value?.modelId) return ElMessage.error("请先在模型管理中启用一个正式模型");
if (!runtimeForm.maxToken) return ElMessage.warning("最大输出长度不能为空");
runtimeSaving.value = true;
try {
const saved = await api.saveAgentRuntimeConfig({
temperature: runtimeForm.temperature,
topP: runtimeForm.topP,
topK: runtimeForm.topK,
presencePenalty: runtimeForm.presencePenalty,
frequencyPenalty: runtimeForm.frequencyPenalty,
maxToken: runtimeForm.maxToken,
streamEnabled: runtimeForm.streamEnabled,
reasoningVisible: runtimeForm.reasoningVisible,
responseDepth: runtimeForm.responseDepth,
});
applyRuntimeConfig(saved);
const model = models.value.find((item) => item.id === saved.modelId);
if (model) Object.assign(model, runtimeForm);
if (agentForm.modelId === saved.modelId) applyDebugModelDefaults(saved.modelId ?? undefined);
ElMessage.success("正式运行参数已保存,将从用户端下一条消息开始生效");
} catch (error) {
ElMessage.error(errorMessage(error, "正式运行参数保存失败"));
} finally {
runtimeSaving.value = false;
}
}
function applyPreviewKnowledge(knowledgeId: number) {
if (knowledge.value.some((item) => item.id === knowledgeId)) {
agentForm.knowledgeIds = [knowledgeId];
activeConfigTab.value = "debug";
}
emit("consumedPreview");
}
function applyPrompt(value: PromptDetail) {
prompt.value = value;
promptContent.value = value.promptContent;
savedPromptContent.value = value.promptContent;
}
async function savePrompt() {
if (!promptContent.value.trim()) return ElMessage.warning("主提示词不能为空");
promptSaving.value = true;
try {
applyPrompt(await api.savePrompt(promptContent.value.trim()));
ElMessage.success("主提示词已保存为新版本");
await loadHistory(1);
} catch (error) {
ElMessage.error(errorMessage(error, "主提示词保存失败"));
} finally {
promptSaving.value = false;
}
}
async function resetPrompt() {
try {
await ElMessageBox.confirm(
"恢复默认提示词会新增一个版本,现有历史不会被删除。确认继续?",
"恢复默认提示词",
{ type: "warning", confirmButtonText: "确认恢复", cancelButtonText: "取消" },
);
} catch {
return;
}
promptSaving.value = true;
try {
applyPrompt(await api.resetPrompt());
ElMessage.success("已恢复默认提示词并保存为新版本");
await loadHistory(1);
} catch (error) {
ElMessage.error(errorMessage(error, "恢复默认提示词失败"));
} finally {
promptSaving.value = false;
}
}
async function loadHistory(page = historyPager.page, pageSize = historyPager.pageSize) {
historyLoading.value = true;
try {
const result = await api.promptHistory({ page, pageSize });
history.value = result.items;
Object.assign(historyPager, { page: result.page, pageSize: result.pageSize, total: result.total });
} catch (error) {
ElMessage.error(errorMessage(error, "提示词历史加载失败"));
} finally {
historyLoading.value = false;
}
}
async function openHistory(item: PromptHistoryItem) {
historyDetailOpen.value = true;
historyDetailLoading.value = true;
selectedHistory.value = null;
try {
selectedHistory.value = await api.promptHistoryDetail(item.id);
} catch (error) {
ElMessage.error(errorMessage(error, "提示词版本加载失败"));
historyDetailOpen.value = false;
} finally {
historyDetailLoading.value = false;
}
}
async function restoreHistory(id: number) {
try {
await ElMessageBox.confirm(
`确认把版本 #${id} 恢复为当前主提示词?系统会新增一个恢复版本,不会覆盖历史。`,
"恢复历史版本",
{ type: "warning", confirmButtonText: "确认恢复", cancelButtonText: "取消" },
);
} catch {
return;
}
promptSaving.value = true;
try {
applyPrompt(await api.restorePrompt(id));
historyDetailOpen.value = false;
activeConfigTab.value = "prompt";
ElMessage.success(`版本 #${id} 已恢复为当前主提示词`);
await loadHistory(1);
} catch (error) {
ElMessage.error(errorMessage(error, "历史版本恢复失败"));
} finally {
promptSaving.value = false;
}
}
async function restoreSelectedHistory() {
if (selectedHistory.value?.id) await restoreHistory(selectedHistory.value.id);
}
async function debugAgent() {
if (agentDebugging.value) return;
if (!agentForm.modelId) return ElMessage.error("请选择调试模型");
if (!agentForm.question.trim()) return ElMessage.error("请输入调试问题");
if (!promptContent.value.trim()) return ElMessage.error("主提示词不能为空");
const question = agentForm.question.trim();
const conversationHistory = agentPreviewMessages.value
.slice(1)
.filter((message) => ["user", "assistant"].includes(message.role) && message.content.trim() && !message.streaming)
.map((message) => ({ role: message.role, content: message.content.trim() }));
agentPreviewMessages.value.push({ role: "user", content: question });
agentPreviewMessages.value.push({ role: "assistant", content: "", streaming: true });
const assistantIndex = agentPreviewMessages.value.length - 1;
const currentAssistant = () => agentPreviewMessages.value[assistantIndex];
agentForm.question = "";
agentDebugging.value = true;
agentDebugTrace.value = [];
lastDebugRoute.value = null;
agentDebugAbortController.value = new AbortController();
scrollAgentPreview();
try {
await streamDebugAgent(
{
promptContent: promptContent.value,
modelId: agentForm.modelId,
userId: agentForm.userId || null,
topicSessionId: agentForm.topicSessionId || null,
knowledgeIds: agentForm.knowledgeIds,
question,
history: conversationHistory,
temperature: agentForm.temperature,
topP: agentForm.topP,
topK: agentForm.topK,
presencePenalty: agentForm.presencePenalty,
frequencyPenalty: agentForm.frequencyPenalty,
maxToken: agentForm.maxToken,
streamEnabled: agentForm.streamEnabled,
reasoningVisible: agentForm.reasoningVisible,
responseDepth: agentForm.responseDepth,
},
(chunk) => {
currentAssistant().content += chunk;
scrollAgentPreview();
},
(chunk) => {
currentAssistant().reasoning = (currentAssistant().reasoning || "") + chunk;
scrollAgentPreview();
},
(status) => {
currentAssistant().showReasoning = status.reasoningVisible;
scrollAgentPreview();
},
(result) => {
agentDebugTrace.value = result.retrievalTrace || [];
lastDebugRoute.value = { modelName: result.modelName, routeReason: result.routeReason };
ElMessage.success(`${result.message || "Agent 调试完成"} · ${result.modelName || selectedModelName.value}`);
},
agentDebugAbortController.value.signal,
);
if (!currentAssistant().content.trim()) currentAssistant().content = "模型未返回正式回答。";
} catch (error) {
if (error instanceof DOMException && error.name === "AbortError") {
currentAssistant().content ||= "已停止生成";
} else {
const message = errorMessage(error, "Agent 调试失败");
currentAssistant().content ||= message;
ElMessage.error(message);
}
} finally {
currentAssistant().streaming = false;
agentDebugging.value = false;
agentDebugAbortController.value = null;
scrollAgentPreview();
}
}
function stopDebugAgent() {
agentDebugAbortController.value?.abort();
}
function scrollAgentPreview() {
void nextTick(() => {
const element = agentPreviewChat.value;
if (element) element.scrollTop = element.scrollHeight;
});
}
function clearAgentPreview(message?: string) {
const notice = typeof message === "string" && message.trim()
? message
: "预览已清空,可以继续发起新的 Agent 调试。";
agentPreviewMessages.value = [{ role: "assistant", content: notice }];
agentDebugTrace.value = [];
lastDebugRoute.value = null;
agentForm.question = "";
scrollAgentPreview();
}
function changeTypeLabel(value?: string) {
return ({ save: "手动保存", reset: "恢复默认", restore: "历史恢复", default: "系统默认" } as Record<string, string>)[value || ""] || "保存";
}
function changeTypeTag(value?: string) {
if (value === "reset") return "warning";
if (value === "restore") return "success";
return "info";
}
function errorMessage(error: unknown, fallback: string) {
return error instanceof Error ? error.message : fallback;
}
</script>
<template>
<div class="page-head agent-page-head">
<div>
<h2>Agent 管理</h2>
<p>维护主提示词用户端正式生成参数和历史版本并验证真实检索与回答效果</p>
</div>
<div class="agent-current-summary">
<el-tag type="success">当前版本 {{ prompt?.id ? `#${prompt.id}` : "系统默认" }}</el-tag>
<span>{{ prompt?.updatedByName || "系统默认" }} · {{ formatDateTime(prompt?.updatedAt, "尚未保存") }}</span>
</div>
</div>
<section v-loading="loading" class="agent-workbench agent-workbench-v2" :class="{ 'agent-batch-mode': activeConfigTab === 'batch' }">
<div class="agent-config-panel agent-control-panel">
<el-tabs v-model="activeConfigTab" class="agent-config-tabs">
<el-tab-pane label="主提示词" name="prompt">
<div class="agent-section-intro">
<div><h3>当前主提示词</h3><p>保存恢复默认或恢复历史版本时都会新增版本历史记录不会被覆盖</p></div>
<el-tag v-if="promptDirty" type="warning">有未保存修改</el-tag>
</div>
<el-input v-model="promptContent" class="agent-prompt-editor" type="textarea" :rows="18" resize="vertical" placeholder="请输入 Agent 主提示词" />
<div class="agent-editor-meta"><span>{{ promptContent.length }} 字符</span><span>调试会立即使用编辑框中的内容</span></div>
<div class="actions agent-primary-actions">
<el-button type="primary" :loading="promptSaving" :disabled="!promptDirty" @click="savePrompt">保存为新版本</el-button>
<el-button :loading="promptSaving" @click="resetPrompt">恢复默认提示词</el-button>
</div>
</el-tab-pane>
<el-tab-pane label="正式运行配置" name="runtime">
<div class="agent-section-intro">
<div><h3>用户端正式运行参数</h3><p>保存后从用户端下一条消息开始生效当前正式模型{{ runtimeConfig?.modelName || '未启用模型' }}</p></div>
<el-button type="primary" :loading="runtimeSaving" :disabled="!runtimeConfig?.modelId" @click="saveRuntimeConfig">保存正式参数</el-button>
</div>
<el-alert v-if="!runtimeConfig?.modelId" title="当前没有已启用模型,请先到模型管理中启用一个正式模型。" type="warning" :closable="false" show-icon />
<el-alert v-else title="最大输出长度默认提升为 8192 Token设置过低会导致长回答在完成前被模型截断。实际可用上限仍受模型供应商限制。" type="info" :closable="false" show-icon />
<el-form label-position="top" class="agent-runtime-form">
<section class="agent-advanced-settings">
<div class="agent-subsection-title"><h4>正式生成参数</h4><span>用户端正式对话使用</span></div>
<AgentGenerationParameters
v-model:temperature="runtimeForm.temperature"
v-model:top-p="runtimeForm.topP"
v-model:top-k="runtimeForm.topK"
v-model:presence-penalty="runtimeForm.presencePenalty"
v-model:frequency-penalty="runtimeForm.frequencyPenalty"
v-model:max-token="runtimeForm.maxToken"
:disabled="!runtimeConfig?.modelId"
/>
<AgentResponseDepthControl v-model="runtimeForm.responseDepth" :disabled="!runtimeConfig?.modelId" />
<el-form-item class="agent-stream-setting">
<div class="agent-stream-setting-copy">
<strong>用户端流式输出</strong>
<span>开启后回答正文会边生成边展示关闭后等待模型完成再一次性显示完整回答</span>
</div>
<el-switch
v-model="runtimeForm.streamEnabled"
:active-value="1"
:inactive-value="0"
active-text="开启"
inactive-text="关闭"
inline-prompt
:disabled="!runtimeConfig?.modelId"
/>
</el-form-item>
<el-form-item class="agent-stream-setting">
<div class="agent-stream-setting-copy">
<strong>展示思考过程</strong>
<span>开启后用户端和后台调试预览可展开查看模型返回的思考内容关闭时后端不会下发该内容</span>
</div>
<el-switch
v-model="runtimeForm.reasoningVisible"
:active-value="1"
:inactive-value="0"
active-text="开启"
inactive-text="关闭"
inline-prompt
:disabled="!runtimeConfig?.modelId"
/>
</el-form-item>
</section>
</el-form>
</el-tab-pane>
<el-tab-pane label="调试配置" name="debug">
<div class="agent-section-intro"><div><h3>本次调试配置</h3><p>配置只作用于后台预览不会修改模型和知识库的正式状态</p></div></div>
<el-form label-position="top">
<el-form-item label="调试模型">
<el-select v-model="agentForm.modelId" placeholder="选择已接入模型" @change="applyDebugModelDefaults">
<el-option v-for="model in models" :key="model.id" :label="model.displayName || model.modelName" :value="model.id" />
</el-select>
</el-form-item>
<div class="agent-debug-context-grid">
<el-form-item label="模拟学员(可选)">
<el-select
v-model="agentForm.userId"
clearable
filterable
remote
:remote-method="searchDebugUsers"
:loading="debugUserLoading"
:disabled="agentDebugging"
placeholder="不选择则不注入学员权益和近期实修回顾"
@change="handleDebugUserChange"
>
<el-option
v-for="user in debugUsers"
:key="user.id"
:label="`${user.name || user.nickname || user.phone} · ${user.phone} · #${user.id}`"
:value="user.id"
/>
</el-select>
<div class="agent-form-help">加载该学员的权益和近期实修回顾只用于保持近期对话连续性不会消耗额度或改动正式会话</div>
</el-form-item>
<el-form-item label="模拟主题(可选)">
<el-select
v-model="agentForm.topicSessionId"
clearable
filterable
remote
:remote-method="searchDebugTopics"
:loading="debugTopicLoading"
:disabled="!agentForm.userId || agentDebugging"
placeholder="选择学员后可加载具体主题"
@change="handleDebugTopicChange"
>
<el-option
v-for="topic in debugTopics"
:key="topic.id"
:label="debugTopicOptionLabel(topic)"
:value="topic.id"
/>
</el-select>
<div class="agent-form-help">选择后会加载该主题摘要和最近对话用于验证追问效果</div>
</el-form-item>
</div>
<el-form-item label="调试知识库">
<el-select v-model="agentForm.knowledgeIds" class="debug-knowledge-select" multiple filterable collapse-tags collapse-tags-tooltip :max-collapse-tags="2" popper-class="debug-knowledge-popper" placeholder="默认使用全部已开放知识库">
<el-option v-for="item in knowledge" :key="item.id" :label="`${item.name}${item.status === 0 ? ' · 已关闭(仅本次预览)' : ''}`" :value="item.id" />
</el-select>
<div class="agent-form-help">不选时与用户端一致只使用已开放已确认且来源正常的正式版本显式选择则是本次预览范围</div>
</el-form-item>
<section class="agent-advanced-settings">
<div class="agent-subsection-title"><h4>高级生成参数</h4><span>仅影响本次后台调试</span></div>
<el-alert
v-if="agentForm.temperature == null"
class="agent-debug-randomness-alert"
title="当前未指定回答随机性,将使用模型供应商默认值;做同题稳定性对比时建议设为 0。"
type="warning"
:closable="false"
show-icon
/>
<AgentGenerationParameters
v-model:temperature="agentForm.temperature"
v-model:top-p="agentForm.topP"
v-model:top-k="agentForm.topK"
v-model:presence-penalty="agentForm.presencePenalty"
v-model:frequency-penalty="agentForm.frequencyPenalty"
v-model:max-token="agentForm.maxToken"
/>
<AgentResponseDepthControl v-model="agentForm.responseDepth" />
<el-form-item class="agent-stream-setting">
<div class="agent-stream-setting-copy">
<strong>流式输出</strong>
<span>开启后后台预览会边生成边展示关闭后等待模型完成再一次性显示</span>
</div>
<el-switch
v-model="agentForm.streamEnabled"
:active-value="1"
:inactive-value="0"
active-text="开启"
inactive-text="关闭"
inline-prompt
/>
</el-form-item>
<el-form-item class="agent-stream-setting">
<div class="agent-stream-setting-copy">
<strong>展示思考过程</strong>
<span>开启后本次后台预览可展开查看模型思考内容不会修改用户端正式配置</span>
</div>
<el-switch
v-model="agentForm.reasoningVisible"
:active-value="1"
:inactive-value="0"
active-text="开启"
inactive-text="关闭"
inline-prompt
/>
</el-form-item>
</section>
</el-form>
</el-tab-pane>
<el-tab-pane v-if="props.canBatch" label="批量测试" name="batch" lazy>
<AgentBatchTestPanel
:config="batchConfig"
:model-name="selectedModelName"
:knowledge-summary="selectedKnowledgeSummary"
/>
</el-tab-pane>
<el-tab-pane label="历史版本" name="history">
<div class="agent-section-intro"><div><h3>主提示词历史</h3><p>查看完整内容或恢复任意版本恢复操作会作为一个新版本记录</p></div></div>
<div v-loading="historyLoading" class="prompt-history-list">
<el-empty v-if="history.length === 0" description="还没有已保存的提示词版本" :image-size="64" />
<article v-for="item in history" :key="item.id" class="prompt-history-item" :class="{ current: item.isCurrent }">
<div class="prompt-history-head">
<strong>版本 #{{ item.id }}</strong>
<el-tag v-if="item.isCurrent" type="success" size="small">当前</el-tag>
<el-tag :type="changeTypeTag(item.changeType)" size="small">{{ changeTypeLabel(item.changeType) }}</el-tag>
<span>{{ item.updatedByName }} · {{ formatDateTime(item.updatedAt, "尚未保存") }}</span>
</div>
<p>{{ item.preview || "空内容" }}</p>
<div class="prompt-history-foot">
<span>{{ item.charCount }} 字符<span v-if="item.sourcePromptId"> · 来源 #{{ item.sourcePromptId }}</span></span>
<div><el-button link type="primary" @click="openHistory(item)">查看完整内容</el-button><el-button v-if="!item.isCurrent" link type="primary" @click="restoreHistory(item.id)">恢复此版本</el-button></div>
</div>
</article>
</div>
<AdminPagination :page="historyPager.page" :page-size="historyPager.pageSize" :total="historyPager.total" @change="loadHistory" />
</el-tab-pane>
</el-tabs>
</div>
<aside class="agent-preview-panel">
<div class="agent-preview-head">
<div><h3>调试预览</h3><small>{{ selectedKnowledgeSummary }} · {{ selectedDebugUserLabel }} · {{ selectedDebugTopicLabel }}</small></div>
<div class="agent-preview-head-actions"><span :title="lastDebugRoute?.routeReason || ''">{{ lastDebugRoute?.modelName || selectedModelName }}</span><el-button link :disabled="agentDebugging" @click="clearAgentPreview()">清空</el-button></div>
</div>
<div ref="agentPreviewChat" class="agent-preview-chat">
<article v-for="(message, index) in agentPreviewMessages" :key="`${message.role}-${index}`" class="agent-preview-message-row" :class="message.role">
<div class="agent-preview-avatar">{{ message.role === "user" ? "你" : "AI" }}</div>
<div class="agent-preview-bubble">
<strong>{{ message.role === "user" ? "你" : "Agent" }}</strong>
<template v-if="message.role === 'assistant'">
<StreamingMarkdownMessage
:content="message.content"
:reasoning="message.reasoning"
:show-reasoning="message.showReasoning"
:streaming="message.streaming"
/>
</template>
<pre v-else>{{ message.content }}</pre>
</div>
</article>
</div>
<div class="agent-preview-composer">
<div class="agent-preview-input">
<el-input v-model="agentForm.question" type="textarea" :rows="2" resize="none" :disabled="agentDebugging" placeholder="向 Agent 发送测试问题Enter 发送Shift+Enter 换行" @keydown.enter.exact.prevent="debugAgent" />
<small>连续发送会携带上方历史同题对比请先清空并将回答随机性设为 0</small>
</div>
<el-button :type="agentDebugging ? 'danger' : 'primary'" @click="agentDebugging ? stopDebugAgent() : debugAgent()">{{ agentDebugging ? '停止' : '发送' }}</el-button>
</div>
</aside>
<aside class="agent-trace-panel agent-trace-wide">
<div class="agent-preview-head"><div><h3>检索追踪</h3><small>按调用顺序查看请求响应耗时与错误</small></div><span>本轮 {{ agentDebugTrace.length }} Tool 调用</span></div>
<div class="agent-trace-list">
<el-empty v-if="agentDebugTrace.length === 0" description="发送问题后查看目录、检索与章节回读" :image-size="64" />
<details v-for="(step, index) in agentDebugTrace" :key="index" class="tool-trace-node">
<summary><strong>{{ index + 1 }}. {{ step.tool }}</strong><el-tag size="small" :type="step.status === 'failed' ? 'danger' : 'success'">{{ step.status || '完成' }}</el-tag><span>{{ step.durationMs ?? 0 }} ms</span></summary>
<h5>请求</h5><pre>{{ JSON.stringify(step.request ?? {}, null, 2) }}</pre>
<h5>响应</h5><pre>{{ step.response == null ? '返回为空' : JSON.stringify(step.response, null, 2) }}</pre>
<el-alert v-if="step.error" :title="step.error" type="error" :closable="false" />
</details>
</div>
</aside>
</section>
<el-dialog v-model="historyDetailOpen" width="min(820px, 92vw)" title="主提示词历史版本" destroy-on-close>
<div v-loading="historyDetailLoading" class="prompt-history-detail">
<template v-if="selectedHistory">
<div class="prompt-history-detail-meta"><el-tag :type="changeTypeTag(selectedHistory.changeType)">{{ changeTypeLabel(selectedHistory.changeType) }}</el-tag><strong>版本 #{{ selectedHistory.id }}</strong><span>{{ selectedHistory.updatedByName }} · {{ formatDateTime(selectedHistory.updatedAt, "尚未保存") }} · {{ selectedHistory.charCount }} 字符</span></div>
<pre>{{ selectedHistory.promptContent }}</pre>
</template>
</div>
<template #footer><el-button @click="historyDetailOpen = false">关闭</el-button><el-button v-if="selectedHistory?.id !== prompt?.id" type="primary" :loading="promptSaving" @click="restoreSelectedHistory">恢复为当前主提示词</el-button></template>
</el-dialog>
</template>

View File

@@ -0,0 +1,43 @@
<script setup lang="ts">
const props = defineProps<{
modelValue: number;
disabled?: boolean;
}>();
defineEmits<{
"update:modelValue": [value: number];
}>();
function depthLabel(value: number) {
if (value <= 33) return "精简";
if (value <= 66) return "平衡";
return "深入";
}
function depthCopy(value: number) {
if (value <= 33) return "少分析、短建议,优先回到当下和身体感受。";
if (value <= 66) return "保留必要解释,给出清楚但不过度展开的建议。";
return "允许更完整拆解,但仍保持回归自身、觉察和释放的方向。";
}
</script>
<template>
<el-form-item class="agent-response-depth">
<div class="agent-response-depth-head">
<div class="agent-stream-setting-copy">
<strong>回答深度</strong>
<span>{{ depthCopy(props.modelValue) }}</span>
</div>
<el-tag effect="plain">{{ depthLabel(props.modelValue) }} {{ props.modelValue }}</el-tag>
</div>
<el-slider
:model-value="props.modelValue"
:disabled="disabled"
:min="0"
:max="100"
:step="5"
:marks="{ 0: '精简', 50: '平衡', 100: '深入' }"
@update:model-value="$emit('update:modelValue', Number($event))"
/>
</el-form-item>
</template>

View File

@@ -0,0 +1,126 @@
<script setup lang="ts">
import { ElMessage } from "element-plus";
import { ref, watch } from "vue";
import { api } from "../services/api";
import type { AiLogRecord } from "../types/api";
import { formatDateTime } from "../utils/dateTime";
const props = defineProps<{ modelValue: boolean; logId: number | null }>();
const emit = defineEmits<{ "update:modelValue": [value: boolean] }>();
const detail = ref<AiLogRecord | null>(null);
const loading = ref(false);
watch(
() => [props.modelValue, props.logId] as const,
async ([open, logId]) => {
if (!open || !logId) return;
detail.value = null;
loading.value = true;
try {
detail.value = await api.aiLogDetail(logId);
} catch (error) {
ElMessage.error(
error instanceof Error ? error.message : "AI 请求详情加载失败",
);
} finally {
loading.value = false;
}
},
);
function formatMoney(value?: number | null, currency = "CNY") {
if (typeof value !== "number") return "-";
return `${currency || "CNY"} ${value.toFixed(6)}`;
}
</script>
<template>
<el-drawer
:model-value="modelValue"
size="820px"
title="AI 请求详情"
@update:model-value="emit('update:modelValue', $event)"
>
<div v-loading="loading">
<template v-if="detail">
<section class="chat-summary">
<div>
<span>请求ID</span><strong>{{ detail.id }}</strong>
</div>
<div>
<span>会话ID</span><strong>{{ detail.sessionId || "-" }}</strong>
</div>
<div>
<span>消息ID</span><strong>{{ detail.messageId || "-" }}</strong>
</div>
<div>
<span>用户ID</span><strong>{{ detail.userId || "-" }}</strong>
</div>
<div>
<span>模型</span><strong>{{ detail.modelName || "-" }}</strong>
</div>
<div>
<span>状态</span><strong>{{ detail.status }}</strong>
</div>
</section>
<section class="audit-detail-grid">
<span>知识库{{ detail.knowledgeIds || "-" }}</span
><span>命中数{{ detail.retrieveCount }}</span
><span>片段数{{ detail.retrievedChunks?.length || 0 }}</span
><span>输入 Token{{ detail.inputToken || "-" }}</span
><span>输出 Token{{ detail.outputToken || "-" }}</span
><span> Token{{ detail.totalToken || "-" }}</span
><span
>估算成本{{
formatMoney(detail.estimatedCost, detail.currency || "CNY")
}}</span
><span>问题类型{{ detail.questionType || "-" }}</span
><span>知识命中{{ detail.knowledgeHit ? "是" : "否" }}</span
><span>路由原因{{ detail.routeReason || "-" }}</span
><span>耗时{{ detail.costMs || "-" }}ms</span
><span>时间{{ formatDateTime(detail.createdAt) }}</span>
</section>
<p v-if="detail.errorMessage" class="error-text">
{{ detail.errorMessage }}
</p>
<h3 class="detail-title">召回知识片段</h3>
<section v-if="detail.retrievedChunks?.length" class="retrieval-chunks">
<article
v-for="chunk in detail.retrievedChunks"
:key="`${detail.id}-${chunk.index}`"
class="retrieval-chunk"
>
<div class="chunk-head">
<strong
>片段 {{ chunk.index }}/{{
detail.retrievedChunks.length
}}</strong
><span>{{ chunk.knowledgeName || "未知知识库" }}</span
><span v-if="chunk.knowledgeId"
>知识库ID{{ chunk.knowledgeId }}</span
>
</div>
<div class="chunk-title">{{ chunk.title || "未命名片段" }}</div>
<pre>{{ chunk.content }}</pre>
<a
v-if="chunk.sourceUrl"
:href="chunk.sourceUrl"
target="_blank"
rel="noreferrer"
>查看来源</a
>
</article>
</section>
<el-empty
v-else
description="本条请求未保存召回片段"
:image-size="72"
/>
<h3 class="detail-title">完整 Prompt</h3>
<pre class="prompt-preview">{{
detail.prompt || "无 Prompt 记录"
}}</pre>
</template>
</div>
</el-drawer>
</template>

View File

@@ -0,0 +1,208 @@
<script setup lang="ts">
import { ElMessage, ElMessageBox } from "element-plus";
import { onMounted, reactive, ref } from "vue";
import { api } from "../services/api";
import type { AttentionItem } from "../types/api";
import AdminPagination from "./AdminPagination.vue";
import AttentionRuleConfigPanel from "./AttentionRuleConfigPanel.vue";
import ChatDetailDrawer from "./ChatDetailDrawer.vue";
import { formatDateTime, toUtcIsoFromBusinessDate } from "../utils/dateTime";
defineProps<{ canConfig: boolean; canPreview: boolean }>();
const loading = ref(false);
const records = ref<AttentionItem[]>([]);
const pager = reactive({ page: 1, pageSize: 20, total: 0 });
const filters = reactive({ priority: "", dateFrom: "", dateTo: "" });
const chatDetailOpen = ref(false);
const sessionId = ref<number | null>(null);
const focusMessageId = ref<number | null>(null);
onMounted(() => loadRecords());
async function loadRecords(page = 1, pageSize = pager.pageSize) {
loading.value = true;
try {
const result = await api.attentionList({
priority: filters.priority || undefined,
dateFrom: toUtcIsoFromBusinessDate(filters.dateFrom, "start"),
dateTo: toUtcIsoFromBusinessDate(filters.dateTo, "end"),
page,
pageSize,
});
records.value = result.items;
Object.assign(pager, {
page: result.page,
pageSize: result.pageSize,
total: result.total,
});
} finally {
loading.value = false;
}
}
function applyFilters() {
if (filters.dateFrom && filters.dateTo && filters.dateFrom > filters.dateTo) {
ElMessage.warning("开始日期不能晚于结束日期");
return;
}
void loadRecords(1, pager.pageSize);
}
function resetFilters() {
Object.assign(filters, { priority: "", dateFrom: "", dateTo: "" });
void loadRecords(1, pager.pageSize);
}
async function updateStatus(row: AttentionItem, status: string) {
await api.updateAttention(row.id, status);
ElMessage.success("人工关注状态已更新");
await loadRecords(pager.page, pager.pageSize);
}
async function deleteRecord(row: AttentionItem) {
try {
await ElMessageBox.confirm(
"确定删除这条人工关注记录吗?操作将写入审计日志。",
"删除人工关注记录",
{
confirmButtonText: "确认删除",
cancelButtonText: "取消",
type: "warning",
},
);
} catch {
return;
}
await api.deleteAttention(row.id);
ElMessage.success("人工关注记录已删除");
await loadRecords(pager.page, pager.pageSize);
}
function openChat(row: AttentionItem) {
sessionId.value = row.sessionId;
focusMessageId.value = row.messageId;
chatDetailOpen.value = true;
}
function priorityLabel(priority: string) {
return (
{ urgent: "紧急", important: "重要", normal: "普通" }[
priority as "urgent" | "important" | "normal"
] || priority
);
}
</script>
<template>
<section v-loading="loading" class="feature-page">
<div class="page-head">
<h2>人工关注</h2>
<p>处理安全风险复杂卡住知识缺失和用户主动求助记录</p>
</div>
<el-tabs class="attention-tabs">
<el-tab-pane label="关注记录">
<section class="record-filter attention-toolbar" aria-label="人工关注筛选">
<el-select v-model="filters.priority" placeholder="全部优先级" clearable aria-label="优先级">
<el-option label="紧急" value="urgent" />
<el-option label="重要" value="important" />
<el-option label="普通" value="normal" />
</el-select>
<div class="record-date-range" aria-label="触发时间范围">
<label class="record-date-field"><span>开始日期</span><input v-model="filters.dateFrom" type="date" aria-label="人工关注开始日期" /></label>
<span class="record-date-sep"></span>
<label class="record-date-field"><span>结束日期</span><input v-model="filters.dateTo" type="date" aria-label="人工关注结束日期" /></label>
</div>
<div class="record-filter-actions">
<el-button type="primary" @click="applyFilters">查询</el-button>
<el-button @click="resetFilters">重置</el-button>
</div>
</section>
<el-table :data="records" stripe
><el-table-column label="优先级" width="100"
><template #default="{ row }"
><el-tag
:type="
row.priority === 'urgent'
? 'danger'
: row.priority === 'important'
? 'warning'
: 'info'
"
>{{ priorityLabel(row.priority) }}</el-tag
></template
></el-table-column
><el-table-column
prop="problemSummary"
label="问题摘要"
min-width="220"
/><el-table-column
prop="triggerReason"
label="触发原因"
min-width="220"
/><el-table-column
prop="userId"
label="用户"
width="80"
/><el-table-column
prop="sessionId"
label="会话"
width="80"
/><el-table-column label="状态" width="150"
><template #default="{ row }"
><el-select
:model-value="row.status"
size="small"
@change="updateStatus(row, $event)"
><el-option label="待处理" value="pending" /><el-option
label="处理中"
value="processing" /><el-option
label="已处理"
value="resolved" /><el-option
label="无需处理"
value="ignored" /></el-select></template></el-table-column
><el-table-column label="触发时间" width="180"
><template #default="{ row }">{{ formatDateTime(row.createdAt) }}</template
></el-table-column><el-table-column label="操作" width="130" fixed="right"
><template #default="{ row }"
><el-tooltip
v-if="row.targetAvailable === false"
content="原会话或触发消息已删除"
placement="top"
>
<span><el-button link disabled>原会话</el-button></span>
</el-tooltip>
<el-button v-else link type="primary" @click="openChat(row)"
>原会话</el-button
><el-button link type="danger" @click="deleteRecord(row)"
>删除</el-button
></template
></el-table-column
></el-table
><AdminPagination
:page="pager.page"
:page-size="pager.pageSize"
:total="pager.total"
@change="loadRecords"
/><ChatDetailDrawer
v-model="chatDetailOpen"
:session-id="sessionId"
:focus-message-id="focusMessageId"
/>
</el-tab-pane>
<el-tab-pane v-if="canConfig || canPreview" label="筛选规则与预览" lazy>
<AttentionRuleConfigPanel :can-config="canConfig" :can-preview="canPreview" />
</el-tab-pane>
</el-tabs>
</section>
</template>
<style scoped>
.attention-tabs { margin-top: 14px; }
.attention-toolbar {
align-items: center;
margin-bottom: 16px;
padding: 14px 16px;
border: 1px solid #e7ebf0;
border-radius: 12px;
background: #fafbfc;
}
@media (max-width: 900px) {
.attention-toolbar { align-items: stretch; }
.attention-toolbar .record-date-range { width: 100%; }
.attention-toolbar .record-date-field { flex: 1; width: auto; }
.attention-toolbar .record-filter-actions { width: 100%; }
}
</style>

View File

@@ -0,0 +1,229 @@
<script setup lang="ts">
import { ElMessage } from "element-plus";
import { computed, onMounted, reactive, ref } from "vue";
import { api } from "../services/api";
import type { AttentionConfig, AttentionPreviewMessage, AttentionPreviewResult } from "../types/api";
import AdminPagination from "./AdminPagination.vue";
import { formatDateTime } from "../utils/dateTime";
const props = defineProps<{ canConfig: boolean; canPreview: boolean }>();
const loading = ref(false);
const saving = ref(false);
const previewLoading = ref(false);
const previewMessages = ref<AttentionPreviewMessage[]>([]);
const selectedMessages = ref<AttentionPreviewMessage[]>([]);
const previewResults = ref<AttentionPreviewResult[]>([]);
const keyword = ref("");
const pager = reactive({ page: 1, pageSize: 20, total: 0 });
const terms = reactive({ urgent: "", important: "", normal: "" });
const config = reactive<AttentionConfig>({
enabled: true,
keywordEnabled: true,
aiEnabled: false,
knowledgeMissingEnabled: true,
urgentTerms: [],
importantTerms: [],
normalTerms: [],
promptTemplate: "",
recognitionItems: [],
});
const canRunPreview = computed(() => props.canPreview && selectedMessages.value.length > 0 && !previewLoading.value);
onMounted(async () => {
if (props.canConfig || props.canPreview) await loadConfig();
if (props.canPreview) await loadPreviewMessages();
});
async function loadConfig() {
loading.value = true;
try {
assignConfig(props.canConfig ? await api.attentionConfig() : await api.attentionPreviewConfig());
} finally {
loading.value = false;
}
}
function assignConfig(value: AttentionConfig) {
Object.assign(config, value);
terms.urgent = value.urgentTerms.join("\n");
terms.important = value.importantTerms.join("\n");
terms.normal = value.normalTerms.join("\n");
}
function currentConfig(): AttentionConfig {
return {
...config,
urgentTerms: splitTerms(terms.urgent),
importantTerms: splitTerms(terms.important),
normalTerms: splitTerms(terms.normal),
};
}
function splitTerms(value: string) {
return [...new Set(value.split(/[\n,]+/).map((item) => item.trim()).filter(Boolean))];
}
function addRecognitionItem() {
if (config.recognitionItems.length >= 30) {
ElMessage.warning("可识别项最多配置 30 个");
return;
}
config.recognitionItems.push({ name: "", description: "", priority: "normal", enabled: true });
}
function removeRecognitionItem(index: number) {
config.recognitionItems.splice(index, 1);
}
async function saveConfig() {
saving.value = true;
try {
assignConfig(await api.saveAttentionConfig(currentConfig()));
ElMessage.success("人工关注筛选规则已保存,后续新问答立即生效");
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : "配置保存失败");
} finally {
saving.value = false;
}
}
async function loadPreviewMessages(page = 1, pageSize = pager.pageSize) {
previewLoading.value = true;
try {
const result = await api.attentionPreviewMessages({ keyword: keyword.value.trim(), page, pageSize });
previewMessages.value = result.items;
selectedMessages.value = [];
Object.assign(pager, { page: result.page, pageSize: result.pageSize, total: result.total });
} finally {
previewLoading.value = false;
}
}
function onSelectionChange(rows: AttentionPreviewMessage[]) {
if (rows.length > 20) {
ElMessage.warning("单次最多预览 20 条历史消息");
selectedMessages.value = rows.slice(0, 20);
return;
}
selectedMessages.value = rows;
}
async function runPreview() {
if (!canRunPreview.value) return;
previewLoading.value = true;
try {
previewResults.value = await api.previewAttention(
selectedMessages.value.map((item) => item.messageId),
currentConfig(),
);
ElMessage.success(`已完成 ${previewResults.value.length} 条历史消息筛选预览`);
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : "提示词筛选预览失败");
} finally {
previewLoading.value = false;
}
}
function priorityLabel(value: string) {
return ({ urgent: "紧急", important: "重要", normal: "普通" } as Record<string, string>)[value] || "-";
}
function sourceLabel(value: string) {
return ({ keyword: "关键词", knowledge_missing: "知识缺失", ai: "AI 提示词", rules: "未触发", disabled: "总开关关闭" } as Record<string, string>)[value] || value;
}
</script>
<template>
<section v-loading="loading" class="attention-config-workspace">
<el-alert
title="运行顺序:紧急/重要/普通关键词 → 知识缺失 → AI 提示词。确定性规则命中后不再调用模型。"
type="info"
:closable="false"
show-icon
/>
<article v-if="canConfig" class="attention-config-card">
<header><div><h3>自动筛选规则</h3><p>配置只影响保存后的新问答不会自动重跑已有记录</p></div><el-button type="primary" :loading="saving" @click="saveConfig">保存规则</el-button></header>
<div class="attention-switch-grid">
<label><span><strong>启用人工关注自动筛选</strong><small>关闭后不再自动创建新的关注记录</small></span><el-switch v-model="config.enabled" /></label>
<label><span><strong>启用关键词筛选</strong><small>优先执行不产生模型调用费用</small></span><el-switch v-model="config.keywordEnabled" /></label>
<label><span><strong>知识缺失自动关注</strong><small>知识库应答缺少可靠正式知识时触发</small></span><el-switch v-model="config.knowledgeMissingEnabled" /></label>
<label><span><strong>启用 AI 提示词筛选</strong><small>仅在前面规则未命中时异步执行不会拖慢用户收到回答</small></span><el-switch v-model="config.aiEnabled" /></label>
</div>
<div class="attention-term-grid">
<label><strong>紧急关键词</strong><small>每行一个命中后标记为紧急</small><el-input v-model="terms.urgent" type="textarea" :rows="6" /></label>
<label><strong>重要关键词</strong><small>每行一个命中后标记为重要</small><el-input v-model="terms.important" type="textarea" :rows="6" /></label>
<label><strong>普通关键词</strong><small>每行一个命中后标记为普通</small><el-input v-model="terms.normal" type="textarea" :rows="6" /></label>
</div>
<section class="recognition-item-editor">
<header><span><strong>AI 可识别项</strong><small>语义筛选只会从已启用项目中选择可按业务需要继续增加</small></span><el-button @click="addRecognitionItem">新增可识别项</el-button></header>
<el-table :data="config.recognitionItems" border>
<el-table-column label="启用" width="72"><template #default="{ row }"><el-switch v-model="row.enabled" /></template></el-table-column>
<el-table-column label="项目名称" width="180"><template #default="{ row }"><el-input v-model="row.name" maxlength="50" placeholder="例如:疑似投诉" /></template></el-table-column>
<el-table-column label="识别说明" min-width="360"><template #default="{ row }"><el-input v-model="row.description" maxlength="500" placeholder="说明什么情况下算命中,尽量写清边界" /></template></el-table-column>
<el-table-column label="优先级" width="130"><template #default="{ row }"><el-select v-model="row.priority"><el-option label="紧急" value="urgent" /><el-option label="重要" value="important" /><el-option label="普通" value="normal" /></el-select></template></el-table-column>
<el-table-column label="操作" width="80"><template #default="{ $index }"><el-button link type="danger" @click="removeRecognitionItem($index)">删除</el-button></template></el-table-column>
</el-table>
</section>
<label class="attention-prompt-editor">
<span><strong>AI 筛选提示词</strong><small>系统会自动追加历史问答数据安全边界和固定 JSON 输出格式无需在提示词中写变量</small></span>
<el-input v-model="config.promptTemplate" type="textarea" :rows="12" maxlength="8000" show-word-limit />
</label>
</article>
<article v-if="canPreview" class="attention-config-card">
<header><div><h3>历史消息筛选预览</h3><p>可使用当前页面尚未保存的关键词和提示词运行不会创建人工关注记录</p></div><el-button type="primary" :loading="previewLoading" :disabled="!canRunPreview" @click="runPreview">预览选中消息</el-button></header>
<div class="attention-preview-search"><el-input v-model="keyword" clearable placeholder="搜索历史问题、用户姓名或手机号" @keyup.enter="loadPreviewMessages(1)" /><el-button @click="loadPreviewMessages(1)">查询</el-button><span>已选 {{ selectedMessages.length }}/20</span></div>
<el-table :data="previewMessages" stripe @selection-change="onSelectionChange">
<el-table-column type="selection" width="48" />
<el-table-column prop="question" label="历史问题" min-width="280" show-overflow-tooltip />
<el-table-column prop="userName" label="用户" width="120" />
<el-table-column prop="sessionTitle" label="会话" min-width="180" show-overflow-tooltip />
<el-table-column label="知识缺失" width="100"><template #default="{ row }"><el-tag :type="row.knowledgeMissing ? 'warning' : 'info'">{{ row.knowledgeMissing ? "是" : "否" }}</el-tag></template></el-table-column>
<el-table-column label="提问时间" width="180"><template #default="{ row }">{{ formatDateTime(row.createdAt) }}</template></el-table-column>
</el-table>
<AdminPagination :page="pager.page" :page-size="pager.pageSize" :total="pager.total" @change="loadPreviewMessages" />
</article>
<article v-if="previewResults.length" class="attention-config-card preview-result-card">
<header><div><h3>筛选结果</h3><p>结果仅供调整规则和提示词不会写入关注记录</p></div></header>
<el-table :data="previewResults" stripe>
<el-table-column label="结果" width="100"><template #default="{ row }"><el-tag :type="row.needsAttention ? 'danger' : 'success'">{{ row.needsAttention ? "需关注" : "不关注" }}</el-tag></template></el-table-column>
<el-table-column label="优先级" width="90"><template #default="{ row }">{{ priorityLabel(row.priority) }}</template></el-table-column>
<el-table-column label="来源" width="110"><template #default="{ row }">{{ sourceLabel(row.source) }}</template></el-table-column>
<el-table-column prop="matchedItem" label="命中项目" width="140" />
<el-table-column prop="question" label="历史问题" min-width="220" show-overflow-tooltip />
<el-table-column prop="reason" label="判断理由" min-width="220" />
<el-table-column prop="summary" label="问题摘要" min-width="220" />
<el-table-column label="详情" width="100"><template #default="{ row }"><el-popover placement="left" :width="560" trigger="click"><template #reference><el-button link type="primary">提示词/原始输出</el-button></template><div class="attention-preview-detail"><strong>最终提示词</strong><pre>{{ row.renderedPrompt || '关键词规则命中,未调用 AI' }}</pre><strong>模型原始输出</strong><pre>{{ row.rawOutput || '无' }}</pre></div></el-popover></template></el-table-column>
</el-table>
</article>
</section>
</template>
<style scoped>
.attention-config-workspace { display: grid; gap: 18px; }
.attention-config-card { padding: 20px; border: 1px solid #e3e9e7; border-radius: 14px; background: #fff; }
.attention-config-card > header { display: flex; align-items: flex-start; justify-content: space-between; gap: 18px; margin-bottom: 18px; }
.attention-config-card h3 { margin: 0; color: #1f2d29; }
.attention-config-card p { margin: 6px 0 0; color: #73817c; font-size: 13px; }
.attention-switch-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; }
.attention-switch-grid label { display: flex; align-items: center; justify-content: space-between; gap: 16px; padding: 14px; border: 1px solid #e6ece9; border-radius: 10px; }
.attention-switch-grid span, .attention-prompt-editor > span { display: grid; gap: 5px; }
.attention-switch-grid small, .attention-term-grid small, .attention-prompt-editor small { color: #82908b; font-weight: 400; }
.attention-term-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 14px; margin-top: 16px; }
.attention-term-grid label { display: grid; gap: 7px; }
.attention-prompt-editor { display: grid; gap: 10px; margin-top: 18px; }
.recognition-item-editor { display: grid; gap: 10px; margin-top: 18px; }
.recognition-item-editor > header { display: flex; align-items: center; justify-content: space-between; gap: 16px; }
.recognition-item-editor > header span { display: grid; gap: 5px; }
.recognition-item-editor small { color: #82908b; font-weight: 400; }
.attention-preview-search { display: grid; grid-template-columns: minmax(260px, 520px) auto 1fr; align-items: center; gap: 10px; margin-bottom: 14px; }
.attention-preview-search span { color: #73817c; font-size: 13px; }
.attention-preview-detail { display: grid; gap: 8px; }
.attention-preview-detail pre { max-height: 300px; margin: 0 0 10px; padding: 10px; overflow: auto; border-radius: 8px; background: #f6f8f7; white-space: pre-wrap; word-break: break-word; }
@media (max-width: 980px) { .attention-switch-grid, .attention-term-grid { grid-template-columns: 1fr; } }
</style>

View File

@@ -0,0 +1,518 @@
<script setup lang="ts">
import { ElMessage } from "element-plus";
import { nextTick, reactive, ref, watch } from "vue";
import { api } from "../services/api";
import type { ChatDetail } from "../types/api";
import AdminPagination from "./AdminPagination.vue";
import { formatDateTime } from "../utils/dateTime";
const props = defineProps<{
modelValue: boolean;
sessionId: number | null;
focusMessageId?: number | null;
canExport?: boolean;
}>();
const emit = defineEmits<{ "update:modelValue": [value: boolean] }>();
const detail = ref<ChatDetail | null>(null);
const loading = ref(false);
const messagesLoading = ref(false);
const aiLogsLoading = ref(false);
const aiLogDetailsLoading = ref<number[]>([]);
const activeAiLogIds = ref<number[]>([]);
const messageKeyword = ref("");
const appliedMessageKeyword = ref("");
const exporting = ref(false);
const bodyRef = ref<HTMLElement | null>(null);
const pager = reactive({ page: 1, pageSize: 20, total: 0 });
const aiPager = reactive({ page: 1, pageSize: 20, total: 0 });
watch(
() => [props.modelValue, props.sessionId, props.focusMessageId] as const,
async ([open, sessionId]) => {
if (!open || !sessionId) return;
detail.value = null;
Object.assign(pager, { page: 1, total: 0 });
Object.assign(aiPager, { page: 1, total: 0 });
messageKeyword.value = "";
appliedMessageKeyword.value = "";
activeAiLogIds.value = [];
await loadDetail(sessionId, 1, pager.pageSize);
},
);
async function loadDetail(sessionId: number, page: number, pageSize: number) {
loading.value = true;
try {
detail.value = await api.chatDetail(sessionId, {
messagePage: page,
messagePageSize: pageSize,
focusMessageId: props.focusMessageId || undefined,
});
const messagePage = detail.value.messagesPage;
Object.assign(pager, {
page: messagePage?.page ?? page,
pageSize: messagePage?.pageSize ?? pageSize,
total: messagePage?.total ?? detail.value.messages.length,
});
void loadAiLogs(sessionId, 1, aiPager.pageSize);
await scrollToFocusedMessage();
} catch (error) {
ElMessage.error(
error instanceof Error ? error.message : "聊天详情加载失败",
);
} finally {
loading.value = false;
}
}
async function scrollToFocusedMessage() {
if (!props.focusMessageId) return;
await nextTick();
const target = bodyRef.value?.querySelector<HTMLElement>(
`[data-message-id="${props.focusMessageId}"]`,
);
target?.scrollIntoView({ behavior: "smooth", block: "center" });
}
async function changeMessagePage(page: number, pageSize: number) {
if (!props.sessionId || !detail.value) return;
messagesLoading.value = true;
try {
const result = await api.chatMessages(props.sessionId, {
keyword: appliedMessageKeyword.value || undefined,
page,
pageSize,
});
detail.value = {
...detail.value,
messages: result.items,
messagesPage: result,
};
Object.assign(pager, {
page: result.page,
pageSize: result.pageSize,
total: result.total,
});
await nextTick();
bodyRef.value?.querySelector(".conversation-title-row")?.scrollIntoView({ behavior: "smooth", block: "start" });
} catch (error) {
ElMessage.error(
error instanceof Error ? error.message : "聊天消息加载失败",
);
} finally {
messagesLoading.value = false;
}
}
async function searchMessages() {
appliedMessageKeyword.value = messageKeyword.value.trim();
await changeMessagePage(1, pager.pageSize);
}
async function clearMessageSearch() {
messageKeyword.value = "";
if (!appliedMessageKeyword.value) return;
appliedMessageKeyword.value = "";
await changeMessagePage(1, pager.pageSize);
}
function messageParts(content: string) {
const keyword = appliedMessageKeyword.value;
if (!keyword) return [{ text: content, matched: false }];
const parts: Array<{ text: string; matched: boolean }> = [];
const lowerContent = content.toLocaleLowerCase();
const lowerKeyword = keyword.toLocaleLowerCase();
let cursor = 0;
let index = lowerContent.indexOf(lowerKeyword);
while (index >= 0) {
if (index > cursor) parts.push({ text: content.slice(cursor, index), matched: false });
parts.push({ text: content.slice(index, index + keyword.length), matched: true });
cursor = index + keyword.length;
index = lowerContent.indexOf(lowerKeyword, cursor);
}
if (cursor < content.length) parts.push({ text: content.slice(cursor), matched: false });
return parts.length ? parts : [{ text: content, matched: false }];
}
async function loadAiLogs(sessionId: number, page: number, pageSize: number) {
aiLogsLoading.value = true;
try {
const result = await api.aiLogs({ sessionId, page, pageSize });
if (!detail.value || props.sessionId !== sessionId) return;
detail.value.aiLogs = result.items;
Object.assign(aiPager, {
page: result.page,
pageSize: result.pageSize,
total: result.total,
});
activeAiLogIds.value = [];
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : "关联 AI 请求加载失败");
} finally {
aiLogsLoading.value = false;
}
}
async function handleAiLogChange(activeNames: string | number | Array<string | number>) {
const ids = (Array.isArray(activeNames) ? activeNames : [activeNames])
.map(Number)
.filter(Number.isFinite);
for (const id of ids) {
const log = detail.value?.aiLogs.find((item) => item.id === id);
if (!log || Object.prototype.hasOwnProperty.call(log, "prompt") || aiLogDetailsLoading.value.includes(id)) continue;
aiLogDetailsLoading.value = [...aiLogDetailsLoading.value, id];
try {
const fullLog = await api.aiLogDetail(id);
const index = detail.value?.aiLogs.findIndex((item) => item.id === id) ?? -1;
if (detail.value && index >= 0) detail.value.aiLogs[index] = fullLog;
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : "AI 请求详情加载失败");
} finally {
aiLogDetailsLoading.value = aiLogDetailsLoading.value.filter((item) => item !== id);
}
}
}
async function exportConversation() {
if (!props.sessionId) return;
exporting.value = true;
try {
await api.exportChatDetail(props.sessionId);
ElMessage.success("当前完整对话已导出");
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : "完整对话导出失败");
} finally {
exporting.value = false;
}
}
</script>
<template>
<el-drawer
:model-value="modelValue"
size="760px"
title="聊天详情"
@update:model-value="emit('update:modelValue', $event)"
>
<div ref="bodyRef" v-loading="loading" class="chat-detail-body">
<template v-if="detail">
<div v-if="canExport" class="chat-detail-actions">
<el-button type="primary" plain :loading="exporting" @click="exportConversation">导出完整对话</el-button>
</div>
<section class="chat-summary">
<div>
<span>会话ID</span><strong>{{ detail.session.id }}</strong>
</div>
<div>
<span>用户</span
><strong>{{ detail.session.userName || "-" }}</strong>
</div>
<div>
<span>手机号</span
><strong>{{ detail.session.userPhone || "-" }}</strong>
</div>
<div>
<span>消息数</span
><strong>{{ detail.session.messageCount }}</strong>
</div>
</section>
<h3 class="detail-title">主题会话</h3>
<el-collapse
v-if="detail.topics?.length"
class="topic-summary-collapse"
>
<el-collapse-item
v-for="topic in detail.topics"
:key="topic.id"
:title="`#${topic.id} ${topic.title} / ${topic.status}`"
>
<section class="topic-summary-card">
<div class="topic-summary-meta">
<span>核心问题{{ topic.coreQuestion }}</span
><span>消息数{{ topic.messageCount }}</span
><span
>Token{{ topic.tokenInput }}/{{ topic.tokenOutput }}</span
><span>开始{{ formatDateTime(topic.startedAt) }}</span
><span>结束{{ formatDateTime(topic.endedAt) }}</span>
</div>
<template v-if="topic.summary">
<pre>{{ topic.summary.summary }}</pre>
<div class="topic-summary-fields">
<p v-if="topic.summary.currentFocus">
<strong>本次关注</strong>{{ topic.summary.currentFocus }}
</p>
<p v-if="topic.summary.nextObservation">
<strong>可以继续留意</strong
>{{ topic.summary.nextObservation }}
</p>
</div></template
>
<el-empty
v-else
description="该主题尚未沉淀摘要"
:image-size="60"
/>
</section>
</el-collapse-item>
</el-collapse>
<el-empty v-else description="该会话暂无主题记录" :image-size="64" />
<h3 class="detail-title">
老师求助卡{{ detail.helpCards?.length || 0 }}
</h3>
<el-collapse
v-if="detail.helpCards?.length"
class="topic-summary-collapse"
><el-collapse-item
v-for="card in detail.helpCards"
:key="card.id"
:title="`#${card.id} / 主题 #${card.topicSessionId} / ${card.copied ? '用户已复制' : '未复制'}`"
><section class="topic-summary-card">
<div class="topic-summary-meta">
<span>生成{{ formatDateTime(card.createdAt) }}</span
><span>复制{{ formatDateTime(card.copiedAt) }}</span
><span>来源{{ card.source }}</span>
</div>
<pre>{{ card.content }}</pre>
</section></el-collapse-item
></el-collapse
>
<el-empty
v-if="!aiLogsLoading && !detail.aiLogs.length"
description="该会话暂无关联 AI 请求"
:image-size="64"
/>
<el-empty
v-else
description="该会话暂无用户生成的求助卡"
:image-size="64"
/>
<h3 class="detail-title">
班级分享稿{{ detail.shareDrafts?.length || 0 }}
</h3>
<el-collapse
v-if="detail.shareDrafts?.length"
class="topic-summary-collapse"
><el-collapse-item
v-for="draft in detail.shareDrafts"
:key="draft.id"
:title="`#${draft.id} / 主题 #${draft.topicSessionId} / ${draft.copied ? '用户已复制' : '未复制'}`"
><section class="topic-summary-card">
<div class="topic-summary-meta">
<span>生成{{ formatDateTime(draft.createdAt) }}</span
><span>复制{{ formatDateTime(draft.copiedAt) }}</span
><span>来源{{ draft.source }}</span>
</div>
<pre>{{ draft.content }}</pre>
</section></el-collapse-item
></el-collapse
>
<el-empty
v-else
description="该会话暂无用户生成的班级分享稿"
:image-size="64"
/>
<div class="conversation-title-row">
<h3 class="detail-title">完整对话</h3>
<span v-if="appliedMessageKeyword">找到 {{ pager.total }} 条匹配消息</span>
</div>
<div class="conversation-search">
<el-input
v-model="messageKeyword"
clearable
maxlength="100"
placeholder="输入关键字搜索当前会话"
@keyup.enter="searchMessages"
@clear="clearMessageSearch"
/>
<el-button type="primary" :loading="messagesLoading" @click="searchMessages">搜索</el-button>
<el-button v-if="appliedMessageKeyword" @click="clearMessageSearch">返回完整对话</el-button>
</div>
<AdminPagination
class="chat-detail-pagination top"
:page="pager.page"
:page-size="pager.pageSize"
:total="pager.total"
@change="changeMessagePage"
/>
<section v-loading="messagesLoading" class="conversation-list">
<article
v-for="message in detail.messages"
:key="message.id"
class="conversation-message"
:class="[
message.role,
{ 'focus-message': message.id === focusMessageId },
]"
:data-message-id="message.id"
>
<div class="message-meta">
<strong>{{
message.role === "user" ? "用户" : "大本营答疑助手"
}}</strong
><el-tag
v-if="message.id === focusMessageId"
type="warning"
effect="light"
>触发人工关注的消息</el-tag
><span>{{ formatDateTime(message.createdAt) }}</span>
</div>
<pre><template v-for="(part, index) in messageParts(message.content)" :key="index"><mark v-if="part.matched">{{ part.text }}</mark><template v-else>{{ part.text }}</template></template></pre>
<div class="message-extra">
<span>状态{{ message.messageStatus }}</span
><span v-if="message.tokenInput"
>输入 Token{{ message.tokenInput }}</span
><span v-if="message.tokenOutput"
>输出 Token{{ message.tokenOutput }}</span
><span v-if="message.responseTimeMs"
>耗时{{ message.responseTimeMs }}ms</span
>
</div>
</article>
</section>
<AdminPagination
class="chat-detail-pagination"
:page="pager.page"
:page-size="pager.pageSize"
:total="pager.total"
@change="changeMessagePage"
/>
<h3 class="detail-title">关联 AI 请求</h3>
<el-collapse
v-model="activeAiLogIds"
v-loading="aiLogsLoading"
@change="handleAiLogChange"
><el-collapse-item
v-for="log in detail.aiLogs"
:key="log.id"
:title="`请求 #${log.id} / ${log.status}`"
><div class="ai-log-grid">
<span>模型{{ log.modelName || "-" }}</span
><span>知识库{{ log.knowledgeIds || "-" }}</span
><span>命中数{{ log.retrieveCount }}</span
><span>Token{{ log.totalToken || "-" }}</span
><span>耗时{{ log.costMs || "-" }}ms</span
><span>时间{{ formatDateTime(log.createdAt) }}</span>
</div>
<p v-if="aiLogDetailsLoading.includes(log.id)" class="ai-log-loading">正在加载请求详情</p>
<section
v-else-if="log.retrievedChunks?.length"
class="retrieval-chunks"
>
<article
v-for="chunk in log.retrievedChunks"
:key="`${log.id}-${chunk.index}`"
class="retrieval-chunk"
>
<div class="chunk-head">
<strong
>#{{ chunk.index }}
{{ chunk.knowledgeName || "未知知识库" }}</strong
><span>{{ chunk.title || "未命名片段" }}</span>
</div>
<pre>{{ chunk.content }}</pre>
<a
v-if="chunk.sourceUrl"
:href="chunk.sourceUrl"
target="_blank"
rel="noreferrer"
>查看来源</a
>
</article>
</section>
<el-empty
v-else
description="本条请求未保存召回片段"
:image-size="64"
/>
<pre v-if="!aiLogDetailsLoading.includes(log.id)" class="prompt-preview">{{
log.prompt || " Prompt 记录"
}}</pre>
<p v-if="log.errorMessage" class="error-text">
{{ log.errorMessage }}
</p></el-collapse-item
></el-collapse
>
<AdminPagination
v-if="aiPager.total"
:page="aiPager.page"
:page-size="aiPager.pageSize"
:total="aiPager.total"
@change="(page, size) => sessionId && loadAiLogs(sessionId, page, size)"
/>
</template>
<el-empty
v-else-if="!loading"
description="聊天详情加载失败或暂无数据"
:image-size="80"
/>
</div>
</el-drawer>
</template>
<style scoped>
.chat-detail-actions {
display: flex;
justify-content: flex-end;
margin-bottom: 14px;
}
.conversation-title-row,
.conversation-search {
display: flex;
align-items: center;
gap: 10px;
}
.conversation-title-row {
justify-content: space-between;
}
.conversation-title-row > span {
color: #7b8494;
font-size: 13px;
}
.conversation-search {
margin: 0 0 12px;
}
.conversation-search :deep(.el-input) {
max-width: 360px;
}
.conversation-message mark {
padding: 1px 3px;
border-radius: 4px;
background: #ffe58f;
color: inherit;
}
.ai-log-loading {
padding: 16px;
color: #7b8494;
text-align: center;
}
.conversation-message.focus-message {
border: 2px solid #e6a23c;
background: #fffaf0;
box-shadow: 0 0 0 4px rgb(230 162 60 / 10%);
animation: focus-message-pulse 1.2s ease-out;
}
@keyframes focus-message-pulse {
0% { box-shadow: 0 0 0 0 rgb(230 162 60 / 38%); }
65% { box-shadow: 0 0 0 12px rgb(230 162 60 / 8%); }
100% { box-shadow: 0 0 0 4px rgb(230 162 60 / 10%); }
}
.conversation-message.focus-message .message-meta {
align-items: center;
gap: 10px;
}
.conversation-message.focus-message .message-meta span:last-child {
margin-left: auto;
}
</style>

View File

@@ -0,0 +1,478 @@
<script setup lang="ts">
import { ElMessage, ElMessageBox } from "element-plus";
import type { InputInstance } from "element-plus";
import { computed, nextTick, onMounted, reactive, ref } from "vue";
import { api } from "../services/api";
import type {
ContentGenerationConfigDetail,
ContentGenerationHistoryItem,
ContentGenerationType,
ContentGenerationVariable,
} from "../types/api";
import AdminPagination from "./AdminPagination.vue";
import ContentGenerationVariableEditor from "./ContentGenerationVariableEditor.vue";
import { formatDateTime } from "../utils/dateTime";
const activeType = ref<ContentGenerationType>("help_card");
const loading = ref(false);
const saving = ref(false);
const previewing = ref(false);
const testing = ref(false);
const detailLoading = ref(false);
const current = ref<ContentGenerationConfigDetail | null>(null);
const history = ref<ContentGenerationHistoryItem[]>([]);
const historyTotal = ref(0);
const historyPage = ref(1);
const historyPageSize = ref(10);
const previewContent = ref("");
const testUsedFallback = ref(false);
const selectedHistory = ref<ContentGenerationConfigDetail | null>(null);
const historyDialogOpen = ref(false);
const activeSection = ref<"variables" | "layout" | "test">("variables");
const templateInputRef = ref<InputInstance>();
const form = reactive<{ templateContent: string; instructionContent: string; variables: ContentGenerationVariable[] }>({
templateContent: "",
instructionContent: "",
variables: [],
});
const saved = reactive({ templateContent: "", instructionContent: "", variablesJson: "[]" });
const sampleText = ref(
"我第一次参加带练,不太确定练习顺序。做到一半身体有些紧,我会担心自己是不是做错了,想请老师确认什么时候应该暂停。",
);
const typeMeta: Record<ContentGenerationType, { label: string; description: string }> = {
help_card: { label: "老师求助卡", description: "给老师确认方向,不会自动转人工" },
share_draft: { label: "班级分享稿", description: "用户自行核对、复制和分享" },
weekly_report: { label: "周报告", description: "根据一周内的全部聊天分批整理" },
monthly_report: { label: "月报告", description: "根据本月覆盖的周报告汇总" },
};
const dirty = computed(
() => form.templateContent !== saved.templateContent
|| form.instructionContent !== saved.instructionContent
|| JSON.stringify(form.variables) !== saved.variablesJson,
);
const typeLabel = computed(() => typeMeta[activeType.value].label);
onMounted(loadAll);
async function loadAll() {
loading.value = true;
try {
const [config, page] = await Promise.all([
api.contentGenerationConfig(activeType.value),
api.contentGenerationHistory(activeType.value, { page: historyPage.value, pageSize: historyPageSize.value }),
]);
applyConfig(config);
history.value = page.items;
historyTotal.value = page.total;
await refreshPreview();
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : "内容生成配置加载失败");
} finally {
loading.value = false;
}
}
function applyConfig(config: ContentGenerationConfigDetail) {
current.value = config;
form.templateContent = config.templateContent;
form.instructionContent = config.instructionContent;
form.variables = config.variables.map((item) => ({ ...item }));
saved.templateContent = config.templateContent;
saved.instructionContent = config.instructionContent;
saved.variablesJson = JSON.stringify(config.variables);
}
async function switchType(type: ContentGenerationType) {
if (type === activeType.value) return;
if (dirty.value) {
try {
await ElMessageBox.confirm("当前修改尚未保存,切换后将丢失。确定继续吗?", "切换配置", {
confirmButtonText: "继续切换",
cancelButtonText: "留在当前页",
type: "warning",
});
} catch {
return;
}
}
activeType.value = type;
historyPage.value = 1;
previewContent.value = "";
testUsedFallback.value = false;
activeSection.value = "variables";
sampleText.value = type === "weekly_report"
? "周一讨论了练习顺序;周三聊到身体紧绷时会急着判断对错;周末继续确认什么时候应该暂停。请把这些内容当作一周内多段完整聊天的模拟材料。"
: type === "monthly_report"
? "第一周主要讨论练习准备,第二周关注身体紧绷,第三周继续梳理表达时的不确定感,第四周回顾了暂停和观察的区别。请把这些内容当作多份周报告的模拟材料。"
: "我第一次参加带练,不太确定练习顺序。做到一半身体有些紧,我会担心自己是不是做错了,想请老师确认什么时候应该暂停。";
await loadAll();
}
async function insertVariable(name: string) {
const token = `{{${name}}}`;
const textarea = templateInputRef.value?.textarea;
if (!textarea) {
form.templateContent = `${form.templateContent}${form.templateContent.endsWith("\n") ? "" : "\n"}${token}`;
return;
}
const start = textarea.selectionStart ?? form.templateContent.length;
const end = textarea.selectionEnd ?? start;
form.templateContent = `${form.templateContent.slice(0, start)}${token}${form.templateContent.slice(end)}`;
await nextTick();
textarea.focus();
textarea.setSelectionRange(start + token.length, start + token.length);
}
function variableToken(name: string) {
return `{{${name}}}`;
}
async function refreshPreview() {
previewing.value = true;
try {
const result = await api.previewContentGeneration({
configType: activeType.value,
templateContent: form.templateContent,
variables: form.variables,
});
previewContent.value = result.content;
testUsedFallback.value = false;
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : "预览生成失败");
} finally {
previewing.value = false;
}
}
async function testGeneration() {
testing.value = true;
try {
const result = await api.testContentGeneration({
configType: activeType.value,
templateContent: form.templateContent,
instructionContent: form.instructionContent,
sampleText: sampleText.value,
variables: form.variables,
});
previewContent.value = result.content;
testUsedFallback.value = result.usedFallback;
ElMessage.success(result.usedFallback ? "模型结果不可用,已展示安全回退结果" : "AI 整理测试完成");
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : "AI 整理测试失败");
} finally {
testing.value = false;
}
}
async function saveConfig() {
if (!dirty.value || saving.value) return;
saving.value = true;
try {
const config = await api.saveContentGenerationConfig(activeType.value, {
templateContent: form.templateContent,
instructionContent: form.instructionContent,
variables: form.variables,
});
applyConfig(config);
ElMessage.success("已保存并立即发布为新版本");
historyPage.value = 1;
await loadHistory();
await refreshPreview();
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : "配置保存失败");
} finally {
saving.value = false;
}
}
async function resetConfig() {
try {
await ElMessageBox.confirm(
`确定将${typeLabel.value}恢复为系统默认配置吗?系统会保留当前版本,可随时回滚。`,
"恢复默认配置",
{ confirmButtonText: "恢复默认", cancelButtonText: "取消", type: "warning" },
);
saving.value = true;
const config = await api.resetContentGenerationConfig(activeType.value);
applyConfig(config);
historyPage.value = 1;
await loadHistory();
await refreshPreview();
ElMessage.success("已恢复默认配置");
} catch (error) {
if (error instanceof Error) ElMessage.error(error.message);
} finally {
saving.value = false;
}
}
async function loadHistory() {
const page = await api.contentGenerationHistory(activeType.value, {
page: historyPage.value,
pageSize: historyPageSize.value,
});
history.value = page.items;
historyTotal.value = page.total;
}
async function changeHistoryPagination(page: number, pageSize: number) {
historyPage.value = page;
historyPageSize.value = pageSize;
await loadHistory();
}
async function openHistory(item: ContentGenerationHistoryItem) {
historyDialogOpen.value = true;
detailLoading.value = true;
selectedHistory.value = null;
try {
selectedHistory.value = await api.contentGenerationHistoryDetail(activeType.value, item.id);
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : "历史版本加载失败");
historyDialogOpen.value = false;
} finally {
detailLoading.value = false;
}
}
async function restoreHistory(id: number) {
try {
await ElMessageBox.confirm("确定回滚到这个版本吗?回滚会生成一个新版本,不会覆盖历史记录。", "回滚配置", {
confirmButtonText: "确认回滚",
cancelButtonText: "取消",
type: "warning",
});
saving.value = true;
const config = await api.restoreContentGenerationConfig(activeType.value, id);
applyConfig(config);
historyDialogOpen.value = false;
historyPage.value = 1;
await loadHistory();
await refreshPreview();
ElMessage.success("已回滚并发布为新版本");
} catch (error) {
if (error instanceof Error) ElMessage.error(error.message);
} finally {
saving.value = false;
}
}
function changeTypeLabel(value: string) {
return { save: "保存", reset: "恢复默认", restore: "历史回滚", default: "系统默认" }[value] || value;
}
</script>
<template>
<div v-loading="loading" class="content-generation-page">
<div class="page-head inline">
<div>
<h2>内容生成配置</h2>
<p>统一配置求助卡分享稿周报告和月报告的变量AI 整理规则与排版保存后立即生效并保留历史版本</p>
</div>
<div class="config-head-actions">
<el-button :loading="previewing" @click="refreshPreview">刷新预览</el-button>
<el-button type="primary" :loading="saving" :disabled="!dirty" @click="saveConfig">保存并发布新版本</el-button>
</div>
</div>
<nav class="content-type-tabs" aria-label="内容生成类型">
<button type="button" :class="{ active: activeType === 'help_card' }" @click="switchType('help_card')">
<strong>老师求助卡</strong><span>给老师确认方向不会自动转人工</span>
</button>
<button type="button" :class="{ active: activeType === 'share_draft' }" @click="switchType('share_draft')">
<strong>班级分享稿</strong><span>用户自行核对复制和分享</span>
</button>
<button type="button" :class="{ active: activeType === 'weekly_report' }" @click="switchType('weekly_report')">
<strong>周报告</strong><span>根据一周内的全部聊天分批整理</span>
</button>
<button type="button" :class="{ active: activeType === 'monthly_report' }" @click="switchType('monthly_report')">
<strong>月报告</strong><span>根据本月覆盖的周报告汇总</span>
</button>
</nav>
<nav class="workflow-tabs" aria-label="内容配置步骤">
<button type="button" :class="{ active: activeSection === 'variables' }" @click="activeSection = 'variables'">
<span>1</span><div><strong>变量设置</strong><small>定义内容字段和提炼含义</small></div>
</button>
<button type="button" :class="{ active: activeSection === 'layout' }" @click="activeSection = 'layout'; refreshPreview()">
<span>2</span><div><strong>内容排版</strong><small>组合变量并查看实际效果</small></div>
</button>
<button type="button" :class="{ active: activeSection === 'test' }" @click="activeSection = 'test'">
<span>3</span><div><strong>AI 测试</strong><small>用模拟材料验证提炼质量</small></div>
</button>
</nav>
<section v-if="activeSection === 'variables'" class="generation-editor-column">
<article class="generation-panel">
<header>
<div><h3>自定义变量</h3><p>管理员可以定义名称含义和取值方式周报的 AI 变量从完整聊天材料提炼月报的 AI 变量从周报材料提炼</p></div>
<el-button text :disabled="saving" @click="resetConfig">恢复系统默认</el-button>
</header>
<ContentGenerationVariableEditor
v-model="form.variables"
:source-options="current?.sourceOptions || []"
/>
</article>
<article class="generation-panel">
<header><div><h3>全局 AI 整理规则</h3><p>这里控制当前内容的语气和边界每个字段具体提炼什么由上方变量含义决定</p></div></header>
<el-input v-model="form.instructionContent" type="textarea" :rows="7" resize="vertical" />
<small>{{ form.instructionContent.length }}/10000 字符</small>
</article>
</section>
<section v-else-if="activeSection === 'layout'" class="generation-editor-layout">
<div class="generation-editor-column">
<article class="generation-panel">
<header>
<div><h3>内容排版</h3><p>先把光标放到需要的位置再点击变量标题说明编号和空行都可以自由调整</p></div>
<el-button text :disabled="saving" @click="resetConfig">恢复系统默认</el-button>
</header>
<div class="variable-list">
<button v-for="item in form.variables" :key="item.name" type="button" @click="insertVariable(item.name)">
{{ item.label }} <code>{{ variableToken(item.name) }}</code>
</button>
</div>
<el-input ref="templateInputRef" v-model="form.templateContent" type="textarea" :rows="21" resize="vertical" />
<small>{{ form.templateContent.length }}/20000 字符</small>
</article>
<article class="locked-rules">
<strong>系统锁定的安全提醒</strong>
<p>{{ current?.lockedFooter }}</p>
<small>这段内容会固定追加在当前类型的所有生成内容末尾管理员不能删除</small>
</article>
</div>
<aside class="generation-preview-column">
<article class="generation-panel preview-panel">
<header>
<div><h3>排版效果预览</h3><p>使用变量的预览示例填充正式生成时周报读取完整聊天月报读取周报</p></div>
<el-button :loading="previewing" @click="refreshPreview">刷新</el-button>
</header>
<pre>{{ previewContent || "点击“刷新”查看效果" }}</pre>
</article>
</aside>
</section>
<section v-else class="generation-editor-layout test-layout">
<article class="generation-panel test-panel">
<header><div><h3>测试材料</h3><p>粘贴一段模拟用户表达AI 会按照当前自定义变量和变量含义进行提炼</p></div></header>
<el-input v-model="sampleText" type="textarea" :rows="14" resize="vertical" maxlength="20000" show-word-limit />
<el-button type="primary" :loading="testing" @click="testGeneration">运行 AI 提炼测试</el-button>
</article>
<article class="generation-panel preview-panel test-result">
<header><div><h3>测试结果</h3><p>本次测试不会保存配置也不会生成正式用户内容</p></div></header>
<pre>{{ previewContent || "运行测试后在这里查看结果" }}</pre>
<el-alert v-if="testUsedFallback" title="本次测试使用了安全回退结果,模型未返回有效结构化内容。" type="warning" :closable="false" show-icon />
</article>
</section>
<section class="generation-history generation-panel">
<header>
<div><h3>版本记录</h3><p>当前配置{{ current?.updatedByName }} · {{ formatDateTime(current?.updatedAt, "系统默认") }}</p></div>
<el-tag v-if="current" type="success">{{ changeTypeLabel(current.changeType) }}</el-tag>
</header>
<el-empty v-if="!history.length" description="尚未保存过版本,当前使用系统默认配置" :image-size="72" />
<div v-else class="generation-history-list">
<article v-for="item in history" :key="item.id" :class="{ current: item.isCurrent }">
<div>
<strong>版本 #{{ item.id }} <el-tag v-if="item.isCurrent" size="small" type="success">当前</el-tag></strong>
<p>{{ item.preview }}</p>
<small>{{ changeTypeLabel(item.changeType) }} · {{ item.updatedByName }} · {{ formatDateTime(item.updatedAt, "系统默认") }}</small>
</div>
<el-button @click="openHistory(item)">查看</el-button>
</article>
</div>
<AdminPagination
v-if="historyTotal > 0"
:page="historyPage"
:page-size="historyPageSize"
:total="historyTotal"
@change="changeHistoryPagination"
/>
</section>
<el-dialog v-model="historyDialogOpen" title="内容生成配置历史版本" width="min(760px, 92vw)" append-to-body>
<div v-loading="detailLoading" class="generation-history-detail">
<template v-if="selectedHistory">
<dl>
<div><dt>版本</dt><dd>#{{ selectedHistory.id }}</dd></div>
<div><dt>操作</dt><dd>{{ changeTypeLabel(selectedHistory.changeType) }}</dd></div>
<div><dt>操作人</dt><dd>{{ selectedHistory.updatedByName }}</dd></div>
<div><dt>时间</dt><dd>{{ formatDateTime(selectedHistory.updatedAt, "系统默认") }}</dd></div>
</dl>
<h4>变量定义</h4>
<div class="history-variables">
<span v-for="item in selectedHistory.variables" :key="item.name"><strong>{{ item.label }}</strong><code>{{ variableToken(item.name) }}</code></span>
</div>
<h4>内容模板</h4><pre>{{ selectedHistory.templateContent }}</pre>
<h4>AI 整理规则</h4><pre>{{ selectedHistory.instructionContent }}</pre>
</template>
</div>
<template #footer>
<el-button @click="historyDialogOpen = false">关闭</el-button>
<el-button v-if="selectedHistory" type="primary" :loading="saving" @click="restoreHistory(selectedHistory.id!)">回滚到此版本</el-button>
</template>
</el-dialog>
</div>
</template>
<style scoped>
.content-generation-page { display: grid; gap: 18px; }
.config-head-actions { display: flex; gap: 10px; }
.content-type-tabs { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 12px; }
.content-type-tabs button { min-height: 78px; display: grid; gap: 5px; padding: 15px 18px; border: 1px solid #dfe8e4; border-radius: 16px; background: #fff; color: #65746f; text-align: left; transition: border-color .18s ease, background .18s ease, transform .18s ease; }
.content-type-tabs button:hover { border-color: #91c8b5; transform: translateY(-1px); }
.content-type-tabs button.active { border-color: #43a783; background: #eff8f4; color: #176c51; box-shadow: 0 8px 24px rgba(23, 108, 81, .08); }
.content-type-tabs strong { font-size: 16px; }
.content-type-tabs span { font-size: 12px; }
.workflow-tabs { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); overflow: hidden; border: 1px solid #dfe8e4; border-radius: 16px; background: #fff; }
.workflow-tabs button { display: flex; align-items: center; gap: 11px; min-height: 68px; padding: 12px 18px; border: 0; border-right: 1px solid #e7eeeb; background: transparent; color: #73827d; text-align: left; }
.workflow-tabs button:last-child { border-right: 0; }
.workflow-tabs button > span { display: grid; flex: 0 0 28px; height: 28px; place-items: center; border-radius: 9px; background: #eef3f1; font-size: 12px; font-weight: 700; }
.workflow-tabs button > div { display: grid; gap: 3px; }
.workflow-tabs strong { font-size: 14px; }
.workflow-tabs small { font-size: 11px; }
.workflow-tabs button.active { background: #f0f8f5; color: #176c51; }
.workflow-tabs button.active > span { background: #42a782; color: #fff; }
.generation-editor-layout { display: grid; grid-template-columns: minmax(0, 1.15fr) minmax(360px, .85fr); gap: 16px; align-items: start; }
.generation-editor-column, .generation-preview-column { min-width: 0; display: grid; gap: 16px; }
.generation-panel, .locked-rules { padding: 18px; border: 1px solid #dfe8e4; border-radius: 16px; background: #fff; }
.generation-panel > header { display: flex; align-items: flex-start; justify-content: space-between; gap: 14px; margin-bottom: 14px; }
.generation-panel h3 { margin: 0; color: #1d342d; font-size: 17px; }
.generation-panel header p { margin: 5px 0 0; color: #75847f; font-size: 12px; line-height: 1.6; }
.generation-panel > small { display: block; margin-top: 7px; color: #8a9893; font-size: 11px; text-align: right; }
.variable-list { display: flex; flex-wrap: wrap; gap: 7px; margin-bottom: 12px; }
.variable-list button { padding: 6px 9px; border: 1px solid #d8e6e0; border-radius: 9px; background: #f7faf9; color: #49665c; font-size: 12px; }
.variable-list button:hover { border-color: #62b596; background: #edf7f3; }
.variable-list code { margin-left: 3px; color: #17805f; font-size: 11px; }
.locked-rules { border-color: #e9dfbd; background: #fffaf0; }
.locked-rules strong { color: #705d2e; }
.locked-rules p { margin: 9px 0 4px; color: #655b43; font-size: 13px; line-height: 1.75; white-space: pre-wrap; }
.locked-rules small { color: #8c805f; font-size: 11px; }
.preview-panel { position: sticky; top: 18px; }
.preview-panel pre, .generation-history-detail pre { max-height: 520px; overflow: auto; margin: 0; padding: 16px; border: 1px solid #e3ebe8; border-radius: 13px; background: #f8fbfa; color: #324b43; font: inherit; font-size: 13px; line-height: 1.8; white-space: pre-wrap; }
.preview-panel .el-alert { margin-top: 12px; }
.test-panel .el-button { width: 100%; margin-top: 12px; }
.test-layout { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.test-result { position: static; }
.generation-history-list { display: grid; gap: 10px; }
.generation-history-list article { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: center; gap: 14px; padding: 13px 14px; border: 1px solid #e4ebe8; border-radius: 13px; }
.generation-history-list article.current { border-color: #83c8ae; background: #f1f8f5; }
.generation-history-list article > div { min-width: 0; }
.generation-history-list strong { display: flex; align-items: center; gap: 7px; color: #2b4039; font-size: 13px; }
.generation-history-list p { overflow: hidden; margin: 5px 0; color: #64736e; font-size: 12px; text-overflow: ellipsis; white-space: nowrap; }
.generation-history-list small { color: #89958f; font-size: 11px; }
.generation-history-detail { min-height: 180px; }
.generation-history-detail dl { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 10px; margin: 0 0 18px; }
.generation-history-detail dl div { padding: 10px; border-radius: 10px; background: #f5f8f7; }
.generation-history-detail dt { color: #89958f; font-size: 11px; }
.generation-history-detail dd { margin: 5px 0 0; color: #30483f; font-size: 13px; }
.generation-history-detail h4 { margin: 18px 0 8px; }
.history-variables { display: flex; flex-wrap: wrap; gap: 7px; }
.history-variables span { display: flex; gap: 6px; padding: 7px 9px; border-radius: 9px; background: #f2f7f5; color: #466158; font-size: 12px; }
.history-variables code { color: #16805e; }
@media (max-width: 1100px) { .content-type-tabs { grid-template-columns: repeat(2, minmax(0, 1fr)); } .generation-editor-layout { grid-template-columns: 1fr; } .preview-panel { position: static; } }
@media (max-width: 720px) { .content-type-tabs, .workflow-tabs { grid-template-columns: 1fr; } .workflow-tabs button { border-right: 0; border-bottom: 1px solid #e7eeeb; } .workflow-tabs button:last-child { border-bottom: 0; } .config-head-actions { width: 100%; } .config-head-actions .el-button { flex: 1; } .generation-history-detail dl { grid-template-columns: 1fr 1fr; } }
</style>

View File

@@ -0,0 +1,115 @@
<script setup lang="ts">
import type { ContentGenerationSourceOption, ContentGenerationVariable } from "../types/api";
const props = defineProps<{
modelValue: ContentGenerationVariable[];
sourceOptions: ContentGenerationSourceOption[];
}>();
const emit = defineEmits<{ "update:modelValue": [value: ContentGenerationVariable[]] }>();
function update(index: number, patch: Partial<ContentGenerationVariable>) {
emit("update:modelValue", props.modelValue.map((item, itemIndex) => itemIndex === index ? { ...item, ...patch } : item));
}
function addVariable() {
const names = new Set(props.modelValue.map((item) => item.name));
let sequence = 1;
while (names.has(`custom_field_${sequence}`)) sequence += 1;
emit("update:modelValue", [
...props.modelValue,
{
name: `custom_field_${sequence}`,
label: `自定义内容 ${sequence}`,
description: "请说明 AI 应该从对话材料中提炼什么内容,以及期望的表达方式",
valueSource: "ai",
sourceKey: null,
sampleValue: "这里显示预览示例",
},
]);
}
function removeVariable(index: number) {
if (props.modelValue.length <= 1) return;
emit("update:modelValue", props.modelValue.filter((_, itemIndex) => itemIndex !== index));
}
function token(name: string) {
return `{{${name || "field_name"}}}`;
}
</script>
<template>
<div class="variable-editor">
<div class="variable-editor-head">
<div>
<strong>变量清单</strong>
<span>每类内容最多配置 30 个变量AI 会严格按照含义说明提炼对应内容</span>
</div>
<el-button type="primary" plain @click="addVariable">+ 新增变量</el-button>
</div>
<article v-for="(item, index) in modelValue" :key="`${item.name}-${index}`" class="variable-card">
<header>
<div class="variable-index"><span>{{ index + 1 }}</span><strong>{{ item.label || "未命名变量" }}</strong></div>
<el-button text type="danger" :disabled="modelValue.length <= 1" @click="removeVariable(index)">删除</el-button>
</header>
<div class="variable-fields">
<label>
<span>变量名称 <em>展示给管理员</em></span>
<el-input :model-value="item.label" maxlength="50" @update:model-value="update(index, { label: String($event) })" />
</label>
<label>
<span>变量标识 <em>用于排版例如 <code>{{ token(item.name) }}</code></em></span>
<el-input :model-value="item.name" maxlength="40" @update:model-value="update(index, { name: String($event).toLowerCase().replace(/[^a-z0-9_]/g, '') })" />
</label>
<label>
<span>取值方式</span>
<el-select :model-value="item.valueSource" @update:model-value="update(index, { valueSource: $event as 'ai' | 'context', sourceKey: $event === 'ai' ? null : (item.sourceKey || sourceOptions[0]?.key || null) })">
<el-option label="AI 根据变量含义提炼" value="ai" />
<el-option label="直接使用系统上下文字段" value="context" />
</el-select>
</label>
<label v-if="item.valueSource === 'context'">
<span>系统字段</span>
<el-select :model-value="item.sourceKey" @update:model-value="update(index, { sourceKey: String($event) })">
<el-option v-for="option in sourceOptions" :key="option.key" :label="option.label" :value="option.key" />
</el-select>
</label>
<label class="full">
<span>变量含义 <em>越具体AI 提炼越稳定</em></span>
<el-input
:model-value="item.description"
type="textarea"
:rows="2"
maxlength="500"
show-word-limit
@update:model-value="update(index, { description: String($event) })"
/>
</label>
<label class="full">
<span>预览示例 <em>只用于排版预览不会进入正式内容</em></span>
<el-input :model-value="item.sampleValue" maxlength="1000" @update:model-value="update(index, { sampleValue: String($event) })" />
</label>
</div>
</article>
</div>
</template>
<style scoped>
.variable-editor { display: grid; gap: 12px; }
.variable-editor-head { display: flex; align-items: center; justify-content: space-between; gap: 16px; padding: 2px 2px 8px; }
.variable-editor-head > div { display: grid; gap: 4px; }
.variable-editor-head strong { color: #243d35; font-size: 15px; }
.variable-editor-head span { color: #768780; font-size: 12px; }
.variable-card { padding: 16px; border: 1px solid #dfe9e5; border-radius: 14px; background: #fbfdfc; }
.variable-card header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 13px; }
.variable-index { display: flex; align-items: center; gap: 9px; color: #29453b; }
.variable-index > span { display: grid; width: 24px; height: 24px; place-items: center; border-radius: 8px; background: #e5f4ee; color: #188060; font-size: 12px; }
.variable-fields { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 13px 14px; }
.variable-fields label { display: grid; min-width: 0; gap: 7px; }
.variable-fields label.full { grid-column: 1 / -1; }
.variable-fields label > span { color: #42574f; font-size: 12px; font-weight: 600; }
.variable-fields em { margin-left: 5px; color: #8a9993; font-size: 11px; font-style: normal; font-weight: 400; }
.variable-fields .el-select { width: 100%; }
@media (max-width: 760px) { .variable-fields { grid-template-columns: 1fr; } .variable-fields label.full { grid-column: auto; } .variable-editor-head { align-items: flex-start; } }
</style>

View File

@@ -0,0 +1,171 @@
<script setup lang="ts">
import { ElMessage } from "element-plus";
import { LineChart } from "echarts/charts";
import { DataZoomComponent, GridComponent, LegendComponent, MarkPointComponent, TooltipComponent } from "echarts/components";
import { init, use, type ECharts } from "echarts/core";
import { CanvasRenderer } from "echarts/renderers";
import { nextTick, onBeforeUnmount, onMounted, reactive, ref } from "vue";
import { api } from "../services/api";
import type { DashboardStats, PeakTrafficResult, TrafficGrain } from "../types/api";
import { formatDateTime } from "../utils/dateTime";
use([LineChart, GridComponent, TooltipComponent, LegendComponent, DataZoomComponent, MarkPointComponent, CanvasRenderer]);
const stats = ref<DashboardStats | null>(null);
const traffic = ref<PeakTrafficResult | null>(null);
const trafficChart = ref<HTMLElement | null>(null);
let trafficChartInstance: ECharts | null = null;
const trafficGrain = ref<TrafficGrain>("hour");
const storageStats = ref<Record<string, any> | null>(null);
const storageLoading = ref(false);
const trafficLoading = ref(false);
const loading = ref(false);
const filters = reactive({ start: "", end: "" });
const trafficOptions: Array<{ label: string; value: TrafficGrain }> = [
{ label: "分钟", value: "minute" },
{ label: "小时", value: "hour" },
{ label: "天", value: "day" },
{ label: "周", value: "week" },
];
onMounted(async () => {
await Promise.all([loadDashboard(), loadTraffic()]);
window.addEventListener("resize", resizeTrafficChart);
});
onBeforeUnmount(() => { window.removeEventListener("resize", resizeTrafficChart); trafficChartInstance?.dispose(); });
async function loadDashboard() {
loading.value = true;
try {
stats.value = await api.dashboard(filters.start, filters.end);
storageLoading.value = true;
api.storageStats().then((value) => { storageStats.value = value; }).catch(() => { storageStats.value = null; }).finally(() => { storageLoading.value = false; });
} finally { loading.value = false; }
}
async function loadTraffic(grain: TrafficGrain = trafficGrain.value) {
trafficGrain.value = grain;
trafficLoading.value = true;
try { traffic.value = await api.peakTraffic(grain); await nextTick(); renderTrafficChart(); }
catch (error) { ElMessage.error(error instanceof Error ? error.message : "峰值流量加载失败"); }
finally { trafficLoading.value = false; }
}
function resizeTrafficChart() { trafficChartInstance?.resize(); }
function renderTrafficChart() {
if (!trafficChart.value) return;
trafficChartInstance ??= init(trafficChart.value);
const rows = traffic.value?.rows ?? [];
const zoomStart = trafficGrain.value === "minute" ? 98 : trafficGrain.value === "hour" ? 70 : 0;
trafficChartInstance.setOption({
animationDuration: 350,
color: ["#176b55", "#d66a57"],
grid: { left: 52, right: 28, top: 36, bottom: rows.length > 30 ? 72 : 48 },
tooltip: {
trigger: "axis",
formatter(params: Array<{ dataIndex: number; marker: string; seriesName: string; value: number }>) {
const index = params[0]?.dataIndex ?? 0;
const row = rows[index];
if (!row) return "";
return `<strong>${row.periodLabel}</strong><br/>${params.map((item) => `${item.marker}${item.seriesName}${item.value}`).join("<br/>")}<br/><span style="color:#7b8b85">平均响应:${row.avgResponseMs} ms</span>`;
},
},
legend: { top: 0, right: 0, data: ["请求数", "5xx 错误"] },
xAxis: { type: "category", boundaryGap: false, data: rows.map((row) => row.periodLabel), axisLabel: { color: "#71827c", hideOverlap: true }, axisLine: { lineStyle: { color: "#dce6e2" } } },
yAxis: { type: "value", minInterval: 1, name: "请求数", nameTextStyle: { color: "#71827c" }, splitLine: { lineStyle: { color: "#edf2f0" } } },
dataZoom: rows.length > 30 ? [{ type: "inside", start: zoomStart, end: 100 }, { type: "slider", start: zoomStart, end: 100, height: 18, bottom: 8, borderColor: "#dce6e2", fillerColor: "rgba(23,107,85,.12)" }] : [],
series: [
{ name: "请求数", type: "line", smooth: true, showSymbol: rows.length < 80, symbolSize: 7, lineStyle: { width: 3 }, areaStyle: { color: "rgba(23,107,85,.10)" }, data: rows.map((row) => row.requestCount), markPoint: { symbolSize: 48, data: [{ type: "max", name: "峰值" }] } },
{ name: "5xx 错误", type: "line", smooth: true, showSymbol: false, lineStyle: { width: 2, type: "dashed" }, data: rows.map((row) => row.errorCount) },
],
}, true);
}
async function refreshStorage() {
storageLoading.value = true;
try { storageStats.value = await api.storageStats(true); ElMessage.success("存储统计已刷新"); }
finally { storageLoading.value = false; }
}
function formatBytes(value: unknown) {
if (typeof value !== "number") return "无法统计";
const units = ["B", "KB", "MB", "GB", "TB"];
let size = value; let index = 0;
while (size >= 1024 && index < units.length - 1) { size /= 1024; index += 1; }
return `${size.toFixed(index ? 2 : 0)} ${units[index]}`;
}
function formatMoney(value?: number | null, currency = "CNY") {
if (typeof value !== "number") return "-";
if (currency === "MIXED") return "多币种,见明细";
return `${currency || "CNY"} ${value.toFixed(6)}`;
}
function modelSceneLabel(scene: string) {
return ({ background_report: "周期报告", background_summary: "摘要沉淀", fixed_info: "固定信息问答", simple_knowledge: "简单知识问答", knowledge_grounded: "知识问答", general_chat: "通用对话" } as Record<string, string>)[scene] || scene || "未分类";
}
</script>
<template>
<section v-loading="loading" class="feature-page dashboard-page">
<header class="dashboard-hero">
<div>
<span class="dashboard-eyebrow">OPERATIONS OVERVIEW</span>
<h2>数据看板</h2>
<p>从业务规模请求峰值到模型成本快速判断系统运行状态</p>
</div>
<div class="dashboard-date-range" aria-label="看板日期范围">
<label class="dashboard-date-field"><span>开始日期</span><input v-model="filters.start" type="date" aria-label="开始日期" @change="loadDashboard" /></label>
<span class="record-date-sep"></span>
<label class="dashboard-date-field"><span>结束日期</span><input v-model="filters.end" type="date" aria-label="结束日期" @change="loadDashboard" /></label>
</div>
</header>
<section class="overview-grid" aria-label="核心业务指标">
<article class="overview-card primary"><span>用户总数</span><strong>{{ stats?.userCount ?? 0 }}</strong><small>当前已沉淀用户</small></article>
<article class="overview-card"><span>会话数</span><strong>{{ stats?.sessionCount ?? 0 }}</strong><small>选定日期范围</small></article>
<article class="overview-card"><span>消息数</span><strong>{{ stats?.messageCount ?? 0 }}</strong><small>用户与 Agent 对话</small></article>
<article class="overview-card"><span>AI 请求</span><strong>{{ stats?.aiRequestCount ?? 0 }}</strong><small>模型实际调用次数</small></article>
<article class="overview-card"><span>估算成本</span><strong class="money">{{ formatMoney(stats?.estimatedCost, stats?.costCurrency || "CNY") }}</strong><small>按已配置模型单价</small></article>
</section>
<section class="dashboard-panel traffic-panel" v-loading="trafficLoading">
<div class="panel-heading">
<div><span class="section-kicker">流量监测</span><h3>峰值流量</h3><p>折线图展示最近 7 天变化趋势可切换时间粒度查看</p></div>
<div class="grain-switch" role="group" aria-label="流量统计粒度">
<button v-for="option in trafficOptions" :key="option.value" type="button" :class="{ active: trafficGrain === option.value }" @click="loadTraffic(option.value)">{{ option.label }}</button>
</div>
</div>
<div class="traffic-summary">
<div><span>峰值时段</span><strong>{{ traffic?.peakPeriod || "暂无数据" }}</strong></div>
<div><span>峰值请求数</span><strong>{{ traffic?.peakRequests ?? 0 }}</strong></div>
<div><span> 7 天总请求</span><strong>{{ traffic?.totalRequests ?? 0 }}</strong></div>
</div>
<div v-if="traffic?.rows.length" ref="trafficChart" class="traffic-chart" role="img" aria-label="最近七天峰值流量折线图"></div>
<el-empty v-else description="暂无流量数据,新请求产生后会自动统计" :image-size="72" />
</section>
<section class="dashboard-panel">
<div class="panel-heading"><div><span class="section-kicker">模型资源</span><h3>模型使用与成本</h3><p>核对模型分流Token 消耗和费用</p></div><div class="token-summary"><span> Token</span><strong>{{ stats?.totalToken ?? 0 }}</strong><small>输入 {{ stats?.inputToken ?? 0 }} · 输出 {{ stats?.outputToken ?? 0 }}</small></div></div>
<el-table :data="stats?.costBreakdown || []" empty-text="当前日期范围内暂无模型调用">
<el-table-column label="调用场景" min-width="130"><template #default="{ row }">{{ modelSceneLabel(row.scene) }}</template></el-table-column>
<el-table-column prop="modelName" label="实际模型" min-width="180" show-overflow-tooltip />
<el-table-column prop="requestCount" label="请求数" width="100" /><el-table-column prop="inputToken" label="输入 Token" width="130" /><el-table-column prop="outputToken" label="输出 Token" width="130" />
<el-table-column label="估算成本" width="170"><template #default="{ row }">{{ formatMoney(row.estimatedCost, row.currency || "CNY") }}</template></el-table-column>
</el-table>
</section>
<section class="dashboard-panel" v-loading="storageLoading">
<div class="panel-heading"><div><span class="section-kicker">基础设施</span><h3>项目存储占用</h3><p>数据库Redis 与附件分别统计无法取得时明确标记</p></div><el-button :loading="storageLoading" @click="refreshStorage">手动刷新</el-button></div>
<div class="storage-grid"><div><span>项目总量</span><strong>{{ formatBytes(storageStats?.totalBytes) }}</strong></div><div><span>数据库</span><strong>{{ formatBytes(storageStats?.detail?.database?.bytes) }}</strong></div><div><span>Redis</span><strong>{{ formatBytes(storageStats?.detail?.redis?.bytes) }}</strong></div><div><span>附件/文件</span><strong>{{ formatBytes(storageStats?.detail?.files?.bytes) }}</strong></div><div><span>近7天增长</span><strong>{{ formatBytes(storageStats?.growth7DaysBytes) }}</strong></div><div><span>近30天增长</span><strong>{{ formatBytes(storageStats?.growth30DaysBytes) }}</strong></div></div>
<small class="storage-updated">最后统计{{ formatDateTime(storageStats?.createdAt, "尚未统计") }}</small>
</section>
</section>
</template>
<style scoped>
.dashboard-page{--dash-border:#dce6e2;--dash-muted:#687b74;--dash-green:#176b55;display:grid;gap:20px}.dashboard-hero{display:flex;align-items:flex-end;justify-content:space-between;gap:24px;padding:24px 26px;border:1px solid var(--dash-border);border-radius:14px;background:linear-gradient(135deg,#f5fbf8 0%,#fff 64%)}.dashboard-eyebrow,.section-kicker{display:block;margin-bottom:6px;color:#2a8069;font-size:11px;font-weight:700;letter-spacing:.12em}.dashboard-hero h2,.panel-heading h3{margin:0}.dashboard-hero h2{font-size:28px}.dashboard-hero p,.panel-heading p{margin:7px 0 0;color:var(--dash-muted);line-height:1.5}.dashboard-date-range{padding:8px;border:1px solid var(--dash-border);border-radius:10px;background:#fff}.dashboard-date-field{display:flex;align-items:center;gap:8px}.dashboard-date-field span{color:var(--dash-muted);font-size:12px;white-space:nowrap}.dashboard-date-field input{height:34px;border:0;outline:0;color:#283631;background:transparent}.overview-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:12px}.overview-card{min-height:126px;padding:18px;border:1px solid var(--dash-border);border-radius:12px;background:#fff}.overview-card.primary{border-color:#b9d9ce;background:#f0f8f5}.overview-card span,.traffic-summary span,.token-summary span{color:var(--dash-muted);font-size:13px}.overview-card strong{display:block;margin:12px 0 7px;color:#1d2925;font-size:28px;line-height:1}.overview-card strong.money{font-size:20px;line-height:1.3}.overview-card small,.token-summary small{color:#8a9a94}.dashboard-panel{padding:22px;border:1px solid var(--dash-border);border-radius:14px;background:#fff}.panel-heading{display:flex;align-items:flex-start;justify-content:space-between;gap:20px;margin-bottom:18px}.panel-heading h3{font-size:20px}.grain-switch{display:flex;padding:4px;border:1px solid var(--dash-border);border-radius:10px;background:#f5f8f7}.grain-switch button{min-width:56px;height:34px;padding:0 13px;border:0;border-radius:7px;color:#586b64;background:transparent;cursor:pointer}.grain-switch button:hover{color:var(--dash-green)}.grain-switch button.active{color:#fff;background:var(--dash-green);box-shadow:0 2px 6px rgba(23,107,85,.18)}.traffic-summary{display:grid;grid-template-columns:2fr 1fr 1fr;margin-bottom:16px;border:1px solid #e6ece9;border-radius:10px;background:#f8faf9}.traffic-summary>div{padding:15px 18px;border-right:1px solid #e6ece9}.traffic-summary>div:last-child{border-right:0}.traffic-summary strong{display:block;margin-top:6px;font-size:19px}.traffic-chart{width:100%;height:360px}.token-summary{min-width:220px;padding:12px 16px;border-radius:10px;background:#f5f8f7}.token-summary strong{display:block;margin:3px 0;font-size:22px}.storage-grid{margin:0}.storage-updated{display:block;margin-top:14px;color:#7d8e88}@media(max-width:900px){.dashboard-hero,.panel-heading{align-items:stretch;flex-direction:column}.dashboard-date-range{align-self:stretch;overflow:auto}.overview-grid{grid-template-columns:repeat(2,minmax(0,1fr))}.traffic-summary{grid-template-columns:1fr}.traffic-summary>div{border-right:0;border-bottom:1px solid #e6ece9}.traffic-summary>div:last-child{border-bottom:0}.grain-switch{overflow:auto}.grain-switch button{flex:1}.traffic-chart{height:320px}}@media(max-width:560px){.overview-grid{grid-template-columns:1fr}.dashboard-panel,.dashboard-hero{padding:16px}}
</style>

View File

@@ -0,0 +1,557 @@
<script setup lang="ts">
import { ElMessage } from "element-plus";
import { computed, nextTick, reactive, ref } from "vue";
import { api } from "../services/api";
import type { EntitlementPlan } from "../types/api";
const props = defineProps<{
plans: EntitlementPlan[];
}>();
const emit = defineEmits<{
refreshed: [];
}>();
const editorRef = ref<HTMLElement | null>(null);
const editingPlanId = ref<number | null>(null);
const saving = ref(false);
const form = reactive({
name: "",
planType: "basic" as "basic" | "deep" | "addon",
description: "",
validityDays: null as number | null,
enableGrowthProfile: 0,
enablePeriodicReports: 0,
allowHelpCard: 1,
allowShareDraft: 1,
status: 1,
sortOrder: 10,
});
const enabledCount = computed(() => props.plans.filter((plan) => plan.status === 1).length);
const capabilityOptions = [
{
key: "enableGrowthProfile" as const,
title: "近期实修回顾",
description: "基于近期主题保持连续性,不做长期画像和成长评价",
},
{
key: "enablePeriodicReports" as const,
title: "周期报告",
description: "允许生成周报、月报和阶段报告",
},
{
key: "allowHelpCard" as const,
title: "求助卡片",
description: "允许学员整理内容并生成求助卡片",
},
{
key: "allowShareDraft" as const,
title: "分享草稿",
description: "允许将对话内容整理为可分享草稿",
},
];
function planTypeLabel(type: string) {
return {
basic: "基础版",
deep: "深度陪伴版",
addon: "高频加购包",
}[type] || type;
}
function resetForm() {
editingPlanId.value = null;
Object.assign(form, {
name: "",
planType: "basic",
description: "",
validityDays: null,
enableGrowthProfile: 0,
enablePeriodicReports: 0,
allowHelpCard: 1,
allowShareDraft: 1,
status: 1,
sortOrder: 10,
});
}
async function savePlan() {
if (!form.name.trim()) {
ElMessage.warning("请填写权益版本名称");
return;
}
saving.value = true;
try {
const payload = { ...form, name: form.name.trim(), description: form.description.trim() };
if (editingPlanId.value) {
await api.updateEntitlementPlan(editingPlanId.value, payload);
ElMessage.success("权益版本已更新");
} else {
await api.createEntitlementPlan(payload);
ElMessage.success("权益版本已新增");
}
resetForm();
emit("refreshed");
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : "权益版本保存失败");
} finally {
saving.value = false;
}
}
async function editPlan(plan: EntitlementPlan) {
editingPlanId.value = plan.id;
Object.assign(form, {
name: plan.name,
planType: plan.planType,
description: plan.description ?? "",
validityDays: plan.validityDays ?? null,
enableGrowthProfile: plan.enableGrowthProfile ? 1 : 0,
enablePeriodicReports: plan.enablePeriodicReports ? 1 : 0,
allowHelpCard: plan.allowHelpCard ? 1 : 0,
allowShareDraft: plan.allowShareDraft ? 1 : 0,
status: plan.status,
sortOrder: plan.sortOrder,
});
await nextTick();
editorRef.value?.scrollIntoView({ behavior: "smooth", block: "start" });
}
</script>
<template>
<div class="page-head entitlement-page-head">
<div>
<h2>权益管理</h2>
<p>配置不同服务版本的有效期与可用能力保存后可在用户管理中直接分配</p>
</div>
<div class="entitlement-summary" aria-label="权益版本统计">
<span><strong>{{ plans.length }}</strong> 个版本</span>
<span><strong>{{ enabledCount }}</strong> 个启用</span>
</div>
</div>
<section ref="editorRef" class="entitlement-editor">
<div class="entitlement-editor-head">
<div>
<div class="entitlement-editor-title-row">
<h3>{{ editingPlanId ? "编辑权益版本" : "新增权益版本" }}</h3>
<span class="editor-mode">{{ editingPlanId ? "编辑中" : "新建" }}</span>
</div>
<p>先设置版本信息再决定学员可使用的产品能力</p>
</div>
<el-button v-if="editingPlanId" @click="resetForm">退出编辑</el-button>
<el-button v-else @click="resetForm">重置表单</el-button>
</div>
<el-form label-position="top" :model="form" @submit.prevent="savePlan">
<div class="entitlement-section-label">
<strong>基础配置</strong>
<span>名称和说明仅用于后台识别不展示给学员</span>
</div>
<div class="entitlement-basic-grid">
<el-form-item class="field-name" label="版本名称" required>
<el-input v-model="form.name" maxlength="50" placeholder="例如:五个月深度陪伴版" />
</el-form-item>
<el-form-item label="版本类型">
<el-select v-model="form.planType">
<el-option label="基础版" value="basic" />
<el-option label="深度陪伴版" value="deep" />
<el-option label="高频加购包" value="addon" />
</el-select>
</el-form-item>
<el-form-item label="版本状态">
<div class="status-switch-field">
<div>
<strong>{{ form.status === 1 ? "启用" : "停用" }}</strong>
<small>{{ form.status === 1 ? "可分配给学员" : "保留配置,不再分配" }}</small>
</div>
<el-switch v-model="form.status" :active-value="1" :inactive-value="0" />
</div>
</el-form-item>
<el-form-item label="有效天数">
<el-input-number v-model="form.validityDays" :min="1" :max="3650" placeholder="留空即长期" />
<div class="field-help">不填写表示长期有效</div>
</el-form-item>
<el-form-item label="列表排序">
<el-input-number v-model="form.sortOrder" :min="0" :max="9999" />
<div class="field-help">数字越小在列表中越靠前</div>
</el-form-item>
<el-form-item class="field-description" label="后台说明">
<el-input v-model="form.description" type="textarea" :rows="3" maxlength="500" show-word-limit placeholder="补充适用人群、售卖规则或内部说明" />
</el-form-item>
</div>
<div class="entitlement-section-label capability-heading">
<strong>能力权限</strong>
<span>关闭后前后端都会按权益规则限制对应能力</span>
</div>
<div class="entitlement-switch-grid">
<label v-for="item in capabilityOptions" :key="item.key" class="capability-switch-card">
<span>
<strong>{{ item.title }}</strong>
<small>{{ item.description }}</small>
</span>
<el-switch v-model="form[item.key]" :active-value="1" :inactive-value="0" />
</label>
</div>
<div class="entitlement-form-actions">
<el-button type="primary" native-type="submit" :loading="saving">
{{ editingPlanId ? "保存修改" : "新增权益版本" }}
</el-button>
<el-button v-if="editingPlanId" @click="resetForm">取消编辑</el-button>
</div>
</el-form>
</section>
<section class="entitlement-list-panel">
<div class="entitlement-list-head">
<div>
<h3>已有权益版本</h3>
<p>停用版本不会再出现在可分配列表中已保存的数据仍会保留</p>
</div>
</div>
<el-table :data="plans" stripe>
<el-table-column label="版本" min-width="210">
<template #default="{ row }">
<div class="plan-name-cell">
<strong>{{ row.name }}</strong>
<small>{{ row.description || "暂无后台说明" }}</small>
</div>
</template>
</el-table-column>
<el-table-column label="类型" width="130">
<template #default="{ row }">{{ planTypeLabel(row.planType) }}</template>
</el-table-column>
<el-table-column label="有效期" width="110" align="center">
<template #default="{ row }">{{ row.validityDays ? `${row.validityDays}` : "长期" }}</template>
</el-table-column>
<el-table-column label="能力" min-width="360">
<template #default="{ row }">
<div class="entitlement-capabilities">
<el-tag v-if="row.enableGrowthProfile" type="success" effect="plain">近期实修回顾</el-tag>
<el-tag v-if="row.enablePeriodicReports" type="success" effect="plain">周期报告</el-tag>
<el-tag v-if="row.allowHelpCard" effect="plain">求助卡片</el-tag>
<el-tag v-if="row.allowShareDraft" effect="plain">分享草稿</el-tag>
</div>
</template>
</el-table-column>
<el-table-column label="状态" width="90" align="center">
<template #default="{ row }"><el-tag :type="row.status === 1 ? 'success' : 'info'">{{ row.status === 1 ? "启用" : "停用" }}</el-tag></template>
</el-table-column>
<el-table-column label="操作" width="112" fixed="right" align="center">
<template #default="{ row }"><el-button size="small" @click="editPlan(row)">编辑</el-button></template>
</el-table-column>
</el-table>
</section>
</template>
<style scoped>
.entitlement-page-head {
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 24px;
}
.entitlement-summary {
display: flex;
flex: 0 0 auto;
gap: 8px;
}
.entitlement-summary span {
padding: 8px 12px;
border: 1px solid #dfe8e5;
border-radius: 999px;
background: #fff;
color: #71817b;
font-size: 13px;
}
.entitlement-summary strong {
color: #1f8d68;
}
.entitlement-editor,
.entitlement-list-panel {
border: 1px solid #dfe8e5;
border-radius: 14px;
background: #fff;
box-shadow: 0 8px 26px rgb(31 65 54 / 4%);
}
.entitlement-editor {
scroll-margin-top: 20px;
padding: 22px;
}
.entitlement-editor-head,
.entitlement-editor-title-row,
.entitlement-list-head {
display: flex;
align-items: center;
}
.entitlement-editor-head {
justify-content: space-between;
gap: 18px;
padding-bottom: 18px;
border-bottom: 1px solid #edf2f0;
}
.entitlement-editor-head h3,
.entitlement-editor-head p,
.entitlement-list-head h3,
.entitlement-list-head p {
margin: 0;
}
.entitlement-editor-head h3,
.entitlement-list-head h3 {
color: #263a34;
font-size: 18px;
}
.entitlement-editor-head p,
.entitlement-list-head p {
margin-top: 5px;
color: #71817b;
font-size: 13px;
line-height: 1.55;
}
.entitlement-editor-title-row {
gap: 10px;
}
.editor-mode {
padding: 3px 8px;
border-radius: 999px;
background: #edf7f3;
color: #218361;
font-size: 12px;
}
.entitlement-section-label {
display: flex;
align-items: baseline;
gap: 10px;
margin: 20px 0 12px;
}
.entitlement-section-label strong {
color: #263a34;
font-size: 15px;
}
.entitlement-section-label span {
color: #8a9893;
font-size: 12px;
}
.entitlement-basic-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 0 16px;
}
.field-name {
grid-column: span 2;
}
.field-description {
grid-column: 1 / -1;
}
.entitlement-basic-grid :deep(.el-select),
.entitlement-basic-grid :deep(.el-input-number) {
width: 100%;
}
.entitlement-basic-grid :deep(.el-form-item) {
margin-bottom: 17px;
}
.status-switch-field {
width: 100%;
min-height: 40px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
box-sizing: border-box;
padding: 5px 12px;
border: 1px solid #dcdfe6;
border-radius: 4px;
}
.status-switch-field div,
.plan-name-cell,
.capability-switch-card > span {
min-width: 0;
}
.status-switch-field strong,
.status-switch-field small,
.plan-name-cell strong,
.plan-name-cell small,
.capability-switch-card strong,
.capability-switch-card small {
display: block;
}
.status-switch-field strong {
color: #40524b;
font-size: 13px;
line-height: 1.2;
}
.status-switch-field small,
.field-help {
color: #98a49f;
font-size: 11px;
}
.field-help {
margin-top: 5px;
line-height: 1.4;
}
.capability-heading {
margin-top: 5px;
}
.entitlement-switch-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 10px;
}
.capability-switch-card {
min-height: 64px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 14px;
box-sizing: border-box;
padding: 11px 13px;
border: 1px solid #dfe8e5;
border-radius: 10px;
background: #f8fbfa;
cursor: pointer;
transition: border-color 0.2s ease, background-color 0.2s ease;
}
.capability-switch-card:hover {
border-color: #b8d9cd;
background: #f4faf7;
}
.capability-switch-card strong {
color: #40524b;
font-size: 13px;
line-height: 1.3;
}
.capability-switch-card small {
margin-top: 4px;
color: #82918c;
font-size: 11px;
line-height: 1.35;
}
.capability-switch-card :deep(.el-switch) {
flex: 0 0 auto;
}
.entitlement-form-actions {
display: flex;
gap: 10px;
margin-top: 20px;
}
.entitlement-form-actions :deep(.el-button) {
min-width: 112px;
}
.entitlement-list-panel {
margin-top: 16px;
overflow: hidden;
}
.entitlement-list-head {
min-height: 74px;
box-sizing: border-box;
padding: 14px 20px;
border-bottom: 1px solid #edf2f0;
}
.plan-name-cell strong {
overflow: hidden;
color: #344942;
text-overflow: ellipsis;
white-space: nowrap;
}
.plan-name-cell small {
overflow: hidden;
margin-top: 4px;
color: #86948f;
font-size: 12px;
text-overflow: ellipsis;
white-space: nowrap;
}
.entitlement-capabilities {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
@media (max-width: 1180px) {
.entitlement-basic-grid,
.entitlement-switch-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.field-name {
grid-column: 1 / -1;
}
}
@media (max-width: 760px) {
.entitlement-page-head {
align-items: flex-start;
flex-direction: column;
}
.entitlement-editor {
padding: 16px;
}
.entitlement-editor-head {
align-items: flex-start;
}
.entitlement-basic-grid,
.entitlement-switch-grid {
grid-template-columns: 1fr;
}
.field-name,
.field-description {
grid-column: auto;
}
.entitlement-section-label {
align-items: flex-start;
flex-direction: column;
gap: 3px;
}
}
</style>

View File

@@ -0,0 +1,200 @@
<script setup lang="ts">
import { ElMessage, ElMessageBox } from "element-plus";
import { nextTick, onMounted, reactive, ref } from "vue";
import { api } from "../services/api";
import type { FeedbackDetail, FeedbackItem } from "../types/api";
import AdminPagination from "./AdminPagination.vue";
import { formatDateTime } from "../utils/dateTime";
defineProps<{ canDelete: boolean; canDetail: boolean; canExport: boolean }>();
const loading = ref(false);
const exporting = ref(false);
const items = ref<FeedbackItem[]>([]);
const total = ref(0);
const page = ref(1);
const pageSize = ref(20);
const readStatus = ref("all");
const dateFrom = ref("");
const dateTo = ref("");
const detail = ref<FeedbackDetail | null>(null);
const detailLoading = ref(false);
const detailBodyRef = ref<HTMLElement | null>(null);
const detailPager = reactive({ page: 1, pageSize: 20, total: 0 });
async function load() {
loading.value = true;
try {
const result = await api.feedbackList({
readStatus: readStatus.value,
startDate: dateFrom.value || undefined,
endDate: dateTo.value || undefined,
page: page.value,
pageSize: pageSize.value,
});
items.value = result.items;
total.value = result.total;
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : "反馈列表加载失败");
} finally {
loading.value = false;
}
}
function applyFilters() {
page.value = 1;
void load();
}
function resetFilters() {
readStatus.value = "all";
dateFrom.value = "";
dateTo.value = "";
applyFilters();
}
function changePage(nextPage: number, nextPageSize: number) {
page.value = nextPage;
pageSize.value = nextPageSize;
void load();
}
async function exportExcel() {
if (!dateFrom.value || !dateTo.value) {
ElMessage.warning("请先选择导出的时间范围");
return;
}
exporting.value = true;
try {
await api.exportFeedback({
readStatus: readStatus.value,
startDate: dateFrom.value,
endDate: dateTo.value,
});
ElMessage.success("反馈 Excel 已导出");
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : "反馈导出失败");
} finally {
exporting.value = false;
}
}
async function open(item: FeedbackItem) {
detail.value = null;
Object.assign(detailPager, { page: 1, total: 0 });
await loadDetail(item.id);
if (detail.value) item.isRead = true;
}
async function loadDetail(feedbackId: number, messagePage?: number, messagePageSize = detailPager.pageSize) {
detailLoading.value = true;
try {
detail.value = await api.feedbackDetail(feedbackId, {
messagePage,
messagePageSize,
});
Object.assign(detailPager, {
page: detail.value.messagesPage.page,
pageSize: detail.value.messagesPage.pageSize,
total: detail.value.messagesPage.total,
});
await scrollToTarget();
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : "反馈详情加载失败");
} finally {
detailLoading.value = false;
}
}
async function changeDetailPage(nextPage: number, nextPageSize: number) {
if (!detail.value) return;
await loadDetail(detail.value.id, nextPage, nextPageSize);
if (!detail.value.messages.some((message) => message.isTarget)) {
await nextTick();
detailBodyRef.value?.querySelector("article")?.scrollIntoView({ behavior: "smooth", block: "start" });
}
}
async function scrollToTarget() {
await nextTick();
const target = detailBodyRef.value?.querySelector<HTMLElement>("[data-feedback-target='true']");
target?.scrollIntoView({ behavior: "smooth", block: "center" });
}
function closeDetail() {
detail.value = null;
}
async function remove(item: FeedbackItem) {
await ElMessageBox.confirm("确认删除这条用户反馈?删除后不可恢复。", "删除反馈", { type: "warning" });
await api.deleteFeedback(item.id);
if (detail.value?.id === item.id) detail.value = null;
ElMessage.success("反馈已删除");
await load();
}
onMounted(load);
</script>
<template>
<section class="feedback-page">
<header>
<div><h2>反馈管理</h2><p>查看用户针对 AI 回答提交的问题与当时对话</p></div>
</header>
<section class="record-filter feedback-toolbar" aria-label="反馈筛选与导出">
<el-select v-model="readStatus" aria-label="反馈状态" @change="applyFilters">
<el-option label="全部反馈" value="all" />
<el-option label="未读反馈" value="unread" />
<el-option label="已读反馈" value="read" />
</el-select>
<div class="record-date-range" aria-label="反馈时间范围">
<label class="record-date-field"><span>开始日期</span><input v-model="dateFrom" type="date" aria-label="反馈开始日期" /></label>
<span class="record-date-sep"></span>
<label class="record-date-field"><span>结束日期</span><input v-model="dateTo" type="date" aria-label="反馈结束日期" /></label>
</div>
<div class="record-filter-actions">
<el-button type="primary" @click="applyFilters">查询</el-button>
<el-button @click="resetFilters">重置</el-button>
<el-button v-if="canExport" :loading="exporting" @click="exportExcel">导出 Excel</el-button>
</div>
</section>
<el-table v-loading="loading" :data="items" stripe>
<el-table-column label="状态" width="88"><template #default="{ row }"><el-tag :type="row.isRead ? 'info' : 'danger'">{{ row.isRead ? '已读' : '未读' }}</el-tag></template></el-table-column>
<el-table-column prop="userName" label="用户" width="130" />
<el-table-column prop="content" label="反馈内容" min-width="240" show-overflow-tooltip />
<el-table-column prop="messageContent" label="反馈的 AI 回答" min-width="280" show-overflow-tooltip />
<el-table-column label="提交时间" width="180"><template #default="{ row }">{{ formatDateTime(row.createdAt) }}</template></el-table-column>
<el-table-column label="操作" width="150" fixed="right"><template #default="{ row }"><el-button v-if="canDetail" link type="primary" @click="open(row)">查看</el-button><el-button v-if="canDelete" link type="danger" @click="remove(row)">删除</el-button></template></el-table-column>
</el-table>
<AdminPagination v-if="total" :page="page" :page-size="pageSize" :total="total" @change="changePage" />
<el-drawer :model-value="Boolean(detail) || detailLoading" title="反馈详情" size="680px" @close="closeDetail">
<template v-if="detail">
<div ref="detailBodyRef" v-loading="detailLoading" class="feedback-detail-body">
<section class="feedback-summary"><span>用户反馈</span><strong>{{ detail.content }}</strong><small>{{ detail.userName }} · {{ formatDateTime(detail.createdAt) }}</small></section>
<h3>当时对话记录</h3>
<AdminPagination class="feedback-detail-pagination" :page="detailPager.page" :page-size="detailPager.pageSize" :total="detailPager.total" @change="changeDetailPage" />
<div class="feedback-history"><article v-for="message in detail.messages" :key="message.id" :class="[message.role, { target: message.isTarget }]" :data-feedback-target="message.isTarget ? 'true' : undefined">
<div><b>{{ message.role === 'user' ? '用户' : 'AI' }}</b><el-tag v-if="message.isTarget" type="warning">用户反馈的这条回答</el-tag><time>{{ formatDateTime(message.createdAt) }}</time></div><p>{{ message.content }}</p>
</article></div>
<AdminPagination class="feedback-detail-pagination" :page="detailPager.page" :page-size="detailPager.pageSize" :total="detailPager.total" @change="changeDetailPage" />
</div>
</template>
<div v-else v-loading="detailLoading" class="feedback-detail-loading"></div>
</el-drawer>
</section>
</template>
<style scoped>
.feedback-page header { display:flex; justify-content:space-between; align-items:flex-start; gap:24px; margin-bottom:18px; }
.feedback-page h2 { margin:0 0 6px; }.feedback-page header p { margin:0; color:#7b8494; }
.feedback-toolbar { align-items:center; margin-bottom:18px; padding:14px 16px; border:1px solid #e7ebf0; border-radius:12px; background:#fafbfc; }
.feedback-toolbar > :deep(.el-select) { width:140px; }
.feedback-summary { display:grid; gap:9px; padding:16px; border-radius:12px; background:#f7f9fc; }.feedback-summary span,.feedback-summary small { color:#87909f; }
.feedback-detail-body { min-height:260px; }
.feedback-detail-loading { min-height:260px; }
.feedback-detail-pagination { margin:12px 0; }
.feedback-history { display:grid; gap:12px; }.feedback-history article { padding:14px 16px; border:1px solid #e6eaf0; border-radius:12px; }.feedback-history article.user { margin-left:42px; background:#f5f8ff; }.feedback-history article.assistant { margin-right:42px; }.feedback-history article.target { border:2px solid #f0a020; background:#fffaf0; box-shadow:0 0 0 4px rgb(240 160 32 / 10%); animation:feedback-target-pulse 1.2s ease-out; }.feedback-history article div { display:flex; align-items:center; gap:10px; }.feedback-history article time { margin-left:auto; color:#87909f; font-size:12px; }.feedback-history p { white-space:pre-wrap; line-height:1.65; margin:8px 0 0; }
@keyframes feedback-target-pulse { 0% { box-shadow:0 0 0 0 rgb(240 160 32 / 38%); } 65% { box-shadow:0 0 0 12px rgb(240 160 32 / 8%); } 100% { box-shadow:0 0 0 4px rgb(240 160 32 / 10%); } }
@media (max-width: 900px) { .feedback-toolbar { align-items:stretch; }.feedback-toolbar .record-date-range { width:100%; }.feedback-toolbar .record-date-field { flex:1; width:auto; }.feedback-toolbar .record-filter-actions { width:100%; } }
</style>

View File

@@ -0,0 +1,178 @@
<script setup lang="ts">
import { computed, ref, watch } from "vue";
import { api } from "../services/api";
import type { KnowledgeVersionContent } from "../types/api";
const props = defineProps<{
versionId: number | null;
targetSectionId?: number | null;
highlightKeyword?: string;
}>();
const loading = ref(false);
const error = ref("");
const content = ref<KnowledgeVersionContent | null>(null);
const activeSectionId = ref<number | null>(null);
let requestSequence = 0;
const activeSection = computed(() => (
content.value?.sections.find((section) => section.id === activeSectionId.value)
?? content.value?.sections[0]
?? null
));
const characterCount = computed(() => (
content.value?.sections.reduce((total, section) => total + section.content.length, 0) ?? 0
));
watch(() => props.versionId, loadVersion, { immediate: true });
watch(() => props.targetSectionId, selectTargetSection);
async function loadVersion(versionId: number | null) {
const sequence = ++requestSequence;
content.value = null;
activeSectionId.value = null;
error.value = "";
if (!versionId) return;
loading.value = true;
try {
const result = await api.knowledgeVersionContent(versionId);
if (sequence !== requestSequence) return;
content.value = result;
activeSectionId.value = props.targetSectionId && result.sections.some((section) => section.id === props.targetSectionId)
? props.targetSectionId
: result.sections[0]?.id ?? null;
} catch (loadError) {
if (sequence !== requestSequence) return;
error.value = loadError instanceof Error ? loadError.message : "知识库内容加载失败";
} finally {
if (sequence === requestSequence) loading.value = false;
}
}
function selectTargetSection(sectionId?: number | null) {
if (!sectionId || !content.value?.sections.some((section) => section.id === sectionId)) return;
activeSectionId.value = sectionId;
}
function highlightedParts(text: string) {
const keyword = (props.highlightKeyword || "").trim();
if (!keyword) return [{ text, match: false }];
const parts: Array<{ text: string; match: boolean }> = [];
const source = text || "";
const lowerSource = source.toLowerCase();
const lowerKeyword = keyword.toLowerCase();
let cursor = 0;
while (cursor < source.length) {
const index = lowerSource.indexOf(lowerKeyword, cursor);
if (index < 0) {
parts.push({ text: source.slice(cursor), match: false });
break;
}
if (index > cursor) parts.push({ text: source.slice(cursor, index), match: false });
parts.push({ text: source.slice(index, index + keyword.length), match: true });
cursor = index + keyword.length;
}
return parts.length ? parts : [{ text: source, match: false }];
}
</script>
<template>
<section class="content-preview" v-loading="loading">
<header class="content-preview-toolbar">
<div>
<h4>知识库内容预览</h4>
<p>查看当前最新版实际进入检索链路的章节内容</p>
</div>
</header>
<el-alert v-if="error" :title="error" type="error" :closable="false" show-icon>
<template #default><el-button link type="primary" @click="loadVersion(versionId)">重新加载</el-button></template>
</el-alert>
<el-empty v-else-if="!loading && !versionId" description="该知识库暂无可预览版本" />
<el-empty v-else-if="!loading && content && content.sections.length === 0" description="当前版本没有可预览的章节内容" />
<template v-else-if="content && activeSection">
<div class="content-preview-summary">
<span>V{{ content.version.versionNo }}</span>
<span>{{ content.sections.length }} 个章节</span>
<span>{{ content.cards.length }} 张知识卡</span>
<span> {{ characterCount.toLocaleString() }} </span>
</div>
<div class="content-preview-layout">
<aside class="content-outline" aria-label="章节目录">
<strong>章节目录</strong>
<button
v-for="section in content.sections"
:key="section.id"
type="button"
:class="{ active: section.id === activeSection.id }"
:title="section.title"
@click="activeSectionId = section.id"
>
<span>{{ section.sectionKey }}</span>
<em>{{ section.title }}</em>
</button>
</aside>
<article class="content-article">
<div class="content-article-head">
<div><small>{{ activeSection.sectionKey }}</small><h3>{{ activeSection.title }}</h3></div>
<span>源内容位置 {{ activeSection.sourceStart }}{{ activeSection.sourceEnd }}</span>
</div>
<pre><template v-for="(part, index) in highlightedParts(activeSection.content)" :key="index"><mark v-if="part.match">{{ part.text }}</mark><span v-else>{{ part.text }}</span></template></pre>
</article>
</div>
<el-collapse class="content-card-panel">
<el-collapse-item :title="`查看知识卡(${content.cards.length}`" name="cards">
<el-empty v-if="content.cards.length === 0" description="当前版本尚未生成知识卡" :image-size="72" />
<el-table v-else :data="content.cards" max-height="360">
<el-table-column prop="title" label="标题" min-width="180" />
<el-table-column prop="summary" label="摘要" min-width="320" show-overflow-tooltip />
<el-table-column prop="riskLevel" label="风险" width="100" />
</el-table>
</el-collapse-item>
</el-collapse>
</template>
</section>
</template>
<style scoped>
.content-preview { min-height: 360px; }
.content-preview-toolbar { display: flex; align-items: flex-start; justify-content: space-between; gap: 20px; margin-bottom: 16px; }
.content-preview-toolbar h4 { margin: 0; font-size: 17px; color: #203b32; }
.content-preview-toolbar p { margin: 6px 0 0; color: #71817b; font-size: 13px; }
.content-preview-summary { display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 12px; }
.content-preview-summary span { padding: 5px 10px; border-radius: 999px; background: #f0f6f3; color: #526860; font-size: 12px; }
.content-preview-layout { display: grid; grid-template-columns: 250px minmax(0, 1fr); height: clamp(420px, 62vh, 680px); min-height: 0; overflow: hidden; border: 1px solid #dfe8e5; border-radius: 12px; background: #fff; }
.content-outline { min-width: 0; min-height: 0; height: 100%; overflow-y: auto; overscroll-behavior: contain; scrollbar-gutter: stable; padding: 14px 10px; border-right: 1px solid #e5ece9; background: #f7faf9; }
.content-outline > strong { display: block; padding: 0 10px 10px; color: #455c53; font-size: 13px; }
.content-outline button { display: grid; grid-template-columns: auto minmax(0, 1fr); align-items: center; width: 100%; gap: 8px; padding: 10px; border: 0; border-radius: 8px; background: transparent; color: #53645e; text-align: left; cursor: pointer; }
.content-outline button:hover { background: #eaf3ef; }
.content-outline button.active { background: #dcefe7; color: #176746; }
.content-outline button span { font-size: 11px; color: #7c8d87; }
.content-outline button em { overflow: hidden; font-style: normal; font-size: 13px; font-weight: 600; text-overflow: ellipsis; white-space: nowrap; }
.content-article { min-width: 0; min-height: 0; height: 100%; overflow-y: auto; overscroll-behavior: contain; scrollbar-gutter: stable; padding: 24px 28px; }
.content-article-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 20px; padding-bottom: 16px; border-bottom: 1px solid #edf1ef; }
.content-article-head small { color: #26815f; }
.content-article-head h3 { margin: 5px 0 0; color: #203b32; font-size: 20px; }
.content-article-head > span { color: #88958f; font-size: 12px; white-space: nowrap; }
.content-article pre { margin: 20px 0 0; white-space: pre-wrap; overflow-wrap: anywhere; color: #354840; font: inherit; font-size: 14px; line-height: 1.85; }
.content-article mark { padding: 0 2px; border-radius: 3px; background: #fff1a8; color: inherit; }
.content-card-panel { margin-top: 16px; }
@media (max-width: 900px) {
.content-preview-toolbar { align-items: stretch; flex-direction: column; }
.content-preview-layout { grid-template-columns: 1fr; height: auto; min-height: 0; overflow: visible; }
.content-outline { display: flex; height: auto; gap: 8px; overflow-x: auto; overflow-y: hidden; border-right: 0; border-bottom: 1px solid #e5ece9; }
.content-outline > strong { display: none; }
.content-outline button { min-width: 180px; width: 180px; }
.content-article { height: auto; max-height: 58vh; padding: 20px; }
.content-article-head { flex-direction: column; gap: 8px; }
}
</style>

View File

@@ -0,0 +1,794 @@
<script setup lang="ts">
import { ElMessage, ElMessageBox } from "element-plus";
import { onMounted, reactive, ref } from "vue";
import { api } from "../services/api";
import { formatDateTime } from "../utils/dateTime";
import KnowledgeContentPreview from "./KnowledgeContentPreview.vue";
import TableRowActions from "./TableRowActions.vue";
import AdminPagination from "./AdminPagination.vue";
import type {
KnowledgeDetail,
KnowledgeContentSearchItem,
KnowledgeBatchSyncResult,
KnowledgeItem,
KnowledgeSyncJob,
MigrationSummary,
} from "../types/api";
const emit = defineEmits<{ preview: [knowledgeId: number] }>();
const rows = ref<KnowledgeItem[]>([]);
const migration = ref<MigrationSummary | null>(null);
const loading = ref(false);
const operatingId = ref<number | null>(null);
const detailOpen = ref(false);
const detail = ref<KnowledgeDetail | null>(null);
const jobs = ref<KnowledgeSyncJob[]>([]);
const previewVersionId = ref<number | null>(null);
const previewTargetSectionId = ref<number | null>(null);
const previewHighlightKeyword = ref("");
const activeTab = ref("overview");
const selectedRows = ref<KnowledgeItem[]>([]);
const editOpen = ref(false);
const editSaving = ref(false);
const batchOpen = ref(false);
const batchSaving = ref(false);
const batchSyncing = ref(false);
const batchSyncResultOpen = ref(false);
const batchSyncResult = ref<KnowledgeBatchSyncResult | null>(null);
const resolvingNode = ref(false);
const nodeResolved = ref(false);
const sourceTitle = ref("");
const filter = reactive({ keyword: "", type: "", open: "", source: "" });
const pager = reactive({ page: 1, pageSize: 20, total: 0 });
const contentSearch = reactive({ keyword: "", includeClosed: true });
const contentSearchRows = ref<KnowledgeContentSearchItem[]>([]);
const contentSearchLoading = ref(false);
const contentSearchPager = reactive({ page: 1, pageSize: 10, total: 0 });
const form = reactive({
name: "",
feishuSpaceId: "",
feishuNodeId: "",
knowledgeType: "general",
status: 0,
remark: "",
});
const editForm = reactive({ id: 0, name: "", knowledgeType: "general" });
const batchForm = reactive({ knowledgeType: "" });
const knowledgeTypeOptions = [
{ label: "课程知识库", value: "course" },
{ label: "答疑知识库", value: "qa" },
{ label: "固定信息类知识库", value: "fixed" },
{ label: "通用知识库", value: "general" },
];
onMounted(load);
async function load(page = pager.page, pageSize = pager.pageSize) {
loading.value = true;
try {
const result = await api.knowledge({
keyword: filter.keyword, knowledgeType: filter.type, openStatus: filter.open,
sourceStatus: filter.source,
page, pageSize,
});
rows.value = result.items;
Object.assign(pager, { page: result.page, pageSize: result.pageSize, total: result.total });
if (!migration.value) migration.value = await api.migrationSummary();
} catch (error) {
ElMessage.error(errorMessage(error, "知识库列表加载失败"));
} finally {
loading.value = false;
}
}
function openEdit() {
if (!detail.value) return;
Object.assign(editForm, {
id: detail.value.knowledge.id,
name: detail.value.knowledge.name,
knowledgeType: detail.value.knowledge.knowledgeType,
});
editOpen.value = true;
}
async function saveMetadata() {
if (!editForm.name.trim()) {
ElMessage.warning("请输入后台知识库名称");
return;
}
editSaving.value = true;
try {
await api.updateKnowledgeMetadata(editForm.id, {
name: editForm.name.trim(), knowledgeType: editForm.knowledgeType,
});
ElMessage.success("知识库基本信息已更新");
editOpen.value = false;
await Promise.all([load(), refreshDetail(editForm.id)]);
} catch (error) {
ElMessage.error(errorMessage(error, "基本信息保存失败"));
} finally {
editSaving.value = false;
}
}
function openBatchEdit() {
if (!selectedRows.value.length) {
ElMessage.warning("请先勾选需要修改的知识库");
return;
}
Object.assign(batchForm, { knowledgeType: "" });
batchOpen.value = true;
}
async function saveBatchMetadata() {
if (!batchForm.knowledgeType) {
ElMessage.warning("请选择知识库类型");
return;
}
if (!await confirmAction(
`将修改已选择的 ${selectedRows.value.length} 个知识库。该操作不影响正式版本和开放状态。`,
"确认批量修改",
{ confirmButtonText: "确认修改", cancelButtonText: "取消", type: "warning" },
)) return;
batchSaving.value = true;
try {
const result = await api.batchUpdateKnowledgeMetadata({
knowledgeIds: selectedRows.value.map((item) => item.id),
knowledgeType: batchForm.knowledgeType || undefined,
});
ElMessage[result.failed ? "warning" : "success"](`批量修改完成:成功 ${result.success} 个,失败 ${result.failed}`);
batchOpen.value = false;
await load();
} catch (error) {
ElMessage.error(errorMessage(error, "批量修改失败"));
} finally {
batchSaving.value = false;
}
}
async function syncSelected() {
if (!selectedRows.value.length) {
ElMessage.warning("请先勾选需要同步的知识库");
return;
}
if (!await confirmAction(
`将依次同步已选择的 ${selectedRows.value.length} 个知识库。同步成功后最新内容立即生效,单个失败不会中断其他知识库。`,
"确认批量同步",
{ confirmButtonText: "开始同步", cancelButtonText: "取消", type: "warning" },
)) return;
batchSyncing.value = true;
try {
const result = await api.batchSyncKnowledge(selectedRows.value.map((item) => item.id));
batchSyncResult.value = result;
batchSyncResultOpen.value = true;
ElMessage[result.failed ? "warning" : "success"](
`批量同步完成:成功 ${result.success} 个,失败 ${result.failed}`,
);
await load();
} catch (error) {
ElMessage.error(errorMessage(error, "批量同步失败"));
} finally {
batchSyncing.value = false;
}
}
async function handleMore(command: string, item: KnowledgeItem) {
try {
if (command === "detail") return await openDetail(item);
if (command === "preview") return emit("preview", item.id);
if (command === "archive") return await archive(item);
if (command === "restore") return await restore(item);
if (command === "delete") return await remove(item);
} catch (error) {
if (error !== "cancel" && error !== "close") ElMessage.error(errorMessage(error, "操作失败"));
}
}
async function searchContent(page = contentSearchPager.page, pageSize = contentSearchPager.pageSize) {
const keyword = contentSearch.keyword.trim();
if (!keyword) {
contentSearchRows.value = [];
Object.assign(contentSearchPager, { page: 1, pageSize, total: 0 });
return ElMessage.warning("请输入要搜索的知识库内容关键字");
}
contentSearchLoading.value = true;
try {
const result = await api.knowledgeContentSearch({
keyword,
includeClosed: contentSearch.includeClosed,
page,
pageSize,
});
contentSearchRows.value = result.items;
Object.assign(contentSearchPager, { page: result.page, pageSize: result.pageSize, total: result.total });
} catch (error) {
ElMessage.error(errorMessage(error, "知识库内容搜索失败"));
} finally {
contentSearchLoading.value = false;
}
}
function clearContentSearch() {
contentSearch.keyword = "";
contentSearchRows.value = [];
Object.assign(contentSearchPager, { page: 1, pageSize: contentSearchPager.pageSize, total: 0 });
}
async function restore(item: KnowledgeItem) {
if (!await confirmAction("恢复后知识库仍保持关闭,需要单独确认开放。", "恢复知识库", {
confirmButtonText: "确认恢复", cancelButtonText: "取消", type: "warning",
})) return;
await api.setKnowledgeLifecycle(item.id, "active");
ElMessage.success("知识库已恢复,当前仍为关闭状态");
await load();
}
async function createKnowledge() {
if (!nodeResolved.value || !form.name || !form.feishuSpaceId || !form.feishuNodeId) {
ElMessage.warning("请先通过 Node ID 获取并确认知识库信息");
return;
}
try {
await api.createKnowledge({ ...form });
Object.assign(form, { name: "", feishuSpaceId: "", feishuNodeId: "", knowledgeType: "general", status: 0, remark: "" });
nodeResolved.value = false; sourceTitle.value = "";
ElMessage.success("知识库已新增,首次同步成功后将自动生效");
await load();
} catch (error) {
ElMessage.error(errorMessage(error, "知识库新增失败"));
}
}
async function resolveNode() {
const nodeId = form.feishuNodeId.trim();
if (!nodeId) return ElMessage.warning("请输入 Node ID");
resolvingNode.value = true;
try {
const info = await api.resolveKnowledgeNode(nodeId);
Object.assign(form, { feishuNodeId: info.nodeId, feishuSpaceId: info.spaceId, name: info.name, remark: info.remark || "" });
sourceTitle.value = info.sourceTitle; nodeResolved.value = true;
ElMessage.success("已获取飞书知识库信息");
} catch (error) {
nodeResolved.value = false; sourceTitle.value = "";
ElMessage.error(errorMessage(error, "Node ID 信息获取失败"));
} finally { resolvingNode.value = false; }
}
async function openDetail(
item: KnowledgeItem | { id: number },
options: { sectionId?: number | null; keyword?: string; tab?: string } = {},
) {
detailOpen.value = true;
detail.value = null;
activeTab.value = options.tab || "overview";
previewVersionId.value = null;
previewTargetSectionId.value = options.sectionId ?? null;
previewHighlightKeyword.value = options.keyword || "";
try {
const refreshedDetail = await refreshDetail(item.id);
previewVersionId.value = refreshedDetail.currentVersion?.id ?? null;
} catch (error) {
detailOpen.value = false;
ElMessage.error(errorMessage(error, "知识库详情加载失败"));
}
}
async function openSearchHit(item: KnowledgeContentSearchItem) {
await openDetail(
{ id: item.knowledgeId },
{ sectionId: item.sectionId, keyword: contentSearch.keyword.trim(), tab: "content" },
);
}
async function refreshDetail(id: number) {
const [nextDetail, nextJobs] = await Promise.all([
api.knowledgeDetail(id), api.knowledgeSyncJobs(id),
]);
detail.value = nextDetail;
jobs.value = nextJobs;
return nextDetail;
}
async function sync(item: KnowledgeItem) {
operatingId.value = item.id;
try {
const result = await api.syncKnowledge(item.id);
const message = result.status === "unchanged"
? "飞书内容未变化,未生成新版本"
: result.status === "published"
? "同步完成,最新版本已自动生效"
: "同步完成";
ElMessage.success(message);
await load();
if (detailOpen.value) await refreshDetail(item.id);
} catch (error) {
ElMessage.error(errorMessage(error, "知识库同步失败"));
} finally {
operatingId.value = null;
}
}
async function runMigration() {
if (!await confirmAction(
"将依次重新读取全部飞书文章。同步成功后仅保留最新版,旧版本和旧内容会被清理。",
"开始知识迁移",
{ confirmButtonText: "开始迁移", cancelButtonText: "取消", type: "warning" },
)) return;
loading.value = true;
try {
const result = await api.runKnowledgeMigration();
ElMessage.success(`迁移处理完成:${result.success}/${result.total} 成功`);
await load();
} catch (error) {
ElMessage.error(errorMessage(error, "知识迁移失败"));
} finally {
loading.value = false;
}
}
async function toggleOpen(item: KnowledgeItem): Promise<boolean> {
const next = item.status !== 1;
if (!await confirmAction(
next ? "开放后,该知识库将从用户下一条消息开始进入 Agent 动态知识目录。" : "关闭后,当前生成不受影响,从用户下一条消息开始无法检索该知识库。",
next ? "开放知识库" : "关闭知识库",
{ confirmButtonText: next ? "确认开放" : "确认关闭", cancelButtonText: "取消", type: "warning" },
)) return false;
operatingId.value = item.id;
try {
await api.setKnowledgeOpen(item.id, next);
ElMessage.success(next ? "知识库已开放" : "知识库已关闭");
if (detailOpen.value && detail.value?.knowledge.id === item.id) await refreshDetail(item.id);
return true;
} catch (error) {
ElMessage.error(errorMessage(error, next ? "知识库开放失败" : "知识库关闭失败"));
return false;
} finally {
operatingId.value = null;
}
}
async function archive(item: KnowledgeItem) {
if (!await confirmAction("归档后知识库会立即关闭,当前最新版和检索记录仍会保留。", "归档知识库", {
confirmButtonText: "确认归档", cancelButtonText: "取消", type: "warning",
})) return;
await api.setKnowledgeLifecycle(item.id, "archived");
ElMessage.success("知识库已归档");
await load();
}
async function remove(item: KnowledgeItem) {
if (!await confirmAction(
`将永久删除“${item.name}”的知识正文、版本、章节、切片和同步记录,并立即停止 Agent 使用。审计日志仍会保留,该操作不可恢复。`,
"永久删除知识库",
{ confirmButtonText: "永久删除", cancelButtonText: "取消", type: "error" },
)) return;
await api.deleteKnowledge(item.id);
ElMessage.success("知识库已删除");
await load();
}
function statusType(status: string) {
if (["published", "passed", "normal", "completed"].includes(status)) return "success";
if (["failed", "blocked", "error", "rejected"].includes(status)) return "danger";
if (["pending_review", "processing", "pending"].includes(status)) return "warning";
return "info";
}
function typeLabel(value: string) {
return knowledgeTypeOptions.find((item) => item.value === value)?.label || value;
}
function typeDescription(value: string) {
return ({
fixed: "固定信息类用于上课时间、带练安排、回放音频、课程作业、会议链接、服务权益、助理联系方式等当前安排;与其他库冲突时优先采用固定信息类。",
course: "课程知识库用于课程概念、功课步骤、老师观点和课程原文解释;优先级高于答疑和通用知识。",
qa: "答疑知识库用于参考过往问答和常见问题,但不能覆盖课程原文或固定信息类的当前安排。",
general: "通用知识库只做背景补充,不能覆盖课程知识、答疑知识或固定信息类。",
} as Record<string, string>)[value] || "用于补充背景信息。";
}
function contentItemTypeLabel(value: string) {
return ({ section: "章节", card: "知识卡", chunk: "切片" } as Record<string, string>)[value] || value;
}
function matchedLabel(values: string[]) {
const labels = values.map((value) => ({ title: "标题", content: "正文" } as Record<string, string>)[value] || value);
return labels.join("、");
}
function highlightSnippet(text: string, terms: string[] = []) {
const keywords = Array.from(new Set([...terms, contentSearch.keyword.trim()].filter(Boolean)))
.sort((left, right) => right.length - left.length);
if (!keywords.length) return [{ text, match: false }];
const parts: Array<{ text: string; match: boolean }> = [];
const source = text || "";
const lowerSource = source.toLowerCase();
let cursor = 0;
while (cursor < source.length) {
const next = keywords
.map((keyword) => ({ keyword, index: lowerSource.indexOf(keyword.toLowerCase(), cursor) }))
.filter((item) => item.index >= 0)
.sort((left, right) => left.index - right.index || right.keyword.length - left.keyword.length)[0];
if (!next) {
parts.push({ text: source.slice(cursor), match: false });
break;
}
const { index, keyword } = next;
if (index > cursor) parts.push({ text: source.slice(cursor, index), match: false });
parts.push({ text: source.slice(index, index + keyword.length), match: true });
cursor = index + keyword.length;
}
return parts.length ? parts : [{ text: source, match: false }];
}
function errorMessage(error: unknown, fallback: string) {
return error instanceof Error ? error.message : fallback;
}
async function confirmAction(
message: string,
title: string,
options: Parameters<typeof ElMessageBox.confirm>[2],
) {
try {
await ElMessageBox.confirm(message, title, options);
return true;
} catch {
return false;
}
}
</script>
<template>
<div class="page-head inline">
<div><h2>知识库管理</h2><p>一篇飞书文章对应一个知识库同步成功后自动生效并且只保留最新版</p></div>
<el-button type="primary" :loading="loading" @click="runMigration">迁移现有知识库</el-button>
</div>
<section class="migration-overview">
<div><span>知识库总数</span><strong>{{ migration?.total ?? 0 }}</strong></div>
<div><span>待迁移</span><strong>{{ migration?.counts.pending ?? 0 }}</strong></div>
<div><span>迁移完成</span><strong>{{ migration?.counts.completed ?? 0 }}</strong></div>
<div><span>失败</span><strong>{{ migration?.counts.failed ?? 0 }}</strong></div>
</section>
<section class="knowledge-work-panel">
<h3>通过 Node ID 快速新增</h3>
<div class="node-resolve-row">
<el-input v-model="form.feishuNodeId" placeholder="请输入飞书 Node ID" :disabled="resolvingNode" @input="nodeResolved = false" />
<el-button type="primary" :loading="resolvingNode" @click="resolveNode">获取知识库信息</el-button>
</div>
<el-alert v-if="nodeResolved" :title="`已获取:${sourceTitle}`" type="success" :closable="false" show-icon />
<div v-if="nodeResolved" class="knowledge-create-grid">
<el-input v-model="form.feishuSpaceId" placeholder="Space ID" disabled />
<el-input :model-value="sourceTitle" placeholder="飞书原标题" disabled />
<el-input v-model="form.name" placeholder="后台显示名称" />
<el-select v-model="form.knowledgeType">
<el-option v-for="item in knowledgeTypeOptions" :key="item.value" :label="item.label" :value="item.value" />
</el-select>
<el-input v-model="form.remark" placeholder="备注(可修改)" />
<el-button type="primary" @click="createKnowledge">保存知识库</el-button>
</div>
</section>
<section class="knowledge-content-search-panel">
<div class="content-search-head">
<div>
<h3>搜索知识库内容</h3>
<p>跨全部当前最新版搜索章节知识卡和切片用于确认内容是否已同步进检索链路</p>
</div>
<el-switch v-model="contentSearch.includeClosed" active-text="包含已关闭" inactive-text="仅开放" />
</div>
<div class="content-search-row">
<el-input
v-model="contentSearch.keyword"
placeholder="输入内容关键字,例如:静水流深静心、合一作业、会议链接"
clearable
@clear="clearContentSearch"
@keydown.enter.exact.prevent="searchContent(1)"
/>
<el-button type="primary" :loading="contentSearchLoading" @click="searchContent(1)">搜索内容</el-button>
</div>
<div v-if="contentSearchPager.total || contentSearchRows.length" v-loading="contentSearchLoading" class="content-search-results">
<div class="content-search-result-summary">
找到 {{ contentSearchPager.total }} 条命中
<span>结果只来自当前最新版已关闭知识库会标记为不可被用户端默认召回</span>
</div>
<article v-for="item in contentSearchRows" :key="`${item.itemType}-${item.itemId}`" class="content-search-hit">
<div class="content-search-hit-main">
<div class="content-search-hit-title">
<strong>{{ item.title }}</strong>
<el-tag v-for="type in item.itemTypes || [item.itemType]" :key="type" size="small" effect="plain">{{ contentItemTypeLabel(type) }}</el-tag>
<el-tag size="small" :type="item.canAgentUse ? 'success' : 'warning'">{{ item.canAgentUse ? '可参与召回' : '不可默认召回' }}</el-tag>
</div>
<p class="content-search-hit-meta">
{{ item.knowledgeName }} · V{{ item.versionNo }} · {{ item.sectionKey }} · 命中{{ matchedLabel(item.matchedIn) }}
</p>
<p class="content-search-hit-snippet">
<template v-for="(part, index) in highlightSnippet(item.snippet, item.highlightTerms)" :key="index">
<mark v-if="part.match">{{ part.text }}</mark><span v-else>{{ part.text }}</span>
</template>
</p>
<div class="content-search-diagnostics">
<span>{{ item.knowledgeStatus === 1 ? '已开放' : '已关闭' }}</span>
<span>{{ item.sourceStatus === 'normal' ? '源正常' : '源异常' }}</span>
<span>{{ item.hasChunks ? '已生成切片' : '未生成切片' }}</span>
<span>{{ item.isCurrentVersion ? '当前版本' : '非当前版本' }}</span>
</div>
</div>
<el-button type="primary" plain @click="openSearchHit(item)">查看内容</el-button>
</article>
<AdminPagination :page="contentSearchPager.page" :page-size="contentSearchPager.pageSize" :total="contentSearchPager.total" @change="searchContent" />
</div>
<el-empty v-else-if="contentSearch.keyword && !contentSearchLoading" description="暂无内容搜索结果" :image-size="64" />
</section>
<div class="knowledge-filters">
<el-input v-model="filter.keyword" placeholder="搜索后台名称或飞书标题" clearable @change="load(1)" />
<el-select v-model="filter.type" placeholder="知识库类型" clearable @change="load(1)"><el-option v-for="item in knowledgeTypeOptions" :key="item.value" :label="item.label" :value="item.value" /></el-select>
<el-select v-model="filter.open" placeholder="开放状态" clearable @change="load(1)"><el-option label="已开放" value="1" /><el-option label="已关闭" value="0" /></el-select>
<el-select v-model="filter.source" placeholder="源状态" clearable @change="load(1)"><el-option label="正常" value="normal" /><el-option label="源异常" value="error" /></el-select>
</div>
<div class="knowledge-batch-bar">
<span>已选择 {{ selectedRows.length }} 个知识库</span>
<div class="knowledge-batch-actions">
<el-button type="primary" plain :disabled="selectedRows.length === 0 || batchSaving" :loading="batchSyncing" @click="syncSelected">批量同步</el-button>
<el-button :disabled="selectedRows.length === 0 || batchSyncing" @click="openBatchEdit">批量修改类型</el-button>
</div>
</div>
<el-table v-loading="loading" :data="rows" stripe @selection-change="selectedRows = $event">
<el-table-column type="selection" width="48" />
<el-table-column label="知识库" min-width="240"><template #default="{ row }"><strong>{{ row.name }}</strong><small class="table-subline">{{ row.sourceTitle || "尚未同步源标题" }}</small></template></el-table-column>
<el-table-column label="类型" width="140"><template #default="{ row }">{{ typeLabel(row.knowledgeType) }}</template></el-table-column>
<el-table-column label="当前版本" width="130"><template #default="{ row }"><span>{{ row.currentVersionId ? `#${row.currentVersionId}` : '尚未同步' }}</span></template></el-table-column>
<el-table-column label="状态" width="210"><template #default="{ row }"><div class="knowledge-status-cell"><el-switch v-model="row.status" :active-value="1" :inactive-value="0" active-text="开放" inactive-text="关闭" inline-prompt :loading="operatingId === row.id" :disabled="row.lifecycleStatus === 'archived'" :before-change="() => toggleOpen(row)" /><el-tag :type="statusType(row.sourceStatus)">{{ row.sourceStatus === 'normal' ? '源正常' : '源异常' }}</el-tag></div></template></el-table-column>
<el-table-column label="最近同步" width="180"><template #default="{ row }">{{ formatDateTime(row.lastSyncedAt) }}</template></el-table-column>
<el-table-column label="操作" width="220" fixed="right"><template #default="{ row }"><TableRowActions><el-button link type="primary" @click="openDetail(row)">详情</el-button><el-button link type="primary" :disabled="row.lifecycleStatus === 'archived'" :loading="operatingId === row.id" @click="sync(row)">同步最新版</el-button><el-dropdown trigger="click" :teleported="true" @command="handleMore($event, row)"><el-button link type="primary">更多</el-button><template #dropdown><el-dropdown-menu><el-dropdown-item command="detail">查看详情</el-dropdown-item><el-dropdown-item command="preview">进入 Agent 预览</el-dropdown-item><el-dropdown-item v-if="row.lifecycleStatus === 'active'" command="archive" divided>归档</el-dropdown-item><el-dropdown-item v-else command="restore" divided>恢复</el-dropdown-item><el-dropdown-item command="delete" divided>永久删除</el-dropdown-item></el-dropdown-menu></template></el-dropdown></TableRowActions></template></el-table-column>
</el-table>
<AdminPagination :page="pager.page" :page-size="pager.pageSize" :total="pager.total" @change="load" />
<el-drawer v-model="detailOpen" size="76%" title="知识库详情" destroy-on-close>
<template v-if="detail">
<div class="knowledge-detail-head">
<div><h3>{{ detail.knowledge.name }}</h3><p>{{ typeLabel(detail.knowledge.knowledgeType) }} · 同步后自动生效</p></div>
<div class="knowledge-detail-head-actions"><el-button type="primary" plain @click="activeTab = 'content'">预览内容</el-button><el-tag :type="detail.knowledge.status === 1 ? 'success' : 'info'">{{ detail.knowledge.status === 1 ? '已开放' : '已关闭' }}</el-tag><el-tag :type="statusType(detail.knowledge.sourceStatus)">{{ detail.knowledge.sourceStatus === 'normal' ? '源文章正常' : '源文章异常' }}</el-tag></div>
</div>
<el-alert v-if="detail.knowledge.sourceError" :title="detail.knowledge.sourceError" type="error" :closable="false" />
<el-tabs v-model="activeTab">
<el-tab-pane label="基本信息" name="overview">
<div class="knowledge-section-head"><h4>基本信息</h4><el-button type="primary" plain @click="openEdit">编辑基本信息</el-button></div>
<dl class="knowledge-meta"><dt>后台名称</dt><dd>{{ detail.knowledge.name }}</dd><dt>知识库类型</dt><dd>{{ typeLabel(detail.knowledge.knowledgeType) }}</dd><dt>版本更新</dt><dd>同步成功后自动替换</dd><dt>SpaceID</dt><dd>{{ detail.knowledge.feishuSpaceId }}</dd><dt>NodeID</dt><dd>{{ detail.knowledge.feishuNodeId }}</dd><dt>当前版本</dt><dd>{{ detail.currentVersion ? `V${detail.currentVersion.versionNo}` : '暂无' }}</dd></dl>
<el-alert :title="typeDescription(detail.knowledge.knowledgeType)" type="info" :closable="false" show-icon />
<section v-if="detail.manifest" class="manifest-panel"><h4>Knowledge Manifest</h4><p><strong>用途</strong>{{ detail.manifest.purpose }}</p><p><strong>适用</strong>{{ detail.manifest.applicableQuestions }}</p><p><strong>不适用</strong>{{ detail.manifest.inapplicableQuestions }}</p><p><strong>核心主题</strong>{{ detail.manifest.coreTopics }}</p><p><strong>边界</strong>{{ detail.manifest.boundaries }}</p></section>
</el-tab-pane>
<el-tab-pane label="内容预览" name="content" lazy>
<KnowledgeContentPreview
:version-id="previewVersionId"
:target-section-id="previewTargetSectionId"
:highlight-keyword="previewHighlightKeyword"
/>
</el-tab-pane>
<el-tab-pane label="同步记录" name="jobs"><el-table :data="jobs"><el-table-column prop="id" label="任务" width="80" /><el-table-column prop="status" label="状态" width="130" /><el-table-column prop="stage" label="阶段" width="130" /><el-table-column label="进度" width="180"><template #default="{ row }"><el-progress :percentage="row.progress" /></template></el-table-column><el-table-column prop="sourceError" label="失败原因" min-width="260" /></el-table></el-tab-pane>
</el-tabs>
</template>
</el-drawer>
<el-dialog v-model="editOpen" title="编辑知识库基本信息" width="min(520px, 92vw)" append-to-body destroy-on-close>
<el-form label-position="top">
<el-form-item label="后台知识库名称" required><el-input v-model="editForm.name" maxlength="100" show-word-limit /></el-form-item>
<el-form-item label="知识库类型" required><el-select v-model="editForm.knowledgeType"><el-option v-for="item in knowledgeTypeOptions" :key="item.value" :label="item.label" :value="item.value" /></el-select></el-form-item>
<el-alert title="本次修改不会重新同步飞书,也不会改变当前正式版本和开放状态。" type="info" :closable="false" />
</el-form>
<template #footer><el-button @click="editOpen = false">取消</el-button><el-button type="primary" :loading="editSaving" @click="saveMetadata">保存</el-button></template>
</el-dialog>
<el-dialog v-model="batchOpen" title="批量修改知识库" width="min(520px, 92vw)" append-to-body destroy-on-close>
<el-alert :title="`已选择 ${selectedRows.length} 个知识库,未选择的字段将保持原值。`" type="info" :closable="false" />
<el-form label-position="top" class="batch-edit-form">
<el-form-item label="知识库类型"><el-select v-model="batchForm.knowledgeType" clearable placeholder="不修改"><el-option v-for="item in knowledgeTypeOptions" :key="item.value" :label="item.label" :value="item.value" /></el-select></el-form-item>
</el-form>
<template #footer><el-button @click="batchOpen = false">取消</el-button><el-button type="primary" :loading="batchSaving" @click="saveBatchMetadata">下一步</el-button></template>
</el-dialog>
<el-dialog v-model="batchSyncResultOpen" title="批量同步结果" width="min(760px, 94vw)" append-to-body destroy-on-close>
<el-alert
v-if="batchSyncResult"
:title="`共处理 ${batchSyncResult.total} 个:成功 ${batchSyncResult.success} 个,失败 ${batchSyncResult.failed} 个`"
:type="batchSyncResult.failed ? 'warning' : 'success'"
:closable="false"
show-icon
/>
<el-table v-if="batchSyncResult" :data="batchSyncResult.results" max-height="440" class="batch-sync-result-table">
<el-table-column label="知识库" min-width="220">
<template #default="{ row }">{{ row.name || `知识库 #${row.knowledgeId}` }}</template>
</el-table-column>
<el-table-column label="结果" width="100">
<template #default="{ row }"><el-tag :type="row.ok ? 'success' : 'danger'">{{ row.ok ? '成功' : '失败' }}</el-tag></template>
</el-table-column>
<el-table-column label="状态 / 原因" min-width="300">
<template #default="{ row }">{{ row.ok ? (row.status === 'unchanged' ? '内容无变化' : '最新内容已生效') : row.error }}</template>
</el-table-column>
</el-table>
<template #footer><el-button type="primary" @click="batchSyncResultOpen = false">完成</el-button></template>
</el-dialog>
</template>
<style scoped>
.knowledge-batch-bar,
.knowledge-section-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
}
.knowledge-batch-bar {
min-height: 48px;
padding: 8px 0;
color: #66756f;
}
.knowledge-batch-actions {
display: flex;
align-items: center;
gap: 8px;
}
.knowledge-section-head h4 {
margin: 0;
}
.knowledge-status-cell {
display: flex;
align-items: center;
gap: 10px;
}
.knowledge-detail-head-actions {
display: flex;
align-items: center;
gap: 8px;
}
.batch-edit-form {
margin-top: 20px;
}
.batch-sync-result-table {
margin-top: 16px;
}
.knowledge-content-search-panel {
margin: 18px 0;
padding: 18px;
border: 1px solid #dfe8e5;
border-radius: 10px;
background: #fbfdfc;
}
.content-search-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 20px;
margin-bottom: 14px;
}
.content-search-head h3 {
margin: 0;
color: #203b32;
}
.content-search-head p {
margin: 6px 0 0;
color: #71817b;
font-size: 13px;
}
.content-search-row {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 12px;
}
.content-search-results {
display: grid;
gap: 10px;
margin-top: 14px;
}
.content-search-result-summary {
display: flex;
justify-content: space-between;
gap: 12px;
color: #53645e;
font-size: 13px;
}
.content-search-result-summary span {
color: #81918b;
}
.content-search-hit {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
align-items: center;
gap: 16px;
padding: 14px;
border: 1px solid #e1ebe7;
border-radius: 8px;
background: #ffffff;
}
.content-search-hit-main {
min-width: 0;
}
.content-search-hit-title {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
}
.content-search-hit-title strong {
overflow: hidden;
color: #203b32;
font-size: 14px;
text-overflow: ellipsis;
white-space: nowrap;
}
.content-search-hit-meta {
margin: 6px 0 0;
color: #708179;
font-size: 12px;
}
.content-search-hit-snippet {
margin: 8px 0 0;
color: #354840;
font-size: 13px;
line-height: 1.7;
}
.content-search-hit-snippet mark {
padding: 0 2px;
border-radius: 3px;
background: #fff1a8;
color: inherit;
}
.content-search-diagnostics {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-top: 8px;
}
.content-search-diagnostics span {
padding: 3px 7px;
border-radius: 999px;
background: #eef5f2;
color: #536b61;
font-size: 11px;
}
@media (max-width: 760px) {
.content-search-head,
.content-search-result-summary,
.content-search-hit {
align-items: stretch;
grid-template-columns: 1fr;
flex-direction: column;
}
.content-search-row {
grid-template-columns: 1fr;
}
}
</style>

View File

@@ -0,0 +1,579 @@
<script setup lang="ts">
import { ElMessage } from "element-plus";
import { onMounted, reactive, ref, watch } from "vue";
import { api } from "../services/api";
import type {
AiLogRecord,
ChatRecord,
QuestionInsightSummary,
SsoClientItem,
} from "../types/api";
import AdminPagination from "./AdminPagination.vue";
import AiLogDetailDrawer from "./AiLogDetailDrawer.vue";
import ChatDetailDrawer from "./ChatDetailDrawer.vue";
import { formatDateTime, toUtcIsoFromBusinessDateTime } from "../utils/dateTime";
defineProps<{ canExport: boolean }>();
type RecordTab = "chats" | "questionInsights" | "aiLogs" | "operationLogs";
const loading = ref(false);
const insightExporting = ref(false);
const activeTab = ref<RecordTab>("chats");
const chats = ref<ChatRecord[]>([]);
const ssoClients = ref<SsoClientItem[]>([]);
const aiLogs = ref<AiLogRecord[]>([]);
const operationLogs = ref<Record<string, unknown>[]>([]);
const insights = ref<QuestionInsightSummary | null>(null);
const detailSessionId = ref<number | null>(null);
const chatDetailOpen = ref(false);
const aiLogId = ref<number | null>(null);
const aiLogDetailOpen = ref(false);
const pagers = reactive({
chats: { page: 1, pageSize: 20, total: 0 },
aiLogs: { page: 1, pageSize: 20, total: 0 },
operationLogs: { page: 1, pageSize: 20, total: 0 },
questionInsights: { page: 1, pageSize: 20, total: 0 },
});
const chatFilters = reactive({
keyword: "",
userId: undefined as number | undefined,
status: "",
sourceType: "",
sourceClientId: undefined as number | undefined,
dateFrom: "",
dateTo: "",
});
const insightFilters = reactive({
dateFrom: "",
dateTo: "",
minCount: 2,
maxMessages: 5000,
});
onMounted(async () => {
await Promise.all([loadTab("chats"), loadSsoClients()]);
});
watch(activeTab, (tab) => loadTab(tab));
async function loadTab(tab: RecordTab = activeTab.value) {
loading.value = true;
try {
if (tab === "chats") {
const result = await api.chats({
...buildChatQuery(),
page: pagers.chats.page,
pageSize: pagers.chats.pageSize,
});
chats.value = result.items;
Object.assign(pagers.chats, {
page: result.page,
pageSize: result.pageSize,
total: result.total,
});
} else if (tab === "aiLogs") {
const result = await api.aiLogs({
page: pagers.aiLogs.page,
pageSize: pagers.aiLogs.pageSize,
});
aiLogs.value = result.items;
Object.assign(pagers.aiLogs, {
page: result.page,
pageSize: result.pageSize,
total: result.total,
});
} else if (tab === "operationLogs") {
const result = await api.operationLogs({
page: pagers.operationLogs.page,
pageSize: pagers.operationLogs.pageSize,
});
operationLogs.value = result.items;
Object.assign(pagers.operationLogs, {
page: result.page,
pageSize: result.pageSize,
total: result.total,
});
} else
await loadInsights(
pagers.questionInsights.page,
pagers.questionInsights.pageSize,
);
} finally {
loading.value = false;
}
}
async function loadSsoClients() {
try {
ssoClients.value = await api.ssoClients();
} catch {
ssoClients.value = [];
}
}
async function changePage(tab: RecordTab, page: number, pageSize: number) {
Object.assign(pagers[tab], { page, pageSize });
await loadTab(tab);
}
async function loadInsights(
page = 1,
pageSize = pagers.questionInsights.pageSize,
) {
insights.value = await api.questionInsights({
dateFrom: toUtcIsoFromBusinessDateTime(insightFilters.dateFrom),
dateTo: toUtcIsoFromBusinessDateTime(insightFilters.dateTo),
minCount: insightFilters.minCount,
maxMessages: insightFilters.maxMessages,
page,
pageSize,
});
Object.assign(pagers.questionInsights, {
page: insights.value.page,
pageSize: insights.value.pageSize,
total: insights.value.total,
});
}
async function searchChats() {
pagers.chats.page = 1;
await loadTab("chats");
}
async function resetChatFilters() {
Object.assign(chatFilters, {
keyword: "",
userId: undefined,
status: "",
sourceType: "",
sourceClientId: undefined,
dateFrom: "",
dateTo: "",
});
await searchChats();
}
async function refreshInsights() {
pagers.questionInsights.page = 1;
loading.value = true;
try {
const result = await api.refreshQuestionInsights({
dateFrom: toUtcIsoFromBusinessDateTime(insightFilters.dateFrom),
dateTo: toUtcIsoFromBusinessDateTime(insightFilters.dateTo),
maxMessages: insightFilters.maxMessages,
});
await loadInsights(1);
ElMessage.success(
result.processedMessages
? `新增清洗 ${result.processedMessages} 条消息,得到 ${result.acceptedQuestions} 个有效问题${result.hasMore ? ";仍有历史消息待清洗" : ""}`
: "没有新增消息,已直接使用持久化清洗结果统计",
);
} catch (error) {
ElMessage.error(
error instanceof Error ? error.message : "问题洞察刷新失败",
);
} finally {
loading.value = false;
}
}
async function resetInsightFilters() {
Object.assign(insightFilters, {
dateFrom: "",
dateTo: "",
minCount: 2,
maxMessages: 5000,
});
await refreshInsights();
}
async function exportInsights() {
const dateFrom = toUtcIsoFromBusinessDateTime(insightFilters.dateFrom);
const dateTo = toUtcIsoFromBusinessDateTime(insightFilters.dateTo);
if (dateFrom && dateTo && dateFrom > dateTo) {
return ElMessage.warning("开始时间不能晚于结束时间");
}
insightExporting.value = true;
try {
const fromLabel = insightFilters.dateFrom.slice(0, 10).replaceAll("-", "") || "全部";
const toLabel = insightFilters.dateTo.slice(0, 10).replaceAll("-", "") || "全部";
await api.exportQuestionInsights(
{
dateFrom,
dateTo,
minCount: insightFilters.minCount,
maxMessages: insightFilters.maxMessages,
},
`问题洞察_${fromLabel}_${toLabel}.xlsx`,
);
ElMessage.success("问题洞察已导出");
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : "问题洞察导出失败");
} finally {
insightExporting.value = false;
}
}
function openChat(sessionId: number) {
detailSessionId.value = sessionId;
chatDetailOpen.value = true;
}
function openAiLog(row: AiLogRecord) {
aiLogId.value = row.id;
aiLogDetailOpen.value = true;
}
function buildChatQuery() {
return {
keyword: chatFilters.keyword,
userId: chatFilters.userId,
status: chatFilters.status,
sourceType: chatFilters.sourceType,
sourceClientId: chatFilters.sourceClientId,
dateFrom: toUtcIsoFromBusinessDateTime(chatFilters.dateFrom),
dateTo: toUtcIsoFromBusinessDateTime(chatFilters.dateTo),
};
}
function formatMoney(value?: number | null, currency = "CNY") {
return typeof value === "number"
? `${currency || "CNY"} ${value.toFixed(6)}`
: "-";
}
</script>
<template>
<section class="feature-page">
<div class="page-head">
<h2>记录审计</h2>
<p>聊天AI 请求和后台操作记录</p>
</div>
<el-tabs v-model="activeTab">
<el-tab-pane label="聊天记录" name="chats">
<div class="record-filter">
<el-input
v-model="chatFilters.keyword"
placeholder="搜索标题、手机号、姓名、聊天内容"
clearable
/><el-input-number
v-model="chatFilters.userId"
:min="1"
placeholder="用户ID"
/><el-select
v-model="chatFilters.status"
placeholder="消息状态"
clearable
><el-option label="全部状态" value="" /><el-option
label="已完成"
value="FINISHED" /><el-option
label="生成中"
value="GENERATING" /><el-option
label="已停止"
value="STOPPED" /><el-option label="失败" value="FAILED"
/></el-select>
<el-select v-model="chatFilters.sourceType" placeholder="会话来源" clearable @change="chatFilters.sourceClientId = undefined">
<el-option label="直接访问" value="direct" />
<el-option label="第三方应用" value="sso" />
</el-select>
<el-select v-if="chatFilters.sourceType === 'sso'" v-model="chatFilters.sourceClientId" placeholder="来源应用" clearable>
<el-option v-for="client in ssoClients" :key="client.id" :label="client.name" :value="client.id" />
</el-select>
<div class="record-date-range" aria-label="时间范围">
<label class="record-date-field"
><span>开始时间</span
><input
v-model="chatFilters.dateFrom"
type="datetime-local"
aria-label="开始时间" /></label
><span class="record-date-sep"></span
><label class="record-date-field"
><span>结束时间</span
><input
v-model="chatFilters.dateTo"
type="datetime-local"
aria-label="结束时间"
/></label>
</div>
<div class="record-filter-actions">
<el-button type="primary" @click="searchChats">搜索</el-button
><el-button @click="resetChatFilters">重置</el-button
><el-button v-if="canExport" @click="api.exportChats(buildChatQuery())"
>导出</el-button
>
</div>
</div>
<el-table v-loading="loading" :data="chats" stripe
><el-table-column
prop="id"
label="会话ID"
width="90"
/><el-table-column
prop="userPhone"
label="手机号"
width="140"
/><el-table-column
prop="userName"
label="用户"
width="120"
/><el-table-column
prop="sourceName"
label="会话来源"
min-width="160"
show-overflow-tooltip
/><el-table-column
prop="title"
label="会话标题"
min-width="220"
show-overflow-tooltip
/><el-table-column
prop="messageCount"
label="消息数"
width="90"
/><el-table-column label="最后消息" width="180"
><template #default="{ row }">{{ formatDateTime(row.lastMessageAt) }}</template
></el-table-column><el-table-column label="操作" width="100" fixed="right"
><template #default="{ row }"
><el-button size="small" @click="openChat(row.id)"
>详情</el-button
></template
></el-table-column
></el-table
>
<AdminPagination
:page="pagers.chats.page"
:page-size="pagers.chats.pageSize"
:total="pagers.chats.total"
@change="(page, size) => changePage('chats', page, size)"
/>
</el-tab-pane>
<el-tab-pane label="问题洞察" name="questionInsights">
<div class="record-filter question-insight-filter">
<div class="record-date-range" aria-label="问题时间范围">
<label class="record-date-field"
><span>开始时间</span
><input
v-model="insightFilters.dateFrom"
type="datetime-local"
aria-label="问题开始时间" /></label
><span class="record-date-sep"></span
><label class="record-date-field"
><span>结束时间</span
><input
v-model="insightFilters.dateTo"
type="datetime-local"
aria-label="问题结束时间"
/></label>
</div>
<label class="insight-number-field"
><span>最低频次</span
><el-input-number
v-model="insightFilters.minCount"
:min="1"
:max="50"
controls-position="right" /></label
><label class="insight-number-field"
><span>单次清洗上限</span
><el-input-number
v-model="insightFilters.maxMessages"
:min="100"
:max="20000"
:step="500"
controls-position="right"
/></label>
<div class="record-filter-actions">
<el-button
type="primary"
:loading="loading"
@click="refreshInsights"
>刷新并统计</el-button
><el-button @click="resetInsightFilters">重置</el-button>
<el-button v-if="canExport" :loading="insightExporting" @click="exportInsights">导出洞察</el-button>
</div>
</div>
<p class="question-insight-help">
清洗结果会持久化保存点击刷新并统计只处理尚未清洗的用户消息再按当前时间范围聚合翻页不会重新扫描聊天原文
</p>
<section v-if="insights" class="question-insight-summary">
<div>
<span>纳入清洗消息</span
><strong>{{ insights.summary.scannedMessages }}</strong>
</div>
<div>
<span>有效问题</span
><strong>{{ insights.summary.cleanedQuestions }}</strong>
</div>
<div>
<span>过滤低价值</span
><strong>{{ insights.summary.filteredMessages }}</strong>
</div>
<div>
<span>高频问题组</span
><strong>{{ insights.summary.visibleClusterCount }}</strong>
</div>
</section>
<section v-loading="loading" class="question-insight-list">
<article
v-for="cluster in insights?.items || []"
:key="`${cluster.rank}-${cluster.normalized}`"
class="question-insight-card"
>
<header>
<div>
<small>TOP {{ cluster.rank }}</small>
<h3>{{ cluster.title }}</h3>
</div>
<div class="question-insight-metrics">
<span>{{ cluster.count }} </span
><span>{{ cluster.userCount }} </span
><span>{{ cluster.sessionCount }} 个会话</span>
</div>
</header>
<div class="question-insight-tags">
<el-tag size="small" effect="plain">{{
cluster.categoryLabel
}}</el-tag
><el-tag
v-if="cluster.needsKnowledgeFollowUp"
size="small"
type="warning"
effect="plain"
>需补知识/查召回</el-tag
><el-tag
v-if="cluster.noHitCount"
size="small"
type="danger"
effect="plain"
>无命中 {{ cluster.noHitCount }}</el-tag
><el-tag
v-if="cluster.failedCount"
size="small"
type="danger"
effect="dark"
>失败 {{ cluster.failedCount }}</el-tag
><el-tag
v-for="term in cluster.topTerms"
:key="term"
size="small"
type="success"
effect="plain"
>{{ term }}</el-tag
>
</div>
<p class="question-insight-action">{{ cluster.suggestedAction }}</p>
<el-collapse
><el-collapse-item
title="查看相似问法和原始样例"
:name="cluster.normalized"
><div class="question-variants">
<span v-for="variant in cluster.variants" :key="variant.text"
>{{ variant.text }} × {{ variant.count }}</span
>
</div>
<div class="question-samples">
<article
v-for="sample in cluster.samples"
:key="sample.messageId"
>
<div>
<strong>{{
sample.userName ||
sample.userPhone ||
`用户 #${sample.userId}`
}}</strong
><span>{{ formatDateTime(sample.createdAt) }}</span>
</div>
<p>清洗后{{ sample.cleaned }}</p>
<pre>{{ sample.raw }}</pre>
<el-button
link
type="primary"
@click="openChat(sample.sessionId)"
>查看原会话</el-button
>
</article>
</div></el-collapse-item
></el-collapse
>
</article>
<el-empty
v-if="insights && insights.items.length === 0"
description="当前条件下暂无达到频次的问题组"
:image-size="72"
/><el-empty
v-else-if="!insights && !loading"
description="选择时间范围后点击统计问题"
:image-size="72"
/>
</section>
<AdminPagination
:page="pagers.questionInsights.page"
:page-size="pagers.questionInsights.pageSize"
:total="pagers.questionInsights.total"
@change="(page, size) => changePage('questionInsights', page, size)"
/>
</el-tab-pane>
<el-tab-pane label="AI 请求" name="aiLogs"
><el-table v-loading="loading" :data="aiLogs" stripe
><el-table-column
prop="sessionId"
label="会话ID"
width="90"
/><el-table-column
prop="modelName"
label="模型"
width="160"
/><el-table-column
prop="knowledgeIds"
label="知识库"
width="120"
/><el-table-column
prop="retrieveCount"
label="命中"
width="80"
/><el-table-column label="片段" width="80"
><template #default="{ row }">{{
row.retrievedChunks?.length || 0
}}</template></el-table-column
><el-table-column
prop="totalToken"
label="Token"
width="90"
/><el-table-column label="估算成本" width="130"
><template #default="{ row }">{{
formatMoney(row.estimatedCost, row.currency || "CNY")
}}</template></el-table-column
><el-table-column
prop="costMs"
label="耗时(ms)"
width="100"
/><el-table-column
prop="status"
label="状态"
width="100"
/><el-table-column
prop="errorMessage"
label="错误"
min-width="220"
show-overflow-tooltip
/><el-table-column label="操作" width="100" fixed="right"
><template #default="{ row }"
><el-button size="small" @click="openAiLog(row)"
>详情</el-button
></template
></el-table-column
></el-table
><AdminPagination
:page="pagers.aiLogs.page"
:page-size="pagers.aiLogs.pageSize"
:total="pagers.aiLogs.total"
@change="(page, size) => changePage('aiLogs', page, size)"
/></el-tab-pane>
<el-tab-pane label="操作日志" name="operationLogs"
><el-table v-loading="loading" :data="operationLogs" stripe
><el-table-column prop="module" label="模块" /><el-table-column
prop="action"
label="动作" /><el-table-column
prop="result"
label="结果" /><el-table-column label="时间" width="180"
><template #default="{ row }">{{ formatDateTime(String(row.createdAt || "")) }}</template
></el-table-column></el-table
><AdminPagination
:page="pagers.operationLogs.page"
:page-size="pagers.operationLogs.pageSize"
:total="pagers.operationLogs.total"
@change="(page, size) => changePage('operationLogs', page, size)"
/></el-tab-pane>
</el-tabs>
<ChatDetailDrawer v-model="chatDetailOpen" :session-id="detailSessionId" :can-export="canExport" />
<AiLogDetailDrawer v-model="aiLogDetailOpen" :log-id="aiLogId" />
</section>
</template>

View File

@@ -0,0 +1,216 @@
<script setup lang="ts">
import { ElMessage, ElMessageBox } from "element-plus";
import { computed, onMounted, reactive, ref } from "vue";
import { api } from "../services/api";
import type { RetrievalLogItem } from "../types/api";
import AdminPagination from "./AdminPagination.vue";
import { formatDateTime } from "../utils/dateTime";
const loading = ref(false);
const logs = ref<RetrievalLogItem[]>([]);
const pager = reactive({ page: 1, pageSize: 20, total: 0 });
const cleanupBefore = ref("");
const retentionDays = ref<number | null>(90);
const retentionChoice = computed({
get: () => retentionDays.value ?? -1,
set: (value: number) => {
retentionDays.value = value === -1 ? null : value;
},
});
const retentionState = ref<{
lastRunAt: string | null;
lastResult: string | null;
} | null>(null);
const selected = ref<Record<string, any> | null>(null);
const detailOpen = ref(false);
onMounted(async () => {
loading.value = true;
try {
await Promise.all([loadLogs(), loadRetention()]);
} finally {
loading.value = false;
}
});
async function loadLogs(page = 1, pageSize = pager.pageSize) {
const result = await api.retrievalLogs({ page, pageSize });
logs.value = result.items;
Object.assign(pager, {
page: result.page,
pageSize: result.pageSize,
total: result.total,
});
}
async function loadRetention() {
const result = await api.retrievalRetention();
retentionDays.value = result.retentionDays;
retentionState.value = result;
}
async function cleanup() {
if (!cleanupBefore.value) return ElMessage.warning("请选择清理日期");
const estimate = await api.estimateRetrievalCleanup(cleanupBefore.value);
await ElMessageBox.confirm(
`预计清理 ${estimate.estimatedCount} 条检索日志及关联明细,操作日志不会被删除。`,
"确认清理",
{ type: "warning" },
);
const result = await api.cleanupRetrievalLogs(cleanupBefore.value);
ElMessage.success(`已清理 ${result.deleted} 条日志`);
await loadLogs(1);
}
async function saveRetention() {
await api.saveRetrievalRetention(retentionDays.value);
ElMessage.success(
retentionDays.value == null
? "已设为永久保留"
: `已设为保留 ${retentionDays.value}`,
);
await loadRetention();
}
async function openDetail(row: RetrievalLogItem) {
selected.value = await api.retrievalLogDetail(row.id);
detailOpen.value = true;
}
</script>
<template>
<section v-loading="loading" class="feature-page">
<div class="page-head inline">
<div>
<h2>检索日志</h2>
<p>
查看每条回答使用的知识目录Agent 决策搜索候选重排和章节回读
</p>
</div>
<div class="cleanup-controls">
<el-date-picker
v-model="cleanupBefore"
value-format="YYYY-MM-DD"
type="date"
placeholder="清理此日期前"
/><el-button type="danger" plain @click="cleanup">清理日志</el-button>
</div>
</div>
<section class="retention-panel">
<div>
<strong>定期清理</strong
><span>每日自动分批删除过期检索日志和关联候选明细</span>
</div>
<el-select v-model="retentionChoice" placeholder="保留时间"
><el-option label="永久保留" :value="-1" /><el-option
label="30天"
:value="30" /><el-option label="90天" :value="90" /><el-option
label="180天"
:value="180" /></el-select
><el-button type="primary" @click="saveRetention">保存规则</el-button
><small>最近执行{{ formatDateTime(retentionState?.lastRunAt, "尚未执行") }}</small>
</section>
<el-table :data="logs" stripe
><el-table-column prop="id" label="ID" width="80" /><el-table-column
prop="sessionId"
label="会话"
width="90"
/><el-table-column
prop="question"
label="用户问题"
min-width="280"
show-overflow-tooltip
/><el-table-column label="调用知识" width="100"
><template #default="{ row }"
><el-tag :type="row.knowledgeCalled ? 'success' : 'info'">{{
row.knowledgeCalled ? "已调用" : "未调用"
}}</el-tag></template
></el-table-column
><el-table-column
prop="selectedKnowledgeIds"
label="知识库"
width="120"
/><el-table-column
prop="status"
label="状态"
width="100"
/><el-table-column
prop="toolCostMs"
label="工具耗时"
width="110"
/><el-table-column
prop="totalCostMs"
label="总耗时"
width="100"
/><el-table-column label="时间" width="180"
><template #default="{ row }">{{ formatDateTime(row.createdAt) }}</template
></el-table-column><el-table-column label="操作" width="90" fixed="right"
><template #default="{ row }"
><el-button link type="primary" @click="openDetail(row)"
>详情</el-button
></template
></el-table-column
></el-table
>
<AdminPagination
:page="pager.page"
:page-size="pager.pageSize"
:total="pager.total"
@change="loadLogs"
/>
<el-drawer v-model="detailOpen" size="820px" title="检索日志详情"
><template v-if="selected"
><dl class="knowledge-meta">
<dt>问题</dt>
<dd>{{ selected.question }}</dd>
<dt>安全规则</dt>
<dd>{{ selected.safetyRuleVersion }}</dd>
<dt>目录哈希</dt>
<dd>{{ selected.catalogHash }}</dd>
<dt>最终章节</dt>
<dd>{{ selected.finalSectionIds || "未读取知识章节" }}</dd>
</dl>
<h3>本轮 KnowledgeList</h3>
<pre class="trace-json">{{
JSON.stringify(selected.catalogSnapshot, null, 2)
}}</pre>
<h3>Agent 工具轨迹</h3>
<pre class="trace-json">{{
JSON.stringify(selected.toolTrace, null, 2)
}}</pre>
<h3>候选切片与取舍</h3>
<el-table :data="selected.candidates"
><el-table-column
prop="knowledgeId"
label="知识库"
width="90" /><el-table-column
prop="knowledgeName"
label="名称"
min-width="150"
show-overflow-tooltip /><el-table-column
prop="knowledgeTypeLabel"
label="类型优先级"
width="170" /><el-table-column
prop="versionId"
label="版本"
width="80" /><el-table-column
prop="sectionId"
label="章节"
width="80" /><el-table-column
prop="lexicalScore"
label="词法分"
width="100" /><el-table-column
prop="rerankScore"
label="重排分"
width="100" /><el-table-column label="采用" width="80"
><template #default="{ row }">{{
row.selected ? "是" : "否"
}}</template></el-table-column
><el-table-column
prop="discardReason"
label="舍弃原因"
min-width="180"
/></el-table>
<h3>最终回答</h3>
<pre class="trace-json">{{
selected.finalAnswer || "尚未生成回答"
}}</pre>
</template></el-drawer
>
</section>
</template>

View File

@@ -0,0 +1,854 @@
<script setup lang="ts">
import { ElMessage, ElMessageBox } from "element-plus";
import { computed, onMounted, reactive, ref } from "vue";
import { api } from "../services/api";
import type { EntitlementPlan, SsoAuditItem, SsoClientItem, SsoIdentityItem } from "../types/api";
import AdminPagination from "./AdminPagination.vue";
import { formatDateTime } from "../utils/dateTime";
const loading = ref(false);
const savingUrl = ref(false);
const clients = ref<SsoClientItem[]>([]);
const entitlementPlans = ref<EntitlementPlan[]>([]);
const userClientUrl = ref("");
const activeTab = ref("applications");
const clientDialogOpen = ref(false);
const editingClientId = ref<number | null>(null);
const secretDialogOpen = ref(false);
const revealedSecret = ref("");
const revealedAppId = ref("");
const identityItems = ref<SsoIdentityItem[]>([]);
const identityTotal = ref(0);
const identityPage = ref(1);
const identityPageSize = ref(10);
const identityKeyword = ref("");
const identityClientId = ref<number | undefined>();
const auditItems = ref<SsoAuditItem[]>([]);
const auditTotal = ref(0);
const auditPage = ref(1);
const auditPageSize = ref(10);
const auditClientId = ref<number | undefined>();
const auditStatus = ref("");
const clientForm = reactive({
appId: "",
name: "",
redirectUrisText: "",
allowAutoRegister: false,
defaultEntitlementPlanId: undefined as number | undefined,
status: 1,
});
const dialogTitle = computed(() => editingClientId.value ? "编辑接入应用" : "新增接入应用");
onMounted(loadOverview);
async function loadOverview() {
loading.value = true;
try {
const [config, clientList, plans] = await Promise.all([
api.ssoConfig(),
api.ssoClients(),
api.entitlementPlans(false),
]);
userClientUrl.value = config.userClientUrl;
clients.value = clientList;
entitlementPlans.value = plans;
} catch (error) {
showError(error, "应用接入配置加载失败");
} finally {
loading.value = false;
}
}
async function savePublicUrl() {
savingUrl.value = true;
try {
const result = await api.saveSsoConfig(userClientUrl.value.trim());
userClientUrl.value = result.userClientUrl;
ElMessage.success("用户端公网地址已保存");
} catch (error) {
showError(error, "用户端公网地址保存失败");
} finally {
savingUrl.value = false;
}
}
function openCreateDialog() {
editingClientId.value = null;
Object.assign(clientForm, {
appId: "",
name: "",
redirectUrisText: "",
allowAutoRegister: false,
defaultEntitlementPlanId: undefined,
status: 1,
});
clientDialogOpen.value = true;
}
function openEditDialog(client: SsoClientItem) {
editingClientId.value = client.id;
Object.assign(clientForm, {
appId: client.appId,
name: client.name,
redirectUrisText: client.redirectUris.join("\n"),
allowAutoRegister: client.allowAutoRegister,
defaultEntitlementPlanId: client.defaultEntitlementPlanId ?? undefined,
status: client.status,
});
clientDialogOpen.value = true;
}
async function saveClient() {
const appId = clientForm.appId.trim();
const name = clientForm.name.trim();
if (!appId || !name) {
ElMessage.warning("请填写应用ID和应用名称");
return;
}
if (clientForm.allowAutoRegister && !clientForm.defaultEntitlementPlanId) {
ElMessage.warning("允许自动注册时请选择默认权益版本");
return;
}
const redirectUris = clientForm.redirectUrisText
.split(/\r?\n/)
.map((item) => item.trim())
.filter(Boolean);
try {
if (editingClientId.value) {
await api.updateSsoClient(editingClientId.value, {
name,
redirectUris,
allowAutoRegister: clientForm.allowAutoRegister,
defaultEntitlementPlanId: clientForm.defaultEntitlementPlanId ?? null,
status: clientForm.status,
});
ElMessage.success("接入应用已更新");
} else {
const result = await api.createSsoClient({
appId,
name,
redirectUris,
allowAutoRegister: clientForm.allowAutoRegister,
defaultEntitlementPlanId: clientForm.defaultEntitlementPlanId ?? null,
status: clientForm.status,
});
revealedSecret.value = result.clientSecret || "";
revealedAppId.value = result.appId;
secretDialogOpen.value = true;
}
clientDialogOpen.value = false;
await loadOverview();
} catch (error) {
showError(error, "接入应用保存失败");
}
}
async function toggleClient(client: SsoClientItem) {
const targetStatus = client.status;
try {
await api.updateSsoClient(client.id, {
name: client.name,
redirectUris: client.redirectUris,
allowAutoRegister: client.allowAutoRegister,
defaultEntitlementPlanId: client.defaultEntitlementPlanId ?? null,
status: targetStatus,
});
ElMessage.success(targetStatus === 1 ? "应用已启用" : "应用已停用");
} catch (error) {
client.status = targetStatus === 1 ? 0 : 1;
showError(error, "应用状态更新失败");
}
}
async function rotateSecret(client: SsoClientItem) {
try {
await ElMessageBox.confirm(
`更新后,${client.name} 使用的旧密钥会立即失效。确定继续吗?`,
"更新应用密钥",
{ type: "warning", confirmButtonText: "确认更新", cancelButtonText: "取消" },
);
const result = await api.rotateSsoClientSecret(client.id);
revealedSecret.value = result.clientSecret;
revealedAppId.value = client.appId;
secretDialogOpen.value = true;
} catch (error) {
if (error === "cancel" || error === "close") return;
showError(error, "应用密钥更新失败");
}
}
async function copySecret() {
await navigator.clipboard.writeText(revealedSecret.value);
ElMessage.success("应用密钥已复制");
}
async function switchTab(tabName: string | number) {
activeTab.value = String(tabName);
if (activeTab.value === "identities") await loadIdentities();
if (activeTab.value === "audits") await loadAudits();
}
async function loadIdentities() {
loading.value = true;
try {
const page = await api.ssoIdentities({
clientId: identityClientId.value,
keyword: identityKeyword.value.trim(),
page: identityPage.value,
pageSize: identityPageSize.value,
});
identityItems.value = page.items;
identityTotal.value = page.total;
} catch (error) {
showError(error, "账号绑定加载失败");
} finally {
loading.value = false;
}
}
async function searchIdentities() {
identityPage.value = 1;
await loadIdentities();
}
async function changeIdentityPage(page: number, pageSize: number) {
identityPage.value = page;
identityPageSize.value = pageSize;
await loadIdentities();
}
async function unlinkIdentity(item: SsoIdentityItem) {
try {
await ElMessageBox.confirm(
`解除后,${item.userName} 下次从 ${item.clientName} 进入时需要重新核对手机号。`,
"解除账号绑定",
{ type: "warning", confirmButtonText: "解除绑定", cancelButtonText: "取消" },
);
await api.unlinkSsoIdentity(item.id);
ElMessage.success("账号绑定已解除");
await Promise.all([loadIdentities(), loadOverview()]);
} catch (error) {
if (error === "cancel" || error === "close") return;
showError(error, "解除账号绑定失败");
}
}
async function loadAudits() {
loading.value = true;
try {
const page = await api.ssoAudits({
clientId: auditClientId.value,
auditStatus: auditStatus.value,
page: auditPage.value,
pageSize: auditPageSize.value,
});
auditItems.value = page.items;
auditTotal.value = page.total;
} catch (error) {
showError(error, "登录审计加载失败");
} finally {
loading.value = false;
}
}
async function searchAudits() {
auditPage.value = 1;
await loadAudits();
}
async function changeAuditPage(page: number, pageSize: number) {
auditPage.value = page;
auditPageSize.value = pageSize;
await loadAudits();
}
function maskPhone(phone: string) {
return phone.replace(/^(\d{3})\d{4}(\d{4})$/, "$1****$2");
}
function showError(error: unknown, fallback: string) {
ElMessage.error(error instanceof Error ? error.message : fallback);
}
</script>
<template>
<section v-loading="loading" class="sso-page">
<div class="page-head inline">
<div>
<h2>应用接入</h2>
<p>让已在其他可信应用登录的学员免验证码进入千问千答并独立管理密钥账号绑定和登录审计</p>
</div>
<el-button type="primary" @click="openCreateDialog">新增接入应用</el-button>
</div>
<section class="sso-public-url">
<div>
<strong>用户端公网地址</strong>
<p>服务端会使用这个地址生成免登录入口例如 https://qa.example.com。</p>
</div>
<el-input v-model="userClientUrl" placeholder="https://qa.example.com" clearable />
<el-button :loading="savingUrl" @click="savePublicUrl">保存地址</el-button>
</section>
<el-tabs class="sso-tabs" :model-value="activeTab" @tab-change="switchTab">
<el-tab-pane label="接入应用" name="applications">
<div class="sso-table-shell">
<el-table :data="clients" stripe empty-text="还没有接入应用" table-layout="fixed" style="width: 100%">
<el-table-column label="应用" min-width="170">
<template #default="{ row }">
<div class="sso-app-cell">
<strong>{{ row.name }}</strong>
<span>{{ row.appId }}</span>
</div>
</template>
</el-table-column>
<el-table-column label="账号策略" min-width="170">
<template #default="{ row }">
<div class="sso-policy-cell">
<span v-if="row.allowAutoRegister">自动注册 · {{ row.defaultEntitlementPlanName || "未配置权益" }}</span>
<span v-else>仅已有学员</span>
<small>已绑定 {{ row.identityCount }} </small>
</div>
</template>
</el-table-column>
<el-table-column label="回跳地址" min-width="170" show-overflow-tooltip>
<template #default="{ row }">{{ row.redirectUris.join("、") || "未配置" }}</template>
</el-table-column>
<el-table-column label="运行状态" width="148">
<template #default="{ row }">
<div class="sso-status-cell">
<div><el-switch v-model="row.status" :active-value="1" :inactive-value="0" @change="toggleClient(row)" /><span>{{ row.status === 1 ? "已启用" : "已停用" }}</span></div>
<small>最近使用{{ formatDateTime(row.lastUsedAt) }}</small>
</div>
</template>
</el-table-column>
<el-table-column label="操作" width="150" align="right">
<template #default="{ row }">
<div class="sso-row-actions">
<el-button size="small" @click="openEditDialog(row)">编辑</el-button>
<el-button size="small" @click="rotateSecret(row)">更新密钥</el-button>
</div>
</template>
</el-table-column>
</el-table>
</div>
<div class="sso-app-cards">
<el-empty v-if="!clients.length" description="还没有接入应用" :image-size="72" />
<article v-for="client in clients" v-else :key="client.id" class="sso-app-card">
<div class="sso-app-card-head">
<div class="sso-app-cell"><strong>{{ client.name }}</strong><span>{{ client.appId }}</span></div>
<div class="sso-card-status"><el-switch v-model="client.status" :active-value="1" :inactive-value="0" @change="toggleClient(client)" /><span>{{ client.status === 1 ? "已启用" : "已停用" }}</span></div>
</div>
<dl>
<div><dt>账号策略</dt><dd>{{ client.allowAutoRegister ? `自动注册 · ${client.defaultEntitlementPlanName || "未配置权益"}` : "仅已有学员" }}已绑定 {{ client.identityCount }} </dd></div>
<div><dt>回跳地址</dt><dd>{{ client.redirectUris.join("、") || "未配置" }}</dd></div>
<div><dt>最近使用</dt><dd>{{ formatDateTime(client.lastUsedAt) }}</dd></div>
</dl>
<div class="sso-card-actions"><el-button size="small" @click="openEditDialog(client)">编辑</el-button><el-button size="small" @click="rotateSecret(client)">更新密钥</el-button></div>
</article>
</div>
</el-tab-pane>
<el-tab-pane label="账号绑定" name="identities">
<div class="sso-filters">
<el-select v-model="identityClientId" clearable placeholder="全部应用">
<el-option v-for="client in clients" :key="client.id" :label="client.name" :value="client.id" />
</el-select>
<el-input v-model="identityKeyword" clearable placeholder="搜索姓名、手机号或外部用户ID" @keyup.enter="searchIdentities" />
<el-button type="primary" @click="searchIdentities">查询</el-button>
</div>
<el-table :data="identityItems" stripe empty-text="暂无账号绑定">
<el-table-column prop="clientName" label="来源应用" min-width="150" />
<el-table-column prop="userName" label="学员" min-width="120" />
<el-table-column label="手机号" width="140">
<template #default="{ row }">{{ maskPhone(row.phone) }}</template>
</el-table-column>
<el-table-column prop="externalUserId" label="外部用户ID" min-width="190" />
<el-table-column label="最近免登录" width="180">
<template #default="{ row }">{{ formatDateTime(row.lastLoginAt) }}</template>
</el-table-column>
<el-table-column label="操作" width="120" align="center">
<template #default="{ row }"><el-button size="small" type="danger" plain @click="unlinkIdentity(row)">解除绑定</el-button></template>
</el-table-column>
</el-table>
<AdminPagination :page="identityPage" :page-size="identityPageSize" :total="identityTotal" @change="changeIdentityPage" />
</el-tab-pane>
<el-tab-pane label="登录审计" name="audits">
<div class="sso-filters">
<el-select v-model="auditClientId" clearable placeholder="全部应用">
<el-option v-for="client in clients" :key="client.id" :label="client.name" :value="client.id" />
</el-select>
<el-select v-model="auditStatus" clearable placeholder="全部状态">
<el-option label="成功" value="SUCCESS" />
<el-option label="失败" value="FAILED" />
</el-select>
<el-button type="primary" @click="searchAudits">查询</el-button>
</div>
<el-table :data="auditItems" stripe empty-text="暂无登录审计">
<el-table-column prop="clientName" label="来源应用" min-width="150" />
<el-table-column prop="externalUserId" label="外部用户ID" min-width="190" />
<el-table-column label="环节" width="110">
<template #default="{ row }">{{ row.action === "ticket" ? "申请授权码" : "兑换登录态" }}</template>
</el-table-column>
<el-table-column label="状态" width="100" align="center">
<template #default="{ row }"><el-tag :type="row.status === 'SUCCESS' ? 'success' : 'danger'">{{ row.status === "SUCCESS" ? "成功" : "失败" }}</el-tag></template>
</el-table-column>
<el-table-column prop="errorMessage" label="失败原因" min-width="220" show-overflow-tooltip />
<el-table-column prop="ip" label="请求IP" width="140" />
<el-table-column label="时间" width="180"><template #default="{ row }">{{ formatDateTime(row.createdAt) }}</template></el-table-column>
</el-table>
<AdminPagination :page="auditPage" :page-size="auditPageSize" :total="auditTotal" @change="changeAuditPage" />
</el-tab-pane>
</el-tabs>
<el-dialog v-model="clientDialogOpen" class="sso-client-dialog" :title="dialogTitle" width="min(720px, calc(100vw - 32px))" destroy-on-close align-center>
<el-form class="sso-client-form" label-position="top">
<section class="sso-form-section">
<div class="sso-section-heading">
<strong>基本信息</strong>
<span>用于识别接入方应用 ID 创建后不可修改</span>
</div>
<div class="sso-form-grid">
<el-form-item label="应用名称"><el-input v-model="clientForm.name" placeholder="例如:学员服务 App" /></el-form-item>
<el-form-item label="应用 ID"><el-input v-model="clientForm.appId" :disabled="Boolean(editingClientId)" placeholder="例如student-app" /></el-form-item>
</div>
</section>
<section class="sso-form-section">
<div class="sso-section-heading">
<strong>登录后回跳</strong>
<span>每行一个完整 HTTPS 地址不需要回跳时可以留空</span>
</div>
<el-form-item class="sso-redirect-field" label="允许回跳地址">
<el-input v-model="clientForm.redirectUrisText" type="textarea" :rows="3" placeholder="https://student.example.com/home" />
</el-form-item>
</section>
<section class="sso-form-section">
<div class="sso-section-heading">
<strong>访问与账号策略</strong>
<span>控制应用是否可用以及首次进入时如何处理新学员</span>
</div>
<div class="sso-setting-list">
<div class="sso-setting-row">
<div><strong>启用应用</strong><span>停用后该应用将无法继续申请或使用免登录</span></div>
<el-switch v-model="clientForm.status" :active-value="1" :inactive-value="0" />
</div>
<div class="sso-setting-row">
<div><strong>自动注册新学员</strong><span>传入已验证手机号和姓名时可自动创建账号并分配权益</span></div>
<el-switch v-model="clientForm.allowAutoRegister" />
</div>
<div v-if="clientForm.allowAutoRegister" class="sso-entitlement-setting">
<el-form-item label="新学员默认权益">
<el-select v-model="clientForm.defaultEntitlementPlanId" placeholder="请选择权益版本" style="width: 100%">
<el-option v-for="plan in entitlementPlans" :key="plan.id" :label="plan.name" :value="plan.id" />
</el-select>
</el-form-item>
</div>
</div>
</section>
</el-form>
<template #footer>
<el-button @click="clientDialogOpen = false">取消</el-button>
<el-button type="primary" @click="saveClient">保存</el-button>
</template>
</el-dialog>
<el-dialog v-model="secretDialogOpen" title="请立即保存应用密钥" width="620px" :close-on-click-modal="false">
<el-alert title="密钥只在本次显示,关闭后无法再次查看。请保存到接入应用的服务端密钥管理中,不要放入前端代码。" type="warning" :closable="false" show-icon />
<div class="sso-secret-block">
<label>应用ID</label><code>{{ revealedAppId }}</code>
<label>应用密钥</label><code>{{ revealedSecret }}</code>
</div>
<template #footer>
<el-button @click="copySecret">复制密钥</el-button>
<el-button type="primary" @click="secretDialogOpen = false">我已保存</el-button>
</template>
</el-dialog>
</section>
</template>
<style scoped>
.sso-page {
container-type: inline-size;
display: grid;
gap: 20px;
width: 100%;
min-width: 0;
max-width: 100%;
overflow: hidden;
}
.sso-public-url {
display: grid;
grid-template-columns: minmax(230px, 1fr) minmax(280px, 1.2fr) auto;
gap: 18px;
align-items: center;
min-width: 0;
padding: 20px 22px;
border: 1px solid #dce8e4;
border-radius: 16px;
background: #fff;
}
.sso-public-url > *,
.sso-tabs,
.sso-table-shell {
min-width: 0;
}
.sso-public-url p {
margin: 6px 0 0;
color: #71817d;
font-size: 13px;
}
:deep(.sso-tabs .el-tabs__content),
:deep(.sso-tabs .el-tab-pane) {
width: 100%;
min-width: 0;
max-width: 100%;
}
.sso-table-shell {
width: 100%;
max-width: 100%;
overflow: hidden;
border: 1px solid #e1eae7;
border-radius: 14px;
background: #fff;
}
.sso-app-cell,
.sso-policy-cell,
.sso-status-cell {
display: grid;
gap: 5px;
min-width: 0;
}
.sso-app-cell strong,
.sso-policy-cell span {
overflow: hidden;
color: #253b35;
text-overflow: ellipsis;
white-space: nowrap;
}
.sso-app-cell span,
.sso-policy-cell small,
.sso-status-cell small {
overflow: hidden;
color: #80908b;
font-size: 12px;
text-overflow: ellipsis;
white-space: nowrap;
}
.sso-status-cell > div,
.sso-card-status {
display: flex;
gap: 8px;
align-items: center;
color: #4f625c;
font-size: 13px;
}
.sso-row-actions,
.sso-card-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
white-space: nowrap;
}
.sso-row-actions :deep(.el-button + .el-button),
.sso-card-actions :deep(.el-button + .el-button) {
margin-left: 0;
}
.sso-app-cards {
display: none;
}
.sso-filters {
display: flex;
gap: 12px;
margin-bottom: 16px;
}
.sso-filters .el-select {
width: 190px;
}
.sso-filters .el-input {
max-width: 360px;
}
.sso-client-form {
display: grid;
gap: 16px;
}
.sso-form-section {
min-width: 0;
padding: 18px 20px;
border: 1px solid #e0e9e6;
border-radius: 14px;
background: #fbfcfc;
}
.sso-section-heading {
display: grid;
gap: 4px;
margin-bottom: 16px;
}
.sso-section-heading strong {
color: #203a33;
font-size: 15px;
}
.sso-section-heading span {
color: #7a8b86;
font-size: 13px;
line-height: 1.55;
}
.sso-form-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 16px;
}
.sso-client-form :deep(.el-form-item) {
min-width: 0;
margin-bottom: 0;
}
.sso-client-form :deep(.el-form-item__label) {
height: auto;
padding-bottom: 7px;
color: #465a54;
font-weight: 600;
line-height: 1.4;
}
.sso-client-form :deep(.el-input__wrapper) {
min-height: 42px;
}
.sso-redirect-field :deep(.el-textarea__inner) {
min-height: 82px !important;
padding: 11px 13px;
line-height: 1.55;
}
.sso-setting-list {
overflow: hidden;
border: 1px solid #e0e9e6;
border-radius: 12px;
background: #fff;
}
.sso-setting-row {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 24px;
align-items: center;
min-height: 72px;
padding: 14px 16px;
}
.sso-setting-row + .sso-setting-row,
.sso-entitlement-setting {
border-top: 1px solid #e7eeec;
}
.sso-setting-row > div {
display: grid;
gap: 5px;
min-width: 0;
}
.sso-setting-row strong {
color: #324942;
font-size: 14px;
}
.sso-setting-row span {
color: #7a8b86;
font-size: 13px;
line-height: 1.5;
}
.sso-entitlement-setting {
padding: 14px 16px 16px;
background: #f5faf8;
}
:deep(.sso-client-dialog) {
max-height: calc(100vh - 32px);
overflow: hidden;
border-radius: 18px;
}
:deep(.sso-client-dialog .el-dialog__header) {
margin-right: 0;
padding: 22px 26px 17px;
border-bottom: 1px solid #e7eeec;
}
:deep(.sso-client-dialog .el-dialog__title) {
color: #1f3932;
font-size: 21px;
font-weight: 700;
}
:deep(.sso-client-dialog .el-dialog__body) {
max-height: calc(100vh - 178px);
overflow-y: auto;
padding: 20px 26px;
}
:deep(.sso-client-dialog .el-dialog__footer) {
padding: 16px 26px 20px;
border-top: 1px solid #e7eeec;
}
.sso-secret-block {
display: grid;
grid-template-columns: 90px minmax(0, 1fr);
gap: 12px 16px;
align-items: start;
margin-top: 20px;
}
.sso-secret-block label {
color: #6c7d79;
}
.sso-secret-block code {
padding: 10px 12px;
overflow-wrap: anywhere;
border-radius: 8px;
background: #f3f7f5;
color: #173d34;
}
@container (max-width: 940px) {
.sso-public-url {
grid-template-columns: minmax(0, 1fr) auto;
}
.sso-public-url > div {
grid-column: 1 / -1;
}
.sso-table-shell {
display: none;
}
.sso-app-cards {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 14px;
}
.sso-app-cards > .el-empty {
grid-column: 1 / -1;
}
.sso-app-card {
display: grid;
gap: 16px;
min-width: 0;
padding: 18px;
border: 1px solid #dfe9e6;
border-radius: 14px;
background: #fff;
}
.sso-app-card-head {
display: flex;
gap: 16px;
align-items: flex-start;
justify-content: space-between;
}
.sso-card-status {
flex: 0 0 auto;
}
.sso-app-card dl {
display: grid;
gap: 12px;
margin: 0;
}
.sso-app-card dl > div {
display: grid;
gap: 4px;
min-width: 0;
}
.sso-app-card dt {
color: #82918d;
font-size: 12px;
}
.sso-app-card dd {
margin: 0;
overflow-wrap: anywhere;
color: #41554f;
font-size: 13px;
line-height: 1.55;
}
}
@container (max-width: 660px) {
.sso-app-cards,
.sso-public-url {
grid-template-columns: minmax(0, 1fr);
}
.sso-public-url > div {
grid-column: auto;
}
.sso-public-url .el-button {
width: 100%;
}
.sso-filters {
flex-wrap: wrap;
}
.sso-filters .el-select,
.sso-filters .el-input {
width: 100%;
max-width: none;
}
}
@media (max-width: 760px) {
.sso-form-grid {
grid-template-columns: 1fr;
}
.sso-form-section {
padding: 16px;
}
:deep(.sso-client-dialog .el-dialog__header),
:deep(.sso-client-dialog .el-dialog__body),
:deep(.sso-client-dialog .el-dialog__footer) {
padding-right: 18px;
padding-left: 18px;
}
}
</style>

View File

@@ -0,0 +1,74 @@
<script setup lang="ts">
import MarkdownIt from "markdown-it";
import { computed } from "vue";
const props = defineProps<{
content: string;
reasoning?: string;
showReasoning?: boolean;
streaming?: boolean;
}>();
const markdown = new MarkdownIt({ breaks: true, html: false, linkify: true });
const parsed = computed(() => splitReasoning(props.content));
const answer = computed(() => parsed.value.answer.replace(/^\s+/, ""));
const reasoning = computed(() => props.reasoning || parsed.value.reasoning);
const renderedContent = computed(() => answer.value.trim() ? markdown.render(normalizeStandaloneHeadings(answer.value)) : "");
function normalizeStandaloneHeadings(content: string) {
return content.replace(/^([ \t]*)\*\*(.+)\*\*[ \t]*$/gm, (line, indent: string, title: string) => {
const normalizedTitle = title.trim();
if (!normalizedTitle || normalizedTitle.length > 48 || normalizedTitle.includes("**")) return line;
return `${indent}### ${normalizedTitle}`;
});
}
function splitReasoning(content: string) {
const lower = content.toLowerCase();
let answer = "";
let reasoning = "";
let cursor = 0;
let depth = 0;
while (cursor < content.length) {
const tagStart = content.indexOf("<", cursor);
if (tagStart === -1) {
if (depth) reasoning += content.slice(cursor);
else answer += content.slice(cursor);
break;
}
const text = content.slice(cursor, tagStart);
if (depth) reasoning += text;
else answer += text;
const tail = lower.slice(tagStart);
const openTag = tail.match(/^<think(?:\s[^>]*)?>/);
if (openTag) {
depth += 1;
cursor = tagStart + openTag[0].length;
continue;
}
const closeTag = tail.match(/^<\/think\s*>/);
if (closeTag) {
depth = Math.max(0, depth - 1);
cursor = tagStart + closeTag[0].length;
continue;
}
if ("<think".startsWith(tail) || "</think>".startsWith(tail)) break;
if (depth) reasoning += "<";
else answer += "<";
cursor = tagStart + 1;
}
return { answer, reasoning };
}
</script>
<template>
<details v-if="showReasoning && reasoning.trim()" class="agent-reasoning-panel" :open="streaming">
<summary>思考过程</summary>
<div class="agent-reasoning-content">{{ reasoning }}</div>
</details>
<div v-if="renderedContent" class="agent-preview-markdown" v-html="renderedContent"></div>
<div v-else-if="streaming" class="agent-preview-generation-state">
<span></span><span></span><span></span>
思考中
</div>
</template>

View File

@@ -0,0 +1,887 @@
<script setup lang="ts">
import { Hide, View } from "@element-plus/icons-vue";
import { ElMessage } from "element-plus";
import { computed, reactive, ref } from "vue";
import { api } from "../services/api";
import {
systemSettingSections,
type SystemSettingDefinition,
type SystemSettingValue,
} from "../config/systemSettings";
interface SettingGroupBlueprint {
title: string;
description: string;
keys: string[];
}
interface SettingGroup extends Omit<SettingGroupBlueprint, "keys"> {
settings: SystemSettingDefinition[];
}
const props = defineProps<{
values: Record<string, SystemSettingValue>;
saving: boolean;
dirty: boolean;
}>();
const emit = defineEmits<{
save: [];
update: [key: string, value: SystemSettingValue];
}>();
const groupBlueprints: Record<string, SettingGroupBlueprint[]> = {
用户与登录: [
{
title: "学员默认配置",
description: "新学员创建、导入时采用的默认资料和额度。",
keys: ["daily_chat_limit", "default_user_name_prefix"],
},
{
title: "登录与验证码",
description: "控制登录状态和验证码的有效时间。",
keys: ["access_token_expire_minutes", "sms_code_expire_minutes"],
},
{
title: "短信服务",
description: "在模拟短信与阿里云短信之间切换,并维护短信通道参数。",
keys: [
"mock_sms_enabled",
"mock_sms_code",
"aliyun_sms_access_key_id",
"aliyun_sms_access_key_secret",
"aliyun_sms_sign_name",
"aliyun_sms_template_code",
"aliyun_sms_template_param_key",
"aliyun_sms_endpoint",
],
},
],
"AI 问答": [
{
title: "回答体验",
description: "控制引用展示和模型请求的基本行为。",
keys: [
"ai_timeout_seconds",
"mock_model_enabled",
"show_reference_sources",
],
},
{
title: "对话记忆与实修回顾",
description: "控制会话记忆,以及成功问答达到多少轮后开始整理实修回顾资料。",
keys: ["chat_context_message_count", "practice_review_auto_settle_successful_rounds"],
},
{
title: "模型分流",
description: "决定正式问答在不同问题类型下如何选择模型。",
keys: ["chat_model_routing_mode"],
},
{
title: "并发与排队",
description: "控制集中提问时的执行上限、等待容量和超时保护。",
keys: [
"chat_max_active_requests",
"chat_max_queue_size",
"chat_queue_timeout_seconds",
"chat_active_lease_seconds",
],
},
{
title: "语音输入",
description: "控制用户端麦克风入口、录音上限和独立的阿里云语音凭证。",
keys: [
"voice_input_enabled",
"voice_max_duration_seconds",
"aliyun_nls_app_key",
"aliyun_nls_access_key_id",
"aliyun_nls_access_key_secret",
"aliyun_nls_endpoint",
],
},
],
外部服务: [
{
title: "飞书接入",
description: "配置飞书知识库直读和独立检索服务。",
keys: ["feishu_search_url", "feishu_app_id", "feishu_app_secret"],
},
{
title: "稳定性策略",
description: "控制飞书接口慢响应和临时失败时的处理方式。",
keys: ["feishu_timeout_seconds", "feishu_retry_count"],
},
],
系统开关: [
{
title: "前端备案信息",
description: "维护登录页和用户主页面底部展示的备案内容及跳转地址。",
keys: ["site_filing_text", "site_filing_url"],
},
{
title: "审计与数据",
description: "控制后台审计信息和聊天记录导出能力。",
keys: ["show_operation_log_detail", "chat_export_enabled"],
},
{
title: "运行状态",
description: "用于系统维护期间的访问和提示策略。",
keys: ["maintenance_mode"],
},
],
};
const wideSettingKeys = new Set([
"chat_model_routing_mode",
"chat_max_active_requests",
"feishu_search_url",
"site_filing_text",
"site_filing_url",
]);
const activeSectionTitle = ref(systemSettingSections[0]?.title || "");
const revealedSecrets = reactive<Record<string, string>>({});
const revealingSecrets = reactive<Record<string, boolean>>({});
const activeSection = computed(
() =>
systemSettingSections.find(
(section) => section.title === activeSectionTitle.value,
) || systemSettingSections[0],
);
const activeGroups = computed<SettingGroup[]>(() => {
const section = activeSection.value;
if (!section) return [];
const byKey = new Map(section.settings.map((setting) => [setting.key, setting]));
const configuredKeys = new Set<string>();
const groups = (groupBlueprints[section.title] || []).map((group) => {
const settings = group.keys
.map((key) => byKey.get(key))
.filter((setting): setting is SystemSettingDefinition => Boolean(setting));
settings.forEach((setting) => configuredKeys.add(setting.key));
return { title: group.title, description: group.description, settings };
});
const ungrouped = section.settings.filter(
(setting) => !configuredKeys.has(setting.key),
);
if (ungrouped.length) {
groups.push({
title: "其他设置",
description: "尚未归入固定分组的新配置项。",
settings: ungrouped,
});
}
return groups.filter((group) => group.settings.length);
});
const statusItems = computed(() => [
{
label: "短信通道",
value: props.values.mock_sms_enabled ? "模拟验证码" : "阿里云短信",
tone: props.values.mock_sms_enabled ? "warning" : "success",
},
{
label: "AI 模式",
value: props.values.mock_model_enabled ? "模拟模型" : "真实模型",
tone: props.values.mock_model_enabled ? "warning" : "success",
},
{
label: "会话记忆",
value:
Number(props.values.chat_context_message_count) > 0
? `最近 ${props.values.chat_context_message_count}`
: "已关闭",
tone:
Number(props.values.chat_context_message_count) > 0 ? "neutral" : "warning",
},
{
label: "问答容量",
value: `${props.values.chat_max_active_requests || 0} 并发`,
tone: "neutral",
},
{
label: "语音输入",
value: props.values.voice_input_enabled
? `已开启 · ${props.values.voice_max_duration_seconds || 60}`
: "已关闭",
tone: props.values.voice_input_enabled ? "success" : "neutral",
},
]);
function updateSetting(key: string, value: unknown) {
if (value === undefined || value === null) return;
emit("update", key, value as SystemSettingValue);
}
async function toggleSecret(setting: SystemSettingDefinition) {
if (revealedSecrets[setting.key] !== undefined) {
delete revealedSecrets[setting.key];
return;
}
const currentValue = String(props.values[setting.key] ?? "");
if (currentValue && currentValue !== "******") {
revealedSecrets[setting.key] = currentValue;
return;
}
if (revealingSecrets[setting.key]) return;
revealingSecrets[setting.key] = true;
try {
const result = await api.revealConfigSecret(setting.key);
revealedSecrets[setting.key] = result.configValue;
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : "敏感配置读取失败");
} finally {
revealingSecrets[setting.key] = false;
}
}
function updatePasswordSetting(key: string, value: string) {
if (revealedSecrets[key] !== undefined) revealedSecrets[key] = value;
updateSetting(key, value);
}
</script>
<template>
<section class="system-config-page">
<header class="system-config-header">
<div>
<div class="system-config-title-line">
<h2>系统配置</h2>
<span :class="['save-state', { dirty }]">
{{ dirty ? "有未保存修改" : "配置已同步" }}
</span>
</div>
<p>集中维护学员登录AI 问答飞书接入和系统运行策略</p>
</div>
<div class="system-config-actions">
<small>保存后立即对后续请求生效</small>
<el-button
type="primary"
:loading="saving"
:disabled="!dirty"
@click="emit('save')"
>
保存修改
</el-button>
</div>
</header>
<section class="config-status-strip" aria-label="当前关键配置摘要">
<div
v-for="item in statusItems"
:key="item.label"
class="config-status-item"
>
<span>{{ item.label }}</span>
<strong :class="`tone-${item.tone}`">{{ item.value }}</strong>
</div>
</section>
<div class="config-workspace">
<nav class="config-section-nav" aria-label="系统配置分组">
<div class="config-nav-heading">
<strong>配置分类</strong>
<span>选择一个分类集中编辑</span>
</div>
<button
v-for="section in systemSettingSections"
:key="section.title"
type="button"
:class="{ active: activeSectionTitle === section.title }"
@click="activeSectionTitle = section.title"
>
<span>
<strong>{{ section.title }}</strong>
<small>{{ section.description }}</small>
</span>
<b>{{ section.settings.length }}</b>
</button>
</nav>
<section v-if="activeSection" class="config-editor-panel">
<header class="config-editor-heading">
<div>
<span>当前分类</span>
<h3>{{ activeSection.title }}</h3>
<p>{{ activeSection.description }}</p>
</div>
<strong>{{ activeSection.settings.length }} 项配置</strong>
</header>
<div class="config-groups">
<article
v-for="group in activeGroups"
:key="group.title"
class="config-group-card"
>
<header>
<h4>{{ group.title }}</h4>
<p>{{ group.description }}</p>
</header>
<div class="config-fields-grid">
<div
v-for="setting in group.settings"
:key="setting.key"
:class="[
'config-field',
`config-field-${setting.type}`,
{ wide: wideSettingKeys.has(setting.key) },
]"
>
<template v-if="setting.type === 'switch'">
<div class="config-switch-copy">
<strong>{{ setting.label }}</strong>
<small>{{ setting.description }}</small>
</div>
<el-switch
:model-value="Boolean(values[setting.key])"
@update:model-value="updateSetting(setting.key, $event)"
/>
</template>
<template v-else>
<label :for="`setting-${setting.key}`">
<strong>{{ setting.label }}</strong>
<span v-if="setting.type === 'password'">敏感配置</span>
</label>
<el-input-number
v-if="setting.type === 'number'"
:id="`setting-${setting.key}`"
:model-value="Number(values[setting.key])"
:min="setting.min"
:max="setting.max"
controls-position="right"
@update:model-value="updateSetting(setting.key, $event)"
/>
<el-input
v-else-if="setting.type === 'text'"
:id="`setting-${setting.key}`"
:model-value="String(values[setting.key] ?? '')"
:placeholder="setting.placeholder"
clearable
@update:model-value="updateSetting(setting.key, $event)"
/>
<el-input
v-else-if="setting.type === 'password'"
:id="`setting-${setting.key}`"
:model-value="revealedSecrets[setting.key] ?? String(values[setting.key] ?? '')"
:placeholder="setting.placeholder"
:type="revealedSecrets[setting.key] !== undefined ? 'text' : 'password'"
@update:model-value="updatePasswordSetting(setting.key, $event)"
>
<template #suffix>
<button
type="button"
class="secret-visibility-toggle"
:class="{ loading: revealingSecrets[setting.key] }"
:aria-label="revealedSecrets[setting.key] !== undefined ? '隐藏真实内容' : '查看真实内容'"
@click="toggleSecret(setting)"
>
<el-icon>
<Hide v-if="revealedSecrets[setting.key] !== undefined" />
<View v-else />
</el-icon>
</button>
</template>
</el-input>
<el-select
v-else-if="setting.type === 'select'"
:id="`setting-${setting.key}`"
:model-value="String(values[setting.key] ?? '')"
:placeholder="setting.placeholder"
@update:model-value="updateSetting(setting.key, $event)"
>
<el-option
v-for="option in setting.options || []"
:key="option.value"
:label="option.label"
:value="option.value"
/>
</el-select>
<small>{{ setting.description }}</small>
<small
v-if="setting.valueHint"
class="config-value-hint"
>
{{ setting.valueHint(values[setting.key]) }}
</small>
</template>
</div>
</div>
</article>
</div>
</section>
</div>
</section>
</template>
<style scoped>
.system-config-page {
display: grid;
gap: 18px;
min-width: 0;
padding-bottom: 24px;
}
.system-config-header {
display: flex;
gap: 24px;
align-items: flex-start;
justify-content: space-between;
}
.system-config-title-line {
display: flex;
flex-wrap: wrap;
gap: 12px;
align-items: center;
}
.system-config-title-line h2 {
margin: 0;
}
.system-config-header p {
margin: 7px 0 0;
color: #687a75;
line-height: 1.6;
}
.save-state {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 5px 9px;
border-radius: 999px;
background: #eaf3f0;
color: #55716a;
font-size: 12px;
font-weight: 600;
}
.save-state::before {
width: 6px;
height: 6px;
border-radius: 50%;
background: #62a38d;
content: "";
}
.save-state.dirty {
background: #fff4df;
color: #95651b;
}
.save-state.dirty::before {
background: #d79a37;
}
.system-config-actions {
display: flex;
flex: 0 0 auto;
gap: 12px;
align-items: center;
}
.system-config-actions small {
color: #83918d;
}
.system-config-actions .el-button {
min-width: 104px;
}
.config-status-strip {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
overflow: hidden;
border: 1px solid #dfe9e5;
border-radius: 14px;
background: #fff;
}
.config-status-item {
display: grid;
gap: 7px;
min-width: 0;
padding: 15px 18px;
}
.config-status-item + .config-status-item {
border-left: 1px solid #e6eeeb;
}
.config-status-item span {
color: #7a8b86;
font-size: 12px;
}
.config-status-item strong {
overflow: hidden;
color: #2d463f;
font-size: 14px;
text-overflow: ellipsis;
white-space: nowrap;
}
.config-status-item .tone-success {
color: #197653;
}
.config-status-item .tone-warning {
color: #a26919;
}
.config-workspace {
display: grid;
grid-template-columns: 248px minmax(0, 1fr);
gap: 18px;
align-items: start;
min-width: 0;
}
.config-section-nav,
.config-editor-panel {
border: 1px solid #dfe8e5;
border-radius: 16px;
background: #fff;
}
.config-section-nav {
position: sticky;
top: 18px;
display: grid;
gap: 6px;
padding: 14px;
}
.config-nav-heading {
display: grid;
gap: 4px;
padding: 5px 7px 11px;
}
.config-nav-heading strong {
color: #29423b;
font-size: 14px;
}
.config-nav-heading span {
color: #879691;
font-size: 12px;
}
.config-section-nav button {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 12px;
align-items: center;
width: 100%;
padding: 12px;
border: 1px solid transparent;
border-radius: 11px;
background: transparent;
color: #49605a;
text-align: left;
cursor: pointer;
transition: border-color 0.18s ease, background-color 0.18s ease;
}
.config-section-nav button:hover {
background: #f5f8f7;
}
.config-section-nav button.active {
border-color: #bcded2;
background: #edf7f3;
color: #176d53;
}
.config-section-nav button > span {
display: grid;
gap: 4px;
min-width: 0;
}
.config-section-nav button strong {
font-size: 14px;
}
.config-section-nav button small {
overflow: hidden;
color: #82908c;
font-size: 11px;
line-height: 1.45;
text-overflow: ellipsis;
white-space: nowrap;
}
.config-section-nav button b {
min-width: 26px;
padding: 4px 7px;
border-radius: 999px;
background: #eef3f1;
color: #71817c;
font-size: 11px;
text-align: center;
}
.config-section-nav button.active b {
background: #d9eee7;
color: #176d53;
}
.config-editor-panel {
min-width: 0;
overflow: hidden;
}
.config-editor-heading {
display: flex;
gap: 20px;
align-items: flex-start;
justify-content: space-between;
padding: 22px 24px;
border-bottom: 1px solid #e5ecea;
background: linear-gradient(135deg, #fbfdfc, #f4f9f7);
}
.config-editor-heading > div {
min-width: 0;
}
.config-editor-heading span {
color: #248064;
font-size: 12px;
font-weight: 700;
}
.config-editor-heading h3 {
margin: 6px 0 0;
color: #203b33;
font-size: 20px;
}
.config-editor-heading p {
margin: 7px 0 0;
color: #70827d;
font-size: 13px;
line-height: 1.6;
}
.config-editor-heading > strong {
flex: 0 0 auto;
padding: 6px 10px;
border-radius: 999px;
background: #e7f2ee;
color: #4d6d64;
font-size: 12px;
}
.config-groups {
display: grid;
gap: 16px;
padding: 20px;
}
.config-group-card {
overflow: hidden;
border: 1px solid #e1eae7;
border-radius: 13px;
background: #fff;
}
.config-group-card > header {
padding: 15px 18px;
border-bottom: 1px solid #e8efed;
background: #fafcfb;
}
.config-group-card h4 {
margin: 0;
color: #2c453e;
font-size: 15px;
}
.config-group-card header p {
margin: 5px 0 0;
color: #7b8c87;
font-size: 12px;
line-height: 1.55;
}
.config-fields-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0;
padding: 4px 18px 18px;
}
.config-field {
display: grid;
align-content: start;
gap: 8px;
min-width: 0;
padding: 15px 16px 2px 0;
}
.config-field:nth-child(even):not(.wide) {
padding-right: 0;
padding-left: 16px;
border-left: 1px solid #edf2f0;
}
.config-field.wide {
grid-column: 1 / -1;
padding-right: 0;
}
.config-field label {
display: flex;
gap: 8px;
align-items: center;
justify-content: space-between;
min-width: 0;
}
.config-field label strong,
.config-switch-copy strong {
color: #324841;
font-size: 14px;
}
.config-field label span {
flex: 0 0 auto;
padding: 3px 7px;
border-radius: 999px;
background: #fff1ed;
color: #a4503f;
font-size: 10px;
font-weight: 600;
}
.config-field > small,
.config-switch-copy small {
color: #7b8985;
font-size: 12px;
line-height: 1.55;
}
.config-field :deep(.el-input-number),
.config-field :deep(.el-input),
.config-field :deep(.el-select) {
width: 100%;
}
.secret-visibility-toggle {
display: inline-flex;
padding: 2px;
border: 0;
background: transparent;
cursor: pointer;
color: #66736f;
}
.secret-visibility-toggle.loading {
pointer-events: none;
opacity: 0.45;
}
.config-field :deep(.el-input__wrapper),
.config-field :deep(.el-select__wrapper),
.config-field :deep(.el-input-number) {
min-height: 40px;
}
.config-field-switch {
grid-template-columns: minmax(0, 1fr) auto;
gap: 20px;
align-items: center;
min-height: 70px;
padding-bottom: 12px;
}
.config-switch-copy {
display: grid;
gap: 5px;
min-width: 0;
}
.config-value-hint {
padding: 10px 12px;
border: 1px solid #cde4db;
border-radius: 9px;
background: #f2f9f6;
color: #2c6e58 !important;
}
@media (max-width: 1320px) {
.config-workspace {
grid-template-columns: 220px minmax(0, 1fr);
}
.config-status-strip {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.config-status-item:nth-child(3) {
border-left: 0;
border-top: 1px solid #e6eeeb;
}
.config-status-item:nth-child(4) {
border-top: 1px solid #e6eeeb;
}
}
@media (max-width: 1080px) {
.system-config-header {
flex-direction: column;
}
.system-config-actions {
width: 100%;
justify-content: space-between;
}
.config-workspace {
grid-template-columns: 1fr;
}
.config-section-nav {
position: static;
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.config-nav-heading {
grid-column: 1 / -1;
}
}
@media (max-width: 760px) {
.config-status-strip,
.config-section-nav,
.config-fields-grid {
grid-template-columns: 1fr;
}
.config-status-item + .config-status-item,
.config-status-item:nth-child(3),
.config-status-item:nth-child(4) {
border-top: 1px solid #e6eeeb;
border-left: 0;
}
.config-field,
.config-field:nth-child(even):not(.wide) {
padding-right: 0;
padding-left: 0;
border-left: 0;
}
}
</style>

View File

@@ -0,0 +1,3 @@
<template>
<div class="table-row-actions"><slot /></div>
</template>

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,440 @@
<script setup lang="ts">
import { ElMessage } from "element-plus";
import { ref, watch } from "vue";
import { api } from "../services/api";
import type { AdminUserDetail } from "../types/api";
import { formatDateTime } from "../utils/dateTime";
const props = defineProps<{ modelValue: boolean; userId: number | null }>();
defineEmits<{ "update:modelValue": [value: boolean] }>();
const detail = ref<AdminUserDetail | null>(null);
const loading = ref(false);
const reportGenerating = ref("");
const settlementRetrying = ref<number | null>(null);
watch(
() => [props.modelValue, props.userId] as const,
([open, userId]) => {
if (open && userId) void loadDetail(userId);
},
{ immediate: true },
);
async function loadDetail(userId = props.userId) {
if (!userId) return;
loading.value = true;
try {
detail.value = await api.userDetail(userId);
} finally {
loading.value = false;
}
}
async function generateReport(type: "weekly" | "monthly" | "stage") {
if (!detail.value) return;
reportGenerating.value = type;
try {
await api.generateUserReport(detail.value.user.id, { reportType: type });
await loadDetail(detail.value.user.id);
ElMessage.success("报告任务已进入后台队列,不会阻塞聊天服务");
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : "报告生成失败");
} finally {
reportGenerating.value = "";
}
}
async function retryTopicSettlement(topicId: number) {
if (!detail.value || settlementRetrying.value !== null) return;
settlementRetrying.value = topicId;
try {
await api.retryTopicSettlement(detail.value.user.id, topicId);
await loadDetail(detail.value.user.id);
ElMessage.success("实修回顾整理任务已重新进入后台队列");
} catch (error) {
ElMessage.error(
error instanceof Error ? error.message : "实修回顾整理重试失败",
);
} finally {
settlementRetrying.value = null;
}
}
function reportStatusLabel(status: string) {
return (
(
{
pending: "等待生成",
running: "生成中",
success: "已完成",
fallback: "已降级沉淀",
empty: "暂无沉淀",
failed: "生成失败",
} as Record<string, string>
)[status] || status
);
}
function formatMoney(value?: number | null, currency = "CNY") {
if (typeof value !== "number") return "-";
if (currency === "MIXED") return "多币种,见明细";
return `${currency || "CNY"} ${value.toFixed(6)}`;
}
function reportTriggerLabel(value: string) {
if (value.startsWith("lazy:")) return "学员活跃自动补齐";
if (value.startsWith("schedule:")) return "系统全局调度";
if (value.startsWith("dependency:")) return "月报依赖自动补周报";
if (value.startsWith("refresh:")) return "来源更新自动刷新";
return "管理员手动";
}
</script>
<template>
<el-drawer
:model-value="modelValue"
size="860px"
title="用户详情"
@update:model-value="$emit('update:modelValue', $event)"
>
<div v-loading="loading" class="user-detail-body">
<template v-if="detail">
<section class="user-detail-head">
<div>
<h3>
{{
detail.user.name || detail.user.nickname || detail.user.phone
}}
</h3>
<p>
{{ detail.user.phone }} ·
{{ detail.user.status === 1 ? "已启用" : "已停用" }}
</p>
</div>
<div class="user-detail-tags">
<el-tag
v-if="detail.metrics.isHighFrequency"
type="warning"
effect="plain"
>高频使用</el-tag
>
<el-tag v-if="detail.metrics.isInactive" type="info" effect="plain"
>近30天低活跃</el-tag
>
<el-tag type="success" effect="plain">{{
detail.user.entitlement?.name || "默认基础版"
}}</el-tag>
</div>
</section>
<section class="chat-summary user-metric-grid">
<div>
<span>近30天活跃</span
><strong>{{ detail.metrics.recentActiveDays }} </strong>
</div>
<div>
<span>总会话</span
><strong>{{ detail.metrics.totalSessions }}</strong>
</div>
<div>
<span>总消息</span
><strong>{{ detail.metrics.totalMessages }}</strong>
</div>
<div>
<span>AI 请求</span
><strong>{{ detail.metrics.aiRequestCount }}</strong>
</div>
<div>
<span> Token</span
><strong>{{ detail.metrics.totalToken }}</strong>
</div>
<div>
<span>估算成本</span
><strong>{{
formatMoney(
detail.metrics.estimatedCost,
detail.metrics.costCurrency,
)
}}</strong>
</div>
</section>
<section class="audit-detail-grid">
<div>
<span>权益有效期</span
><strong>{{
detail.user.entitlement?.expiredAt ||
detail.user.expiredAt ||
"长期有效"
}}</strong>
</div>
<div>
<span>最近登录</span
><strong>{{ formatDateTime(detail.user.lastLoginAt) }}</strong>
</div>
<div>
<span>最近发言</span
><strong>{{ formatDateTime(detail.metrics.lastMessageAt) }}</strong>
</div>
<div>
<span>生成记录</span
><strong
>求助卡 {{ detail.metrics.helpCardCount }} / 分享稿
{{ detail.metrics.shareDraftCount }}</strong
>
</div>
</section>
<h3 class="detail-title">近期实修回顾</h3>
<section v-if="detail.growthProfile" class="topic-summary-card">
<pre>{{ detail.growthProfile.reviewText || "暂无近期回顾" }}</pre>
<div
v-if="detail.growthProfile.currentFocus"
class="topic-summary-fields"
>
<p>
<strong>近期关注</strong>{{ detail.growthProfile.currentFocus }}
</p>
</div>
</section>
<el-empty
v-else
description="该用户暂无新版近期实修回顾,将在下一次对话回顾整理后生成"
:image-size="64"
/>
<section class="user-report-head">
<div>
<h3 class="detail-title">周期实修回顾</h3>
<p>
只基于本周期对话摘要回顾近期关注不评价成长结果同一周期重复生成会覆盖旧版本
</p>
</div>
<div class="user-report-actions">
<el-button size="small" @click="loadDetail()">刷新状态</el-button>
<el-button
size="small"
:loading="reportGenerating === 'weekly'"
@click="generateReport('weekly')"
>生成周报</el-button
>
<el-button
size="small"
:loading="reportGenerating === 'monthly'"
@click="generateReport('monthly')"
>生成月报</el-button
>
<el-button
size="small"
:loading="reportGenerating === 'stage'"
@click="generateReport('stage')"
>生成阶段回顾</el-button
>
</div>
</section>
<el-collapse
v-if="detail.recentReports.length"
class="topic-summary-collapse"
>
<el-collapse-item
v-for="report in detail.recentReports"
:key="report.id"
:title="`#${report.id} ${report.title} / ${reportStatusLabel(report.status)}`"
>
<section class="topic-summary-card">
<div class="topic-summary-meta">
<span>状态{{ reportStatusLabel(report.status) }}</span
><span>类型{{ report.reportTypeLabel }}</span>
<span
>周期{{ report.periodStart }}
{{ report.periodEnd }}</span
>
<span v-if="report.reportType === 'weekly'">来源聊天{{ report.sourceMessageIds.length }}</span>
<span v-else-if="report.reportType === 'monthly'">来源周报{{ report.sourceReportIds.length }}</span>
<template v-else>
<span>来源主题{{ report.sourceTopicIds.length }}</span>
<span>来源摘要{{ report.sourceSummaryIds.length }}</span>
</template>
<span>模型{{ report.modelName || "-" }}</span>
<span
>触发方式{{ reportTriggerLabel(report.generatedBy) }}</span
>
<span
>执行次数{{ report.attemptCount }}/{{
report.maxAttempts
}}</span
>
<span v-if="report.nextRunAt"
>下次执行{{ formatDateTime(report.nextRunAt) }}</span
><span>完成时间{{ formatDateTime(report.finishedAt) }}</span>
</div>
<p v-if="report.errorMessage" class="report-error">
最近错误{{ report.errorMessage }}
</p>
<pre v-if="report.content">{{ report.content }}</pre>
<p
v-else-if="
report.status === 'pending' || report.status === 'running'
"
class="report-pending"
>
后台正在生成报告,可以先关闭抽屉,稍后回来查看。
</p>
</section>
</el-collapse-item>
</el-collapse>
<el-empty v-else description="该用户暂无周期报告" :image-size="64" />
<h3 class="detail-title">最近对话片段</h3>
<el-collapse
v-if="detail.recentTopics.length"
class="topic-summary-collapse"
>
<el-collapse-item
v-for="topic in detail.recentTopics"
:key="topic.id"
:title="`#${topic.id} ${topic.title} / ${topic.status}`"
>
<section class="topic-summary-card">
<div class="topic-summary-meta">
<span>核心问题:{{ topic.coreQuestion }}</span
><span>消息数:{{ topic.messageCount }}</span>
<span
>Token{{ topic.tokenInput }}/{{ topic.tokenOutput }}</span
>
<span>开始:{{ formatDateTime(topic.startedAt) }}</span
><span>更新:{{ formatDateTime(topic.updatedAt) }}</span>
</div>
<template v-if="topic.summary">
<div class="topic-summary-meta">
<span
>回顾整理状态:{{
reportStatusLabel(topic.summary.status)
}}</span
>
<span
>执行次数:{{ topic.summary.attemptCount }}/{{
topic.summary.maxAttempts
}}</span
>
<span v-if="topic.summary.lastStartedAt"
>开始执行:{{ formatDateTime(topic.summary.lastStartedAt) }}</span
>
<span v-if="topic.summary.finishedAt"
>完成:{{ formatDateTime(topic.summary.finishedAt) }}</span
>
</div>
<p v-if="topic.summary.errorMessage" class="report-error">
最近错误:{{ topic.summary.errorMessage }}
</p>
<pre
v-if="
topic.summary.status === 'success' ||
topic.summary.status === 'fallback'
"
>{{ topic.summary.summary }}</pre
>
<p
v-else-if="
topic.summary.status === 'pending' ||
topic.summary.status === 'running'
"
class="report-pending"
>
主题已结束,摘要和近期实修回顾正在后台生成。
</p>
<div
v-if="
topic.summary.status === 'success' ||
topic.summary.status === 'fallback'
"
class="topic-summary-fields"
>
<p v-if="topic.summary.currentFocus">
<strong>本次关注</strong>{{ topic.summary.currentFocus }}
</p>
<p v-if="topic.summary.nextObservation">
<strong>可以继续留意</strong
>{{ topic.summary.nextObservation }}
</p>
</div>
<el-button
v-if="topic.summary.status === 'failed'"
size="small"
type="primary"
:loading="settlementRetrying === topic.id"
@click="retryTopicSettlement(topic.id)"
>重新沉淀</el-button
>
</template>
<el-empty
v-else
description="该主题尚未沉淀摘要"
:image-size="60"
/>
</section>
</el-collapse-item>
</el-collapse>
<el-empty v-else description="该用户暂无主题记录" :image-size="64" />
<h3 class="detail-title">
最近老师求助卡({{ detail.recentHelpCards.length }}
</h3>
<el-collapse
v-if="detail.recentHelpCards.length"
class="topic-summary-collapse"
>
<el-collapse-item
v-for="card in detail.recentHelpCards"
:key="card.id"
:title="`#${card.id} / 主题 #${card.topicSessionId} / ${card.copied ? '用户已复制' : '未复制'}`"
>
<section class="topic-summary-card">
<div class="topic-summary-meta">
<span>生成:{{ formatDateTime(card.createdAt) }}</span
><span>复制:{{ formatDateTime(card.copiedAt) }}</span
><span>来源:{{ card.source }}</span>
</div>
<pre>{{ card.content }}</pre>
</section>
</el-collapse-item>
</el-collapse>
<el-empty
v-else
description="该用户暂无求助卡生成记录"
:image-size="64"
/>
<h3 class="detail-title">
最近班级分享稿({{ detail.recentShareDrafts.length }}
</h3>
<el-collapse
v-if="detail.recentShareDrafts.length"
class="topic-summary-collapse"
>
<el-collapse-item
v-for="draft in detail.recentShareDrafts"
:key="draft.id"
:title="`#${draft.id} / 主题 #${draft.topicSessionId} / ${draft.copied ? '用户已复制' : '未复制'}`"
>
<section class="topic-summary-card">
<div class="topic-summary-meta">
<span>生成:{{ formatDateTime(draft.createdAt) }}</span
><span>复制:{{ formatDateTime(draft.copiedAt) }}</span
><span>来源:{{ draft.source }}</span>
</div>
<pre>{{ draft.content }}</pre>
</section>
</el-collapse-item>
</el-collapse>
<el-empty
v-else
description="该用户暂无分享稿生成记录"
:image-size="64"
/>
</template>
</div>
</el-drawer>
</template>

View File

@@ -0,0 +1,60 @@
<script setup lang="ts">
import { computed, reactive, watch } from "vue";
import type { AdminUser } from "../types/api";
const props = defineProps<{
modelValue: boolean;
users: AdminUser[];
submitting: boolean;
}>();
const emit = defineEmits<{
"update:modelValue": [value: boolean];
submit: [payload: { extensionDays: number; remark: string }];
}>();
const form = reactive({ extensionDays: 30, remark: "" });
const title = computed(() => props.users.length > 1 ? `批量续期(${props.users.length} 人)` : "用户权益续期");
const userNames = computed(() => props.users.map((user) => user.name || user.phone).join("、"));
watch(() => props.modelValue, (open) => {
if (open) Object.assign(form, { extensionDays: 30, remark: "" });
});
</script>
<template>
<el-dialog
:model-value="modelValue"
:title="title"
width="min(520px, calc(100vw - 32px))"
destroy-on-close
@update:model-value="$emit('update:modelValue', $event)"
>
<div class="renew-summary">
<span>续期对象</span>
<strong>{{ userNames }}</strong>
<small>未到期权益从原到期日顺延已到期权益从当前时间重新生效长期有效或未分配专属权益的用户不会被误续期</small>
</div>
<el-form label-position="top" @submit.prevent>
<el-form-item label="延长天数">
<el-input-number v-model="form.extensionDays" :min="1" :max="3650" controls-position="right" />
</el-form-item>
<el-form-item label="续期备注(可选)">
<el-input v-model="form.remark" maxlength="255" show-word-limit placeholder="例如:线下续费确认、活动补偿" />
</el-form-item>
</el-form>
<template #footer>
<el-button :disabled="submitting" @click="$emit('update:modelValue', false)">取消</el-button>
<el-button type="primary" :loading="submitting" @click="emit('submit', form)">确认续期</el-button>
</template>
</el-dialog>
</template>
<style scoped>
.renew-summary { display: grid; gap: 6px; margin-bottom: 18px; padding: 14px; border-radius: 10px; background: #f4f8f6; }
.renew-summary span, .renew-summary small { color: #6d7e78; font-size: 12px; }
.renew-summary strong { color: #25483e; line-height: 1.55; }
.renew-summary small { line-height: 1.6; }
.el-input-number { width: 100%; }
</style>

View File

@@ -0,0 +1,458 @@
<script setup lang="ts">
import { ElMessage } from "element-plus";
import { onMounted, reactive, ref } from "vue";
import { api } from "../services/api";
import type {
AdminUser,
EntitlementPlan,
UserImportResult,
} from "../types/api";
import AdminPagination from "./AdminPagination.vue";
import TableRowActions from "./TableRowActions.vue";
import UserDetailDrawer from "./UserDetailDrawer.vue";
import UserEntitlementRenewDialog from "./UserEntitlementRenewDialog.vue";
import { formatDate, formatDateTime, toUtcIsoFromBusinessDate } from "../utils/dateTime";
const loading = ref(false);
const users = ref<AdminUser[]>([]);
const entitlementPlans = ref<EntitlementPlan[]>([]);
const pager = reactive({ page: 1, pageSize: 20, total: 0 });
const keyword = ref("");
const filters = reactive({
planId: undefined as number | undefined,
status: "all",
});
const selectedUsers = ref<AdminUser[]>([]);
const renewDialogOpen = ref(false);
const renewTargets = ref<AdminUser[]>([]);
const renewSubmitting = ref(false);
const detailOpen = ref(false);
const detailUserId = ref<number | null>(null);
const studentImportFile = ref<File | null>(null);
const studentImportInput = ref<HTMLInputElement | null>(null);
const studentImportResult = ref<UserImportResult | null>(null);
const studentForm = reactive({
phone: "",
name: "",
nickname: "",
status: 1,
dailyChatLimit: 100,
expiredAt: "",
});
onMounted(async () => {
loading.value = true;
try {
await Promise.all([loadEntitlementPlans(), loadUsers()]);
} finally {
loading.value = false;
}
});
async function loadUsers(page = 1, pageSize = pager.pageSize) {
const result = await api.users({
keyword: keyword.value,
planId: filters.planId,
entitlementStatus: filters.status,
page,
pageSize,
});
users.value = result.items;
selectedUsers.value = [];
Object.assign(pager, {
page: result.page,
pageSize: result.pageSize,
total: result.total,
});
}
async function reloadCurrentPage() {
await loadUsers(pager.page, pager.pageSize);
}
async function loadEntitlementPlans() {
entitlementPlans.value = await api.entitlementPlans(true);
}
function resetFilters() {
keyword.value = "";
filters.planId = undefined;
filters.status = "all";
void loadUsers(1);
}
function handleSelectionChange(rows: AdminUser[]) {
selectedUsers.value = rows;
}
function openRenewDialog(targets: AdminUser[]) {
if (!targets.length) return ElMessage.warning("请先选择需要续期的用户");
renewTargets.value = targets;
renewDialogOpen.value = true;
}
async function submitEntitlementRenew(payload: {
extensionDays: number;
remark: string;
}) {
if (!renewTargets.value.length || renewSubmitting.value) return;
renewSubmitting.value = true;
const idempotencyKey =
typeof crypto.randomUUID === "function"
? crypto.randomUUID()
: `renew-${Date.now()}-${Math.random().toString(36).slice(2)}`;
try {
if (renewTargets.value.length === 1) {
await api.renewUserEntitlement(renewTargets.value[0].id, {
...payload,
idempotencyKey,
});
ElMessage.success("权益续期成功");
} else {
const result = await api.batchRenewUserEntitlement({
userIds: renewTargets.value.map((user) => user.id),
...payload,
idempotencyKey,
});
if (result.failureCount) {
const failure = result.failed[0];
ElMessage.warning(
`成功 ${result.successCount} 人,失败 ${result.failureCount}${failure ? `${failure.name || failure.userId}${failure.reason}` : ""}`,
);
} else {
ElMessage.success(`已为 ${result.successCount} 位用户续期`);
}
}
renewDialogOpen.value = false;
await reloadCurrentPage();
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : "权益续期失败");
} finally {
renewSubmitting.value = false;
}
}
function entitlementStatusLabel(row: AdminUser) {
const status = row.entitlement?.lifecycleStatus;
if (status === "expired_fallback") return "已到期,已降级";
if (status === "expiring_7") return "7 天内到期";
if (status === "expiring_30") return "30 天内到期";
if (status === "long_term") return "长期有效";
if (status === "default") return "默认权益";
return "生效中";
}
function entitlementStatusType(row: AdminUser) {
const status = row.entitlement?.lifecycleStatus;
if (status === "expired_fallback") return "danger";
if (status === "expiring_7" || status === "expiring_30") return "warning";
if (status === "active" || status === "long_term") return "success";
return "info";
}
async function assignUserEntitlement(row: AdminUser, planId: number) {
row.entitlement = await api.assignUserEntitlement(row.id, { planId });
ElMessage.success("用户权益已更新");
}
function openUserDetail(row: AdminUser) {
detailUserId.value = row.id;
detailOpen.value = true;
}
async function saveUser(row: AdminUser) {
await api.updateUser(row.id, {
status: row.status,
dailyChatLimit: row.dailyChatLimit,
});
ElMessage.success("用户已更新");
await reloadCurrentPage();
}
async function createStudent() {
if (!studentForm.phone || !studentForm.name)
return ElMessage.error("请填写手机号和姓名");
await api.createUser({
phone: studentForm.phone,
name: studentForm.name,
nickname: studentForm.nickname || null,
status: studentForm.status,
dailyChatLimit: studentForm.dailyChatLimit,
expiredAt: toUtcIsoFromBusinessDate(studentForm.expiredAt, "end") ?? null,
});
ElMessage.success("学员已添加");
resetStudentForm();
await loadUsers(1);
}
function handleStudentImportFile(event: Event) {
studentImportFile.value =
(event.target as HTMLInputElement).files?.[0] ?? null;
}
async function importStudents() {
if (!studentImportFile.value) return ElMessage.error("请先选择 Excel 文件");
studentImportResult.value = await api.importUsersExcel(
studentImportFile.value,
);
ElMessage.success(
`导入完成:新增 ${studentImportResult.value.created},更新 ${studentImportResult.value.updated},失败 ${studentImportResult.value.failed}`,
);
studentImportFile.value = null;
if (studentImportInput.value) studentImportInput.value.value = "";
await loadUsers(1);
}
function resetStudentForm() {
Object.assign(studentForm, {
phone: "",
name: "",
nickname: "",
status: 1,
dailyChatLimit: 100,
expiredAt: "",
});
}
async function deleteUser(row: AdminUser) {
if (
!window.confirm(
`确认删除用户 ${row.phone} 吗?删除后前台将无法继续使用该账号。`,
)
)
return;
await api.deleteUser(row.id);
ElMessage.success("用户已删除");
await reloadCurrentPage();
}
</script>
<template>
<section v-loading="loading" class="feature-page">
<div class="page-head inline">
<div>
<h2>用户管理</h2>
<p>维护学员名单只有名单内启用学员可以登录用户端</p>
</div>
</div>
<section class="student-tools">
<div class="student-tool-panel">
<h3>手动添加学员</h3>
<div class="student-form-grid">
<el-input v-model="studentForm.phone" placeholder="手机号" />
<el-input v-model="studentForm.name" placeholder="姓名" />
<el-input v-model="studentForm.nickname" placeholder="昵称(可选)" />
<el-input-number v-model="studentForm.dailyChatLimit" :min="0" />
<label class="record-date-field student-expiry-field">
<span>有效期</span>
<input
v-model="studentForm.expiredAt"
type="date"
aria-label="学员有效期"
title="留空表示长期有效"
/>
</label>
<div class="student-status-field">
<span>启用</span>
<el-switch
v-model="studentForm.status"
:active-value="1"
:inactive-value="0"
aria-label="启用学员账号"
/>
</div>
</div>
<div class="actions compact">
<el-button type="primary" @click="createStudent">添加学员</el-button>
<el-button @click="resetStudentForm">清空</el-button>
</div>
</div>
<div class="student-tool-panel">
<h3>批量导入学员</h3>
<p class="tool-help">下载模板后填写学员信息和权益名称再上传 .xlsx 文件导入权益名称须与系统完全一致留空或未匹配时不分配专属权益</p>
<div class="student-file-import">
<input
ref="studentImportInput"
class="file-input"
type="file"
accept=".xlsx"
@change="handleStudentImportFile"
/>
<span class="file-name">{{
studentImportFile?.name || "未选择文件"
}}</span>
</div>
<div class="actions compact">
<el-button @click="api.downloadStudentTemplate()">下载模板</el-button>
<el-button type="primary" @click="importStudents"
>导入 Excel</el-button
>
<span v-if="studentImportResult" class="import-result"
>新增 {{ studentImportResult.created }}更新
{{ studentImportResult.updated }}失败
{{ studentImportResult.failed }}</span
>
</div>
<div
v-if="studentImportResult?.failures.length"
class="import-failures"
>
<span
v-for="failure in studentImportResult.failures.slice(0, 5)"
:key="`${failure.row}-${failure.phone}`"
> {{ failure.row }} {{ failure.phone || "-" }}{{
failure.reason
}}</span
>
</div>
</div>
</section>
<section class="user-list-toolbar">
<el-input
v-model="keyword"
placeholder="手机号或姓名"
clearable
@keyup.enter="loadUsers(1)"
/>
<el-select v-model="filters.planId" placeholder="全部权益版本" clearable>
<el-option
v-for="plan in entitlementPlans.filter((item) => item.status === 1)"
:key="plan.id"
:label="plan.name"
:value="plan.id"
/>
</el-select>
<el-select v-model="filters.status" aria-label="权益状态">
<el-option label="全部权益状态" value="all" /><el-option
label="生效中"
value="active"
/>
<el-option label="7 天内到期" value="expiring7" /><el-option
label="8-30 天内到期"
value="expiring30"
/>
<el-option label="已到期" value="expired" /><el-option
label="使用默认权益"
value="default"
/>
</el-select>
<el-button type="primary" @click="loadUsers(1)">筛选</el-button>
<el-button @click="resetFilters">重置</el-button>
<el-button
class="batch-renew-button"
:disabled="!selectedUsers.length"
@click="openRenewDialog(selectedUsers)"
>批量续期{{
selectedUsers.length ? `${selectedUsers.length}` : ""
}}</el-button
>
</section>
<el-table :data="users" stripe @selection-change="handleSelectionChange">
<el-table-column type="selection" width="48" /><el-table-column
prop="id"
label="ID"
width="80"
/>
<el-table-column
prop="phone"
label="手机号"
width="150"
/><el-table-column prop="name" label="姓名" width="140" />
<el-table-column prop="nickname" label="昵称" width="140" />
<el-table-column label="注册来源" width="130">
<template #default="{ row }">
<el-tag v-if="row.registrationSource === 'sso'" type="info">第三方接入</el-tag>
<el-tag v-else>直接学员</el-tag>
</template>
</el-table-column>
<el-table-column label="状态" width="130"
><template #default="{ row }"
><el-switch
v-model="row.status"
:active-value="1"
:inactive-value="0" /></template
></el-table-column>
<el-table-column label="每日额度" width="150"
><template #default="{ row }"
><el-input-number
v-model="row.dailyChatLimit"
:min="0"
size="small" /></template
></el-table-column>
<el-table-column prop="dailyChatUsed" label="已用" width="90" />
<el-table-column label="权益版本" min-width="300">
<template #default="{ row }">
<div class="user-entitlement-cell">
<el-select
:model-value="row.entitlement?.planId"
placeholder="默认基础版"
size="small"
@change="(planId: number) => assignUserEntitlement(row, planId)"
>
<el-option
v-for="plan in entitlementPlans.filter(
(item) => item.status === 1,
)"
:key="plan.id"
:label="plan.name"
:value="plan.id"
/>
</el-select>
<small
>{{ row.entitlement?.name || "默认基础版" }} ·
{{
row.entitlement?.lifecycleStatus === "expired_fallback"
? `原 ${row.entitlement?.previousPlanName || "权益"} ${formatDate(row.entitlement?.previousExpiredAt, "长期")} 到期`
: `有效至 ${formatDate(row.entitlement?.expiredAt, "长期")}`
}}</small
>
<div class="entitlement-meta-row">
<el-tag size="small" :type="entitlementStatusType(row)">{{
entitlementStatusLabel(row)
}}</el-tag>
</div>
</div>
</template>
</el-table-column>
<el-table-column label="最近登录" width="180"><template #default="{ row }">{{ formatDateTime(row.lastLoginAt) }}</template></el-table-column>
<el-table-column label="操作" width="210" fixed="right">
<template #default="{ row }">
<TableRowActions>
<el-button size="small" @click="openUserDetail(row)"
>详情</el-button
>
<el-button size="small" type="primary" @click="saveUser(row)"
>保存</el-button
>
<el-dropdown trigger="click"
><el-button size="small">更多</el-button
><template #dropdown
><el-dropdown-menu
><el-dropdown-item @click="openRenewDialog([row])"
>权益续期</el-dropdown-item
><el-dropdown-item @click="deleteUser(row)"
>删除用户</el-dropdown-item
></el-dropdown-menu
></template
></el-dropdown
>
</TableRowActions>
</template>
</el-table-column>
</el-table>
<AdminPagination
:page="pager.page"
:page-size="pager.pageSize"
:total="pager.total"
@change="loadUsers"
/>
<UserEntitlementRenewDialog
v-model="renewDialogOpen"
:users="renewTargets"
:submitting="renewSubmitting"
@submit="submitEntitlementRenew"
/>
<UserDetailDrawer v-model="detailOpen" :user-id="detailUserId" />
</section>
</template>

View File

@@ -0,0 +1,382 @@
export type SystemSettingValue = string | number | boolean;
type SystemSettingType = "number" | "switch" | "text" | "password" | "select";
interface SystemSettingOption {
label: string;
value: string;
}
export interface SystemSettingDefinition {
key: string;
label: string;
type: SystemSettingType;
defaultValue: SystemSettingValue;
min?: number;
max?: number;
placeholder?: string;
options?: SystemSettingOption[];
description: string;
valueHint?: (value: SystemSettingValue) => string;
}
export interface SystemSettingSection {
title: string;
description: string;
settings: SystemSettingDefinition[];
}
export const systemSettingSections: SystemSettingSection[] = [
{
title: "用户与登录",
description: "控制学员准入、默认资料、验证码和登录态有效时间。",
settings: [
{
key: "daily_chat_limit",
label: "默认每日聊天额度",
type: "number",
defaultValue: 100,
min: 0,
max: 100000,
description: "新建或导入学员未单独填写额度时使用。",
},
{
key: "access_token_expire_minutes",
label: "登录 Token 有效期(分钟)",
type: "number",
defaultValue: 43200,
min: 5,
max: 43200,
description: "用户端和管理端登录后的 Token 有效期。",
},
{
key: "sms_code_expire_minutes",
label: "验证码有效期(分钟)",
type: "number",
defaultValue: 5,
min: 1,
max: 60,
description: "短信验证码生成后的可使用时间。",
},
{
key: "mock_sms_enabled",
label: "使用 mock 短信",
type: "switch",
defaultValue: true,
description: "开发和演示环境可开启,生产环境应关闭并接入真实短信。",
},
{
key: "mock_sms_code",
label: "mock 短信验证码",
type: "text",
defaultValue: "123456",
placeholder: "例如 123456",
description: "mock 短信开启时使用的固定验证码。",
},
{
key: "aliyun_sms_access_key_id",
label: "阿里云 AccessKey ID",
type: "password",
defaultValue: "",
placeholder: "请输入 RAM 用户 AccessKey ID",
description: "关闭 mock 短信后用于调用阿里云短信服务。",
},
{
key: "aliyun_sms_access_key_secret",
label: "阿里云 AccessKey Secret",
type: "password",
defaultValue: "",
placeholder: "请输入 RAM 用户 AccessKey Secret",
description: "敏感配置,建议使用最小权限 RAM 用户。",
},
{
key: "aliyun_sms_sign_name",
label: "阿里云短信签名",
type: "text",
defaultValue: "",
placeholder: "例如 某某课堂",
description: "需与阿里云短信控制台审核通过的签名一致。",
},
{
key: "aliyun_sms_template_code",
label: "阿里云模板 Code",
type: "text",
defaultValue: "",
placeholder: "例如 SMS_123456789",
description: "验证码短信模板 Code。",
},
{
key: "aliyun_sms_template_param_key",
label: "验证码变量名",
type: "text",
defaultValue: "code",
placeholder: "默认 code",
description: "模板中验证码变量名,例如模板变量为 ${code} 时填写 code。",
},
{
key: "aliyun_sms_endpoint",
label: "阿里云短信 Endpoint",
type: "text",
defaultValue: "dysmsapi.aliyuncs.com",
placeholder: "dysmsapi.aliyuncs.com",
description: "一般保持默认值即可。",
},
{
key: "default_user_name_prefix",
label: "默认用户名前缀",
type: "text",
defaultValue: "用户",
placeholder: "例如 用户",
description: "系统需要生成默认名称时使用的前缀。",
},
],
},
{
title: "AI 问答",
description: "控制 Agent 调用、上下文和用户端回答展示策略。",
settings: [
{
key: "ai_timeout_seconds",
label: "AI 请求超时(秒)",
type: "number",
defaultValue: 60,
min: 5,
max: 300,
description: "真实模型请求超过该时间应判定为超时。",
},
{
key: "mock_model_enabled",
label: "使用 mock 大模型",
type: "switch",
defaultValue: true,
description: "开发和演示环境可开启,真实模型验收前应关闭。",
},
{
key: "chat_context_message_count",
label: "上下文消息数",
type: "number",
defaultValue: 10,
min: 0,
max: 100,
description: "当前会话带入模型的最近历史消息条数;更早内容会滚动摘要,设为 0 将关闭会话记忆。修改后下一次提问立即生效。",
},
{
key: "practice_review_auto_settle_successful_rounds",
label: "实修回顾开始整理轮数",
type: "number",
defaultValue: 2,
min: 1,
max: 100,
description: "同一段对话达到该数量的成功问答后,自动整理阶段摘要,供近期实修回顾使用;不影响每日问答次数。",
},
{
key: "show_reference_sources",
label: "用户端展示引用来源",
type: "switch",
defaultValue: true,
description: "后台始终记录引用;此项控制用户端是否展示。",
},
{
key: "chat_model_routing_mode",
label: "正式问答模型分流规则",
type: "select",
defaultValue: "fixed_only",
options: [
{ label: "关闭分流(全部使用主模型)", value: "off" },
{ label: "仅固定信息(推荐)", value: "fixed_only" },
{ label: "保守分流(固定信息 + 简单知识)", value: "conservative" },
],
description: "“简单知识”只覆盖短问题、明确知识问法且仅召回课程/问答/通用知识;涉及个人情况、情绪、关系、建议或混合固定信息时仍使用主模型。",
},
{
key: "chat_max_active_requests",
label: "问答最大并发数",
type: "number",
defaultValue: 30,
min: 1,
max: 1000,
description: "用户正式问答的全局执行上限Redis 可用时由所有后端实例共享,超过后进入排队。",
valueHint: concurrentCapacityHint,
},
{
key: "chat_max_queue_size",
label: "问答最大排队数",
type: "number",
defaultValue: 90,
min: 0,
max: 10000,
description: "超过最大并发后允许等待的请求数量。默认按并发数的 3 倍预留,避免课程结束后的集中提问被直接拒绝。",
},
{
key: "chat_queue_timeout_seconds",
label: "问答排队超时(秒)",
type: "number",
defaultValue: 90,
min: 1,
max: 3600,
description: "请求在排队中超过该时间后自动结束。默认 90 秒可覆盖 30 并发下约三轮请求释放。",
},
{
key: "chat_active_lease_seconds",
label: "问答执行租约(秒)",
type: "number",
defaultValue: 900,
min: 60,
max: 86400,
description: "Redis 全局队列中执行名额的自动过期时间,防止 worker 异常退出后长期占用并发名额。",
},
{
key: "voice_input_enabled",
label: "启用语音输入",
type: "switch",
defaultValue: false,
description: "关闭后用户端不展示麦克风入口,后端也会拒绝语音转写请求。",
},
{
key: "voice_max_duration_seconds",
label: "单条语音最大时长(秒)",
type: "number",
defaultValue: 60,
min: 5,
max: 60,
description: "达到设定时长后自动停止并转成文字,阿里云一句话识别上限为 60 秒。",
},
{
key: "aliyun_nls_app_key",
label: "阿里云语音 AppKey",
type: "text",
defaultValue: "",
placeholder: "请输入智能语音交互项目 AppKey",
description: "填写阿里云智能语音交互控制台中对应应用的 AppKey。",
},
{
key: "aliyun_nls_access_key_id",
label: "语音服务 AccessKey ID",
type: "password",
defaultValue: "",
placeholder: "请单独填写语音服务 AccessKey ID",
description: "语音服务独立配置;如与短信使用同一账号,也需要在这里手动填写。",
},
{
key: "aliyun_nls_access_key_secret",
label: "语音服务 AccessKey Secret",
type: "password",
defaultValue: "",
placeholder: "请单独填写语音服务 AccessKey Secret",
description: "加密保存且不会明文回显,不再从短信配置中读取。",
},
{
key: "aliyun_nls_endpoint",
label: "阿里云语音 Endpoint",
type: "text",
defaultValue: "https://nls-gateway-cn-shanghai.aliyuncs.com/stream/v1/asr",
placeholder: "https://nls-gateway-cn-shanghai.aliyuncs.com/stream/v1/asr",
description: "一句话识别服务地址,一般保持默认值即可。",
},
],
},
{
title: "外部服务",
description: "控制飞书搜索接口、飞书直读超时和重试策略。",
settings: [
{
key: "feishu_search_url",
label: "飞书检索服务地址",
type: "text",
defaultValue: "",
placeholder: "例如 https://example.com/search",
description: "如使用独立飞书检索适配服务,在这里填写调用地址。",
},
{
key: "feishu_app_id",
label: "飞书应用 AppID",
type: "text",
defaultValue: "",
placeholder: "填写飞书开放平台应用 AppID",
description: "用于后端直读飞书知识库;系统设置优先于环境变量。",
},
{
key: "feishu_app_secret",
label: "飞书应用 AppSecret",
type: "password",
defaultValue: "",
placeholder: "填写飞书开放平台应用 AppSecret",
description: "用于后端获取飞书 tenant_access_token保存后立即生效。",
},
{
key: "feishu_timeout_seconds",
label: "飞书请求超时(秒)",
type: "number",
defaultValue: 20,
min: 1,
max: 120,
description: "飞书相关接口调用超过该时间应判定为超时。",
},
{
key: "feishu_retry_count",
label: "飞书检索重试次数",
type: "number",
defaultValue: 2,
min: 0,
max: 10,
description: "飞书检索失败时的最大重试次数。",
},
],
},
{
title: "系统开关",
description: "控制系统级展示、备案信息和运行策略,不包含密钥类配置。",
settings: [
{
key: "site_filing_text",
label: "备案展示内容",
type: "text",
defaultValue: "",
placeholder: "例如 京ICP备12345678号-1",
description: "显示在用户端登录页和主页面底部;留空时前端不展示备案区域。",
},
{
key: "site_filing_url",
label: "备案跳转链接",
type: "text",
defaultValue: "",
placeholder: "例如 https://beian.miit.gov.cn/",
description: "选填。填写后用户点击备案信息将在新窗口打开该地址,仅支持 http 或 https。",
},
{
key: "show_operation_log_detail",
label: "展示操作日志详情",
type: "switch",
defaultValue: true,
description: "开启后后台审计页可展示更完整的操作上下文。",
},
{
key: "chat_export_enabled",
label: "允许导出聊天记录",
type: "switch",
defaultValue: true,
description: "关闭后后台不应提供聊天记录导出能力。",
},
{
key: "maintenance_mode",
label: "维护模式",
type: "switch",
defaultValue: false,
description: "预留给后续停机维护提示和访问控制使用。",
},
],
},
];
function concurrentCapacityHint(value: SystemSettingValue) {
const concurrency = Math.max(1, Number(value) || 1);
const lower = roundDownToTen((concurrency * 120) / 25 / 1.4);
const upper = roundDownToTen((concurrency * 120) / 25 / 1.2);
const capacity = lower === upper ? `${lower}` : `${lower}${upper}`;
return `容量估算:按单次问答约 25 秒并预留 20%40% 波动,当前 ${concurrency} 并发可承接 2 分钟内${capacity}集中提问。实际能力还受模型服务商额度和回答时长影响。`;
}
function roundDownToTen(value: number) {
return Math.max(1, Math.floor(value / 10) * 10);
}
export const systemSettingDefinitions = systemSettingSections.flatMap((section) => section.settings);

View File

@@ -0,0 +1,122 @@
import {
ElAlert,
ElButton,
ElCard,
ElCheckbox,
ElCollapse,
ElCollapseItem,
ElDatePicker,
ElDivider,
ElDialog,
ElDrawer,
ElDropdown,
ElDropdownItem,
ElDropdownMenu,
ElEmpty,
ElForm,
ElFormItem,
ElInput,
ElInputNumber,
ElOption,
ElPagination,
ElProgress,
ElRadioButton,
ElRadioGroup,
ElSelect,
ElSlider,
ElSwitch,
ElTabPane,
ElTable,
ElTableColumn,
ElTabs,
ElTag,
ElTooltip,
ElTree,
vLoading,
} from "element-plus";
import "element-plus/theme-chalk/base.css";
import "element-plus/theme-chalk/el-alert.css";
import "element-plus/theme-chalk/el-button.css";
import "element-plus/theme-chalk/el-card.css";
import "element-plus/theme-chalk/el-checkbox.css";
import "element-plus/theme-chalk/el-collapse.css";
import "element-plus/theme-chalk/el-date-picker.css";
import "element-plus/theme-chalk/el-divider.css";
import "element-plus/theme-chalk/el-dialog.css";
import "element-plus/theme-chalk/el-drawer.css";
import "element-plus/theme-chalk/el-dropdown.css";
import "element-plus/theme-chalk/el-empty.css";
import "element-plus/theme-chalk/el-form.css";
import "element-plus/theme-chalk/el-input.css";
import "element-plus/theme-chalk/el-input-number.css";
import "element-plus/theme-chalk/el-loading.css";
import "element-plus/theme-chalk/el-message.css";
import "element-plus/theme-chalk/el-message-box.css";
import "element-plus/theme-chalk/el-option.css";
import "element-plus/theme-chalk/el-overlay.css";
import "element-plus/theme-chalk/el-pagination.css";
import "element-plus/theme-chalk/el-progress.css";
import "element-plus/theme-chalk/el-radio-button.css";
import "element-plus/theme-chalk/el-radio-group.css";
import "element-plus/theme-chalk/el-popper.css";
import "element-plus/theme-chalk/el-scrollbar.css";
import "element-plus/theme-chalk/el-select.css";
import "element-plus/theme-chalk/el-slider.css";
import "element-plus/theme-chalk/el-switch.css";
import "element-plus/theme-chalk/el-table.css";
import "element-plus/theme-chalk/el-tabs.css";
import "element-plus/theme-chalk/el-tag.css";
import "element-plus/theme-chalk/el-tooltip.css";
import "element-plus/theme-chalk/el-tree.css";
import { createApp } from "vue";
import zhCn from "element-plus/es/locale/lang/zh-cn";
import App from "./App.vue";
import "./styles.css";
const app = createApp(App);
app.config.globalProperties.$ELEMENT = { locale: zhCn };
[
ElAlert,
ElButton,
ElCard,
ElCheckbox,
ElCollapse,
ElCollapseItem,
ElDatePicker,
ElDivider,
ElDialog,
ElDrawer,
ElDropdown,
ElDropdownItem,
ElDropdownMenu,
ElEmpty,
ElForm,
ElFormItem,
ElInput,
ElInputNumber,
ElOption,
ElPagination,
ElProgress,
ElRadioButton,
ElRadioGroup,
ElSelect,
ElSlider,
ElSwitch,
ElTabPane,
ElTable,
ElTableColumn,
ElTabs,
ElTag,
ElTooltip,
ElTree,
].forEach((component) => {
app.use(component);
});
// Element Plus 2.11 exposes radio group/button without an installer; register them explicitly.
app.component("ElRadioButton", ElRadioButton);
app.component("ElRadioGroup", ElRadioGroup);
app.directive("loading", vLoading);
app.mount("#app");

View File

@@ -0,0 +1,438 @@
import type {
AdminProfile,
ManagedAdmin,
PermissionNode,
AdminUser,
AdminUserDetail,
AgentDebugResult,
AgentDebugStreamComplete,
AgentGenerationConfig,
AgentRuntimeConfig,
AgentBatchJob,
AgentBatchJobDetail,
AiLogRecord,
ApiResponse,
ChatDetail,
ChatMessageRecord,
ChatRecord,
ChatRecordQuery,
ContentGenerationConfigDetail,
ContentGenerationVariable,
ContentGenerationHistoryItem,
ContentGenerationType,
DashboardStats,
PeakTrafficResult,
TrafficGrain,
EntitlementPlan,
EntitlementBatchRenewResult,
KnowledgeItem,
KnowledgeContentSearchItem,
KnowledgeDetail,
KnowledgeSyncJob,
KnowledgeVersionContent,
MigrationSummary,
KnowledgeBatchSyncResult,
KnowledgeBatchUpdateResult,
RetrievalLogItem,
AttentionItem,
AttentionConfig,
AttentionPreviewMessage,
AttentionPreviewResult,
ModelItem,
SystemConfigItem,
UserImportResult,
UserEntitlementSummary,
UserBehaviorOverview,
UserBehaviorTimeline,
UserBehaviorUserSummary,
PageResult,
PeriodicReportRecord,
PromptDetail,
PromptHistoryItem,
QuestionInsightRefreshResult,
QuestionInsightSummary,
SsoAuditItem,
SsoClientItem,
SsoIdentityItem,
TopicSessionRecord,
TopicSummaryRecord,
} from "../types/api";
const API_BASE = import.meta.env.VITE_API_BASE_URL ?? "/api";
const TOKEN_KEY = "ai-kb-admin-token";
export function getToken() {
return window.localStorage.getItem(TOKEN_KEY);
}
export function saveToken(token: string) {
window.localStorage.setItem(TOKEN_KEY, token);
}
export function clearToken() {
window.localStorage.removeItem(TOKEN_KEY);
}
async function request<T>(path: string, init: RequestInit = {}): Promise<T> {
const headers = new Headers(init.headers);
headers.set("Content-Type", "application/json");
const token = getToken();
if (token) headers.set("Authorization", `Bearer ${token}`);
const response = await fetch(`${API_BASE}${path}`, { ...init, headers });
const body = await readApiResponse<T>(response);
if (!response.ok || body.code !== 0) {
throw new Error(body.detail || body.message || `${response.status} ${response.statusText}`);
}
return body.data;
}
async function readApiResponse<T>(response: Response): Promise<ApiResponse<T> & { detail?: string }> {
const text = await response.text();
if (!text.trim()) {
throw new Error(response.ok ? "接口返回为空" : `服务暂时不可用(${response.status}`);
}
try {
return JSON.parse(text) as ApiResponse<T> & { detail?: string };
} catch {
throw new Error(response.ok ? "接口返回格式不正确" : `服务暂时不可用(${response.status}`);
}
}
async function download(path: string, filename: string) {
const headers = new Headers();
const token = getToken();
if (token) headers.set("Authorization", `Bearer ${token}`);
const response = await fetch(`${API_BASE}${path}`, { headers });
if (!response.ok) throw new Error("导出失败");
const blob = await response.blob();
const url = window.URL.createObjectURL(blob);
const anchor = document.createElement("a");
anchor.href = url;
anchor.download = filename;
anchor.click();
window.URL.revokeObjectURL(url);
}
async function upload<T>(path: string, formData: FormData): Promise<T> {
const headers = new Headers();
const token = getToken();
if (token) headers.set("Authorization", `Bearer ${token}`);
const response = await fetch(`${API_BASE}${path}`, { method: "POST", body: formData, headers });
const body = await readApiResponse<T>(response);
if (!response.ok || body.code !== 0) {
throw new Error(body.message || body.detail || `${response.status} ${response.statusText}`);
}
return body.data;
}
function queryString(query: object) {
const params = new URLSearchParams();
Object.entries(query).forEach(([key, value]) => {
if (value !== undefined && value !== null && value !== "") params.set(key, String(value));
});
const text = params.toString();
return text ? `?${text}` : "";
}
export const api = {
login: (username: string, password: string) =>
request<{ token: string; admin: AdminProfile }>("/admin/login", {
method: "POST",
body: JSON.stringify({ username, password }),
}),
logout: () => request<null>("/admin/logout", { method: "POST", body: "{}" }),
profile: () => request<AdminProfile>("/admin/profile"),
permissionTree: () => request<PermissionNode[]>("/admin/permission-tree"),
administrators: () => request<ManagedAdmin[]>("/admin/administrator/list"),
createAdministrator: (payload: Record<string, unknown>) => request<ManagedAdmin>("/admin/administrator", { method: "POST", body: JSON.stringify(payload) }),
updateAdministrator: (id: number, payload: Record<string, unknown>) => request<ManagedAdmin>(`/admin/administrator/${id}`, { method: "PUT", body: JSON.stringify(payload) }),
deleteAdministrator: (id: number) => request<null>(`/admin/administrator/${id}`, { method: "DELETE" }),
changeAdminPassword: (payload: { currentPassword: string; newPassword: string }) => request<null>("/admin/password", { method: "POST", body: JSON.stringify(payload) }),
dashboard: (start?: string, end?: string) => {
const params = new URLSearchParams();
if (start) params.set("start", start);
if (end) params.set("end", end);
const qs = params.toString();
return request<DashboardStats>(`/admin/dashboard${qs ? "?" + qs : ""}`);
},
peakTraffic: (grain: TrafficGrain) =>
request<PeakTrafficResult>(`/admin/dashboard/traffic${queryString({ grain })}`),
userBehaviorOverview: (query: { start?: string; end?: string } = {}) =>
request<UserBehaviorOverview>(`/admin/user-behavior/overview${queryString(query)}`),
userBehaviorUsers: (query: { start?: string; end?: string; keyword?: string; page?: number; pageSize?: number; sortBy?: string; sortOrder?: "asc" | "desc" } = {}) =>
request<PageResult<UserBehaviorUserSummary>>(`/admin/user-behavior/users${queryString(query)}`),
userBehaviorTimeline: (userId: number, query: { start?: string; end?: string; page?: number; pageSize?: number } = {}) =>
request<UserBehaviorTimeline>(`/admin/user-behavior/user/${userId}/timeline${queryString(query)}`),
exportUserBehavior: (query: { start?: string; end?: string; keyword?: string; sortBy?: string; sortOrder?: "asc" | "desc" } = {}) =>
download(
`/admin/user-behavior/export${queryString(query)}`,
`用户行为分析_${query.start || "开始"}_${query.end || "当前"}.xlsx`,
),
users: (query: { keyword?: string; planId?: number; entitlementStatus?: string; page?: number; pageSize?: number } = {}) => request<PageResult<AdminUser>>(`/admin/user/list${queryString(query)}`),
userDetail: (id: number) => request<AdminUserDetail>(`/admin/user/${id}/detail`),
userTopics: (id: number, query: { keyword?: string; page?: number; pageSize?: number } = {}) =>
request<PageResult<TopicSessionRecord>>(`/admin/user/${id}/topics${queryString(query)}`),
userReports: (id: number, limit = 20) => request<PeriodicReportRecord[]>(`/admin/user/${id}/reports${queryString({ limit })}`),
generateUserReport: (id: number, payload: { reportType: string; periodStart?: string | null; periodEnd?: string | null }) =>
request<PeriodicReportRecord>(`/admin/user/${id}/reports/generate`, { method: "POST", body: JSON.stringify(payload) }),
retryTopicSettlement: (userId: number, topicId: number) =>
request<TopicSummaryRecord>(`/admin/user/${userId}/topic/${topicId}/settlement/retry`, { method: "POST", body: "{}" }),
createUser: (payload: Record<string, unknown>) =>
request<AdminUser>("/admin/user", { method: "POST", body: JSON.stringify(payload) }),
importUsers: (students: Record<string, unknown>[]) =>
request<UserImportResult>("/admin/user/import", { method: "POST", body: JSON.stringify({ students }) }),
downloadStudentTemplate: () => download("/admin/user/import/template", "学员导入模板.xlsx"),
importUsersExcel: (file: File) => {
const formData = new FormData();
formData.append("file", file);
return upload<UserImportResult>("/admin/user/import/excel", formData);
},
updateUser: (id: number, payload: Record<string, unknown>) =>
request<AdminUser>(`/admin/user/${id}`, { method: "PUT", body: JSON.stringify(payload) }),
deleteUser: (id: number) => request<null>(`/admin/user/${id}`, { method: "DELETE" }),
entitlementPlans: (includeDisabled = true) =>
request<EntitlementPlan[]>(`/admin/entitlement/plan/list${queryString({ includeDisabled })}`),
createEntitlementPlan: (payload: Record<string, unknown>) =>
request<EntitlementPlan>("/admin/entitlement/plan", { method: "POST", body: JSON.stringify(payload) }),
updateEntitlementPlan: (id: number, payload: Record<string, unknown>) =>
request<EntitlementPlan>(`/admin/entitlement/plan/${id}`, { method: "PUT", body: JSON.stringify(payload) }),
assignUserEntitlement: (userId: number, payload: { planId: number; effectiveAt?: string | null; expiredAt?: string | null; remark?: string | null }) =>
request<UserEntitlementSummary>(`/admin/user/${userId}/entitlement`, { method: "POST", body: JSON.stringify(payload) }),
renewUserEntitlement: (userId: number, payload: { extensionDays: number; idempotencyKey: string; remark?: string | null }) =>
request<UserEntitlementSummary>(`/admin/user/${userId}/entitlement/renew`, { method: "POST", body: JSON.stringify(payload) }),
batchRenewUserEntitlement: (payload: { userIds: number[]; extensionDays: number; idempotencyKey: string; remark?: string | null }) =>
request<EntitlementBatchRenewResult>("/admin/user/entitlement/batch-renew", { method: "POST", body: JSON.stringify(payload) }),
knowledge: (query: Record<string, unknown> = {}) => request<PageResult<KnowledgeItem>>(`/admin/knowledge/list${queryString(query)}`),
knowledgeOptions: () => request<KnowledgeItem[]>("/admin/knowledge/options"),
resolveKnowledgeNode: (nodeId: string) => request<{ nodeId: string; spaceId: string; sourceTitle: string; name: string; remark: string }>("/admin/knowledge/resolve-node", { method: "POST", body: JSON.stringify({ nodeId }) }),
createKnowledge: (payload: Record<string, unknown>) =>
request<KnowledgeItem>("/admin/knowledge", { method: "POST", body: JSON.stringify(payload) }),
updateKnowledge: (id: number, payload: Record<string, unknown>) =>
request<KnowledgeItem>(`/admin/knowledge/${id}`, { method: "PUT", body: JSON.stringify(payload) }),
updateKnowledgeMetadata: (id: number, payload: { name: string; knowledgeType: string }) =>
request<KnowledgeItem>(`/admin/knowledge/${id}/metadata`, { method: "PATCH", body: JSON.stringify(payload) }),
batchUpdateKnowledgeMetadata: (payload: { knowledgeIds: number[]; knowledgeType?: string }) =>
request<KnowledgeBatchUpdateResult>("/admin/knowledge/batch/metadata", { method: "PATCH", body: JSON.stringify(payload) }),
batchSyncKnowledge: (knowledgeIds: number[]) =>
request<KnowledgeBatchSyncResult>("/admin/knowledge/batch/sync", {
method: "POST",
body: JSON.stringify({ knowledgeIds }),
}),
deleteKnowledge: (id: number) => request<null>(`/admin/knowledge/${id}`, { method: "DELETE" }),
importKnowledgeFromSpace: (body: { node_token: string }) =>
request<{ imported: number; skipped: number; id: number; name: string }>(
"/admin/knowledge/import",
{ method: "POST", body: JSON.stringify(body) },
),
knowledgeDetail: (id: number) => request<KnowledgeDetail>(`/admin/knowledge/${id}/detail`),
knowledgeContentSearch: (query: { keyword: string; includeClosed?: boolean; page?: number; pageSize?: number }) =>
request<PageResult<KnowledgeContentSearchItem>>(`/admin/knowledge/content-search${queryString(query)}`),
knowledgeVersionContent: (versionId: number) => request<KnowledgeVersionContent>(`/admin/knowledge/version/${versionId}/content`),
knowledgeSyncJobs: (id: number) => request<KnowledgeSyncJob[]>(`/admin/knowledge/${id}/sync-jobs`),
syncKnowledge: (id: number) =>
request<KnowledgeSyncJob>(`/admin/knowledge/${id}/sync`, { method: "POST" }),
setKnowledgeOpen: (id: number, open: boolean) =>
request<KnowledgeItem>(`/admin/knowledge/${id}/open-status`, { method: "PUT", body: JSON.stringify({ open }) }),
setKnowledgeLifecycle: (id: number, status: "active" | "archived") =>
request<KnowledgeItem>(`/admin/knowledge/${id}/lifecycle`, { method: "PUT", body: JSON.stringify({ status }) }),
migrationSummary: () => request<MigrationSummary>("/admin/knowledge-migration/summary"),
runKnowledgeMigration: (knowledgeId?: number) =>
request<{ total: number; success: number; results: Record<string, unknown>[] }>(`/admin/knowledge-migration/run${knowledgeId ? `?knowledgeId=${knowledgeId}` : ""}`, { method: "POST", body: "{}" }),
prompt: () => request<PromptDetail>("/admin/prompt"),
savePrompt: (promptContent: string) =>
request<PromptDetail>("/admin/prompt", { method: "PUT", body: JSON.stringify({ promptContent }) }),
resetPrompt: () => request<PromptDetail>("/admin/prompt/reset", { method: "POST", body: "{}" }),
promptHistory: (query: { page?: number; pageSize?: number } = {}) =>
request<PageResult<PromptHistoryItem>>(`/admin/prompt/history${queryString(query)}`),
promptHistoryDetail: (id: number) => request<PromptDetail>(`/admin/prompt/history/${id}`),
restorePrompt: (id: number) => request<PromptDetail>(`/admin/prompt/history/${id}/restore`, { method: "POST", body: "{}" }),
contentGenerationConfig: (configType: ContentGenerationType) =>
request<ContentGenerationConfigDetail>(`/admin/content-generation/config/${configType}`),
saveContentGenerationConfig: (
configType: ContentGenerationType,
payload: { templateContent: string; instructionContent: string; variables: ContentGenerationVariable[] },
) => request<ContentGenerationConfigDetail>(`/admin/content-generation/config/${configType}`, {
method: "PUT",
body: JSON.stringify(payload),
}),
resetContentGenerationConfig: (configType: ContentGenerationType) =>
request<ContentGenerationConfigDetail>(`/admin/content-generation/config/${configType}/reset`, { method: "POST", body: "{}" }),
contentGenerationHistory: (configType: ContentGenerationType, query: { page?: number; pageSize?: number } = {}) =>
request<PageResult<ContentGenerationHistoryItem>>(`/admin/content-generation/config/${configType}/history${queryString(query)}`),
contentGenerationHistoryDetail: (configType: ContentGenerationType, id: number) =>
request<ContentGenerationConfigDetail>(`/admin/content-generation/config/${configType}/history/${id}`),
restoreContentGenerationConfig: (configType: ContentGenerationType, id: number) =>
request<ContentGenerationConfigDetail>(`/admin/content-generation/config/${configType}/history/${id}/restore`, { method: "POST", body: "{}" }),
previewContentGeneration: (payload: { configType: ContentGenerationType; templateContent: string; variables: ContentGenerationVariable[] }) =>
request<{ content: string }>("/admin/content-generation/preview", { method: "POST", body: JSON.stringify(payload) }),
testContentGeneration: (payload: {
configType: ContentGenerationType;
templateContent: string;
instructionContent: string;
sampleText: string;
variables: ContentGenerationVariable[];
}) => request<{ content: string; usedFallback: boolean }>("/admin/content-generation/test", {
method: "POST",
body: JSON.stringify(payload),
}),
debugAgent: (payload: Record<string, unknown>) =>
request<AgentDebugResult>("/admin/agent/debug", { method: "POST", body: JSON.stringify(payload) }),
agentRuntimeConfig: () => request<AgentRuntimeConfig>("/admin/agent/runtime-config"),
saveAgentRuntimeConfig: (payload: AgentGenerationConfig) =>
request<AgentRuntimeConfig>("/admin/agent/runtime-config", { method: "PUT", body: JSON.stringify(payload) }),
agentKnowledgeOptions: () => request<KnowledgeItem[]>("/admin/agent/knowledge-options"),
agentModelOptions: () => request<ModelItem[]>("/admin/agent/model-options"),
downloadAgentBatchTemplate: () => download("/admin/agent/batch/template", "Agent批量测试导入模板.xlsx"),
createAgentBatch: (file: File, config: Record<string, unknown>) => {
const formData = new FormData();
formData.append("file", file);
formData.append("configJson", JSON.stringify(config));
return upload<AgentBatchJob>("/admin/agent/batch/jobs", formData);
},
agentBatchJobs: (query: { page?: number; pageSize?: number } = {}) =>
request<PageResult<AgentBatchJob>>(`/admin/agent/batch/jobs${queryString(query)}`),
agentBatchJob: (id: number, query: { page?: number; pageSize?: number } = {}) =>
request<AgentBatchJobDetail>(`/admin/agent/batch/jobs/${id}${queryString(query)}`),
exportAgentBatch: (id: number) => download(`/admin/agent/batch/jobs/${id}/export`, `Agent批量测试结果_${id}.xlsx`),
cancelAgentBatch: (id: number) => request<AgentBatchJob>(`/admin/agent/batch/jobs/${id}/cancel`, { method: "POST", body: "{}" }),
models: () => request<ModelItem[]>("/admin/model/list"),
createModel: (payload: Record<string, unknown>) =>
request<ModelItem>("/admin/model", { method: "POST", body: JSON.stringify(payload) }),
updateModel: (id: number, payload: Record<string, unknown>) =>
request<ModelItem>(`/admin/model/${id}`, { method: "PUT", body: JSON.stringify(payload) }),
setModelAvailability: (modelId: number, enabled: number) =>
request<null>("/admin/model/enable", { method: "POST", body: JSON.stringify({ modelId, enabled }) }),
setDefaultModel: (modelId: number) =>
request<null>("/admin/model/default", { method: "POST", body: JSON.stringify({ modelId }) }),
deleteModel: (modelId: number) =>
request<null>(`/admin/model/${modelId}`, { method: "DELETE" }),
testModel: (modelId: number) =>
request<{ ok: boolean; message: string; answer: string }>(`/admin/model/${modelId}/test`, {
method: "POST",
body: "{}",
}),
configs: () => request<SystemConfigItem[]>("/admin/config"),
revealConfigSecret: (configKey: string) =>
request<{ configKey: string; configValue: string }>(
`/admin/config/${encodeURIComponent(configKey)}/secret`,
),
saveConfig: (payload: Record<string, unknown>) =>
request<SystemConfigItem>("/admin/config", { method: "PUT", body: JSON.stringify(payload) }),
ssoConfig: () => request<{ userClientUrl: string }>("/admin/sso/config"),
saveSsoConfig: (userClientUrl: string) =>
request<{ userClientUrl: string }>("/admin/sso/config", {
method: "PUT",
body: JSON.stringify({ userClientUrl }),
}),
ssoClients: () => request<SsoClientItem[]>("/admin/sso/client/list"),
createSsoClient: (payload: { appId: string; name: string; redirectUris: string[]; allowAutoRegister: boolean; defaultEntitlementPlanId?: number | null; status: number }) =>
request<SsoClientItem>("/admin/sso/client", { method: "POST", body: JSON.stringify(payload) }),
updateSsoClient: (id: number, payload: { name: string; redirectUris: string[]; allowAutoRegister: boolean; defaultEntitlementPlanId?: number | null; status: number }) =>
request<SsoClientItem>(`/admin/sso/client/${id}`, { method: "PUT", body: JSON.stringify(payload) }),
rotateSsoClientSecret: (id: number) =>
request<{ clientId: number; clientSecret: string }>(`/admin/sso/client/${id}/secret/rotate`, {
method: "POST",
body: "{}",
}),
ssoIdentities: (query: { clientId?: number; keyword?: string; page?: number; pageSize?: number } = {}) =>
request<PageResult<SsoIdentityItem>>(`/admin/sso/identity/list${queryString(query)}`),
unlinkSsoIdentity: (id: number) => request<null>(`/admin/sso/identity/${id}`, { method: "DELETE" }),
ssoAudits: (query: { clientId?: number; auditStatus?: string; page?: number; pageSize?: number } = {}) =>
request<PageResult<SsoAuditItem>>(`/admin/sso/audit/list${queryString(query)}`),
chats: (query: ChatRecordQuery = {}) => request<PageResult<ChatRecord>>(`/admin/chat/list${queryString(query)}`),
chatDetail: (sessionId: number, query: { messagePage?: number; messagePageSize?: number; focusMessageId?: number } = {}) =>
request<ChatDetail>(`/admin/chat/${sessionId}${queryString(query)}`),
chatMessages: (sessionId: number, query: { keyword?: string; page?: number; pageSize?: number } = {}) =>
request<PageResult<ChatMessageRecord>>(`/admin/chat/${sessionId}/messages${queryString(query)}`),
exportChatDetail: (sessionId: number, filename = `完整对话_会话${sessionId}.xlsx`) =>
download(`/admin/chat/${sessionId}/export`, filename),
exportChats: (query: ChatRecordQuery = {}) => download(`/admin/chat/export${queryString(query)}`, "chat_records.csv"),
aiLogs: (query: { sessionId?: number; userId?: number; status?: string; page?: number; pageSize?: number } = {}) =>
request<PageResult<AiLogRecord>>(`/admin/ai-log/list${queryString(query)}`),
aiLogDetail: (id: number) => request<AiLogRecord>(`/admin/ai-log/${id}`),
operationLogs: (query: { module?: string; page?: number; pageSize?: number } = {}) => request<PageResult<Record<string, unknown>>>(`/admin/log/list${queryString(query)}`),
questionInsights: (query: { dateFrom?: string; dateTo?: string; minCount?: number; maxMessages?: number; page?: number; pageSize?: number } = {}) =>
request<QuestionInsightSummary>(`/admin/question-insights/summary${queryString(query)}`),
refreshQuestionInsights: (query: { dateFrom?: string; dateTo?: string; maxMessages?: number } = {}) =>
request<QuestionInsightRefreshResult>(`/admin/question-insights/refresh${queryString(query)}`, {
method: "POST",
}),
exportQuestionInsights: (query: { dateFrom?: string; dateTo?: string; minCount?: number; maxMessages?: number } = {}, filename = "问题洞察.xlsx") =>
download(`/admin/question-insights/export${queryString(query)}`, filename),
retrievalLogs: (query: { page?: number; pageSize?: number } = {}) => request<PageResult<RetrievalLogItem>>(`/admin/retrieval-log/list${queryString(query)}`),
estimateRetrievalCleanup: (before: string) => request<{ before: string; estimatedCount: number }>("/admin/retrieval-log/cleanup/estimate", { method: "POST", body: JSON.stringify({ before }) }),
cleanupRetrievalLogs: (before: string) => request<{ before: string; deleted: number }>("/admin/retrieval-log/cleanup", { method: "POST", body: JSON.stringify({ before }) }),
retrievalRetention: () => request<{ enabled: boolean; retentionDays: number | null; lastRunAt: string | null; lastResult: string | null }>("/admin/retrieval-log/retention"),
saveRetrievalRetention: (retentionDays: number | null) => request<{ enabled: boolean; retentionDays: number | null }>("/admin/retrieval-log/retention", { method: "PUT", body: JSON.stringify({ retentionDays }) }),
storageStats: (refresh = false) => request<Record<string, any>>(`/admin/dashboard/storage${refresh ? "?refresh=true" : ""}`),
retrievalLogDetail: (id: number) => request<Record<string, unknown>>(`/admin/retrieval-log/${id}`),
attentionList: (query: { priority?: string; status?: string; dateFrom?: string; dateTo?: string; page?: number; pageSize?: number } = {}) => request<PageResult<AttentionItem>>(`/admin/attention/list${queryString(query)}`),
attentionConfig: () => request<AttentionConfig>("/admin/attention/config"),
attentionPreviewConfig: () => request<AttentionConfig>("/admin/attention/preview/config"),
saveAttentionConfig: (payload: AttentionConfig) => request<AttentionConfig>("/admin/attention/config", { method: "PUT", body: JSON.stringify(payload) }),
attentionPreviewMessages: (query: { keyword?: string; page?: number; pageSize?: number } = {}) => request<PageResult<AttentionPreviewMessage>>(`/admin/attention/preview/messages${queryString(query)}`),
previewAttention: (messageIds: number[], config: AttentionConfig) => request<AttentionPreviewResult[]>("/admin/attention/preview", { method: "POST", body: JSON.stringify({ messageIds, config }) }),
updateAttention: (id: number, status: string, note?: string) =>
request<AttentionItem>(`/admin/attention/${id}`, { method: "PUT", body: JSON.stringify({ status, note }) }),
deleteAttention: (id: number) => request<null>(`/admin/attention/${id}`, { method: "DELETE" }),
clearFeishuCache: () =>
request<{ cleared: number; message: string }>("/admin/feishu/cache/clear", { method: "POST", body: "{}" }),
feedbackList: (query: { readStatus?: string; startDate?: string; endDate?: string; page?: number; pageSize?: number } = {}) => request<PageResult<import("../types/api").FeedbackItem>>(`/feedback/admin/list${queryString(query)}`),
exportFeedback: (query: { readStatus?: string; startDate: string; endDate: string }) =>
download(`/feedback/admin/export${queryString(query)}`, `反馈记录_${query.startDate}_${query.endDate}.xlsx`),
feedbackDetail: (id: number, query: { messagePage?: number; messagePageSize?: number } = {}) =>
request<import("../types/api").FeedbackDetail>(`/feedback/admin/${id}${queryString(query)}`),
deleteFeedback: (id: number) => request<null>(`/feedback/admin/${id}`, { method: "DELETE" }),
};
export async function streamDebugAgent(
payload: Record<string, unknown>,
onChunk: (chunk: string) => void,
onReasoning: (chunk: string) => void,
onStatus: (status: { message: string; reasoningVisible: boolean }) => void,
onComplete: (result: AgentDebugStreamComplete) => void,
signal?: AbortSignal,
) {
const token = getToken();
const response = await fetch(`${API_BASE}/admin/agent/debug/stream`, {
method: "POST",
headers: {
"Content-Type": "application/json",
...(token ? { Authorization: `Bearer ${token}` } : {}),
},
body: JSON.stringify(payload),
signal,
});
if (!response.ok || !response.body) {
const body = await readApiResponse<unknown>(response);
throw new Error(body.detail || body.message || "Agent 调试连接失败");
}
const reader = response.body.getReader();
const decoder = new TextDecoder("utf-8");
let buffer = "";
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const events = buffer.split(/\r?\n\r?\n/);
buffer = events.pop() ?? "";
for (const event of events) {
const dataLines = event.split(/\r?\n/).filter((line) => line.startsWith("data:"));
if (!dataLines.length) continue;
const data = dataLines.map((line) => line.slice(5).trimStart()).join("\n").trim();
if (data === "[DONE]") return;
const parsed = JSON.parse(data) as AgentDebugStreamComplete & {
type?: string;
content?: string;
reasoningVisible?: boolean;
};
if (parsed.type === "error") throw new Error(parsed.message || "Agent 调试失败");
if (parsed.type === "content" && parsed.content) onChunk(parsed.content);
if (parsed.type === "reasoning" && parsed.content) onReasoning(parsed.content);
if (parsed.type === "status") {
onStatus({ message: parsed.message || "思考中", reasoningVisible: Boolean(parsed.reasoningVisible) });
}
if (parsed.type === "complete") onComplete(parsed);
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,928 @@
export interface ApiResponse<T> {
code: number;
message: string;
data: T;
}
export interface PageResult<T> {
items: T[];
total: number;
page: number;
pageSize: number;
}
export interface AdminProfile {
id: number;
username: string;
name: string;
status: number;
isSuperAdmin: boolean;
mustChangePassword: boolean;
permissions: string[];
}
export interface ManagedAdmin {
id: number;
username: string;
name: string;
status: number;
isSuperAdmin: boolean;
mustChangePassword: boolean;
permissions: string[];
lastLoginAt?: string | null;
createdAt?: string | null;
}
export interface PermissionNode {
code: string;
name: string;
superOnly?: boolean;
children?: PermissionNode[];
}
export interface SsoClientItem {
id: number;
appId: string;
name: string;
redirectUris: string[];
allowAutoRegister: boolean;
defaultEntitlementPlanId?: number | null;
defaultEntitlementPlanName?: string | null;
status: number;
identityCount: number;
lastUsedAt?: string | null;
createdAt: string;
updatedAt: string;
clientSecret?: string;
}
export interface SsoIdentityItem {
id: number;
clientId: number;
clientName: string;
appId: string;
userId: number;
userName: string;
phone: string;
externalUserId: string;
displayNameSnapshot?: string | null;
lastLoginAt?: string | null;
createdAt: string;
}
export interface SsoAuditItem {
id: number;
appId?: string | null;
clientName?: string | null;
userId?: number | null;
externalUserId?: string | null;
action: string;
status: string;
errorMessage?: string | null;
ip?: string | null;
createdAt: string;
}
export interface DashboardStats {
userCount: number;
sessionCount: number;
messageCount: number;
aiRequestCount: number;
knowledgeCount: number;
inputToken: number;
outputToken: number;
totalToken: number;
estimatedCost?: number | null;
costCurrency?: string | null;
costBreakdown: Array<{
scene: string;
modelName: string;
requestCount: number;
inputToken: number;
outputToken: number;
totalToken: number;
estimatedCost: number;
currency?: string | null;
}>;
}
export type TrafficGrain = "minute" | "hour" | "day" | "week";
export interface PeakTrafficRow {
period: string;
periodLabel: string;
requestCount: number;
errorCount: number;
errorRate: number;
avgResponseMs: number;
}
export interface PeakTrafficResult {
grain: TrafficGrain;
peakPeriod?: string | null;
peakRequests: number;
totalRequests: number;
rows: PeakTrafficRow[];
}
export interface UserBehaviorOverview {
startDate: string;
endDate: string;
retentionDays: number;
totalEvents: number;
activeUsers: number;
pageDialogOpens: number;
buttonClicks: number;
daily: Array<{ date: string; eventCount: number; activeUsers: number }>;
eventRanking: Array<{
eventCode: string;
eventName: string;
eventType: "page" | "dialog" | "button";
count: number;
userCount: number;
}>;
}
export interface UserBehaviorUserSummary {
userId: number;
userName: string;
phone: string;
eventCount: number;
lastEventAt: string;
}
export interface UserBehaviorTimelineEvent {
id: number;
eventCode: string;
eventName: string;
eventType: "page" | "dialog" | "button";
targetType?: string | null;
targetId?: number | null;
occurredAt: string;
}
export interface UserBehaviorTimeline {
user: { userId: number; userName: string; phone: string };
items: UserBehaviorTimelineEvent[];
total: number;
page: number;
pageSize: number;
}
export interface PromptDetail {
id: number | null;
promptContent: string;
changeType: "default" | "save" | "reset" | "restore";
sourcePromptId?: number | null;
updatedByName?: string | null;
updatedAt?: string | null;
charCount: number;
}
export interface PromptHistoryItem {
id: number;
preview: string;
charCount: number;
changeType: "save" | "reset" | "restore";
sourcePromptId?: number | null;
updatedByName: string;
updatedAt: string;
isCurrent: boolean;
}
export type ContentGenerationType = "help_card" | "share_draft" | "weekly_report" | "monthly_report";
export interface ContentGenerationVariable {
name: string;
label: string;
description: string;
valueSource: "ai" | "context";
sourceKey: string | null;
sampleValue: string;
}
export interface ContentGenerationSourceOption {
key: string;
label: string;
}
export interface ContentGenerationConfigDetail {
id: number | null;
configType: ContentGenerationType;
label: string;
templateContent: string;
instructionContent: string;
lockedFooter: string;
variables: ContentGenerationVariable[];
sourceOptions: ContentGenerationSourceOption[];
changeType: "default" | "save" | "reset" | "restore";
sourceConfigId?: number | null;
updatedByName: string;
updatedAt?: string | null;
templateCharCount: number;
instructionCharCount: number;
}
export interface ContentGenerationHistoryItem {
id: number;
configType: ContentGenerationType;
preview: string;
templateCharCount: number;
instructionCharCount: number;
changeType: "save" | "reset" | "restore";
sourceConfigId?: number | null;
updatedByName: string;
updatedAt: string;
isCurrent: boolean;
}
export interface AdminUser {
id: number;
phone: string;
name: string;
nickname?: string | null;
registrationSource: "direct" | "sso" | string;
registrationClientId?: number | null;
status: number;
dailyChatLimit: number;
dailyChatUsed: number;
expiredAt?: string | null;
lastLoginAt?: string | null;
createdAt?: string | null;
entitlement?: UserEntitlementSummary | null;
}
export interface AdminUserDetail {
user: AdminUser;
metrics: AdminUserMetrics;
growthProfile?: UserGrowthProfileRecord | null;
recentTopics: TopicSessionRecord[];
recentHelpCards: TeacherHelpCardRecord[];
recentShareDrafts: ShareDraftRecord[];
recentReports: PeriodicReportRecord[];
}
export interface AdminUserMetrics {
totalSessions: number;
totalMessages: number;
recentActiveDays: number;
lastMessageAt?: string | null;
aiRequestCount: number;
inputToken: number;
outputToken: number;
totalToken: number;
estimatedCost: number;
costCurrency: string;
helpCardCount: number;
shareDraftCount: number;
isHighFrequency: boolean;
isInactive: boolean;
}
export interface EntitlementPlan {
id: number;
name: string;
planType: "basic" | "deep" | "addon";
description?: string | null;
validityDays?: number | null;
enableGrowthProfile: boolean;
enablePeriodicReports: boolean;
allowHelpCard: boolean;
allowShareDraft: boolean;
status: number;
sortOrder: number;
createdAt?: string | null;
updatedAt?: string | null;
}
export interface UserEntitlementSummary {
planId?: number | null;
name: string;
planType: string;
enableGrowthProfile: boolean;
enablePeriodicReports: boolean;
allowHelpCard: boolean;
allowShareDraft: boolean;
effectiveAt?: string | null;
expiredAt?: string | null;
source: string;
lifecycleStatus: "active" | "long_term" | "expiring_7" | "expiring_30" | "expired_fallback" | "default" | "legacy" | string;
daysUntilExpiry?: number | null;
previousPlanName?: string | null;
previousExpiredAt?: string | null;
}
export interface EntitlementBatchRenewResult {
succeeded: Array<{ userId: number; name: string }>;
failed: Array<{ userId: number; name?: string; reason: string }>;
successCount: number;
failureCount: number;
}
export interface UserImportFailure {
row: number;
phone: string;
reason: string;
}
export interface UserImportResult {
created: number;
updated: number;
failed: number;
failures: UserImportFailure[];
}
export interface SystemConfigItem {
id: number;
configKey: string;
configValue: string;
description?: string | null;
updatedAt?: string | null;
}
export interface KnowledgeItem {
id: number;
name: string;
feishuSpaceId: string;
feishuNodeId: string;
status: number;
remark?: string | null;
sourceTitle?: string | null;
knowledgeType: "course" | "qa" | "general" | "fixed";
lifecycleStatus: "active" | "archived";
sourceStatus: "normal" | "error";
sourceError?: string | null;
currentVersionId?: number | null;
lastSyncedAt?: string | null;
lastPublishedAt?: string | null;
migrationStatus: string;
createdAt?: string | null;
updatedAt?: string | null;
}
export interface ModelItem {
id: number;
provider: string;
displayName?: string | null;
apiType: string;
modelName: string;
baseUrl?: string | null;
apiUrl: string;
apiKeyMasked: string;
authType: string;
apiVersion?: string | null;
temperature?: number | null;
topP?: number | null;
topK?: number | null;
presencePenalty?: number | null;
frequencyPenalty?: number | null;
maxToken?: number | null;
contextWindow?: number | null;
streamEnabled: number;
responseFormat?: string | null;
extraParams?: string | null;
remark?: string | null;
timeoutSecond: number;
enabled: number;
isDefault: number;
inputPricePer1k?: number | null;
outputPricePer1k?: number | null;
currency: string;
usageScenarios?: string | null;
allowSummary: number;
allowReport: number;
allowFixedInfo: number;
allowSimpleKnowledge: number;
allowDeepChat: number;
}
export interface AgentGenerationConfig {
temperature: number | null;
topP: number | null;
topK: number | null;
presencePenalty: number | null;
frequencyPenalty: number | null;
maxToken: number;
streamEnabled: number;
reasoningVisible: number;
responseDepth: number;
}
export interface AgentRuntimeConfig extends AgentGenerationConfig {
modelId: number | null;
modelName: string | null;
}
export interface AgentDebugResult {
ok: boolean;
message: string;
answer: string;
prompt: string;
modelName?: string;
retrieveCount?: number;
knowledgeIds?: string;
retrievalTrace?: Record<string, unknown>[];
retrievalLogId?: number;
routeReason?: string;
questionType?: string;
}
export interface AgentDebugStreamComplete {
message: string;
modelName?: string;
retrieveCount?: number;
knowledgeIds?: string;
retrievalTrace?: Record<string, unknown>[];
retrievalLogId?: number;
routeReason?: string;
questionType?: string;
}
export type AgentBatchStatus = "pending" | "running" | "completed" | "completed_with_errors" | "cancelled";
export interface AgentBatchJob {
id: number;
name: string;
originalFilename: string;
status: AgentBatchStatus;
totalCount: number;
processedCount: number;
successCount: number;
failedCount: number;
progress: number;
executionMode: "sequential" | "concurrent";
concurrencyLimit: number;
modelId: number;
modelName: string;
knowledgeIds: number[];
knowledgeNames: string[];
createdAt: string;
startedAt?: string | null;
finishedAt?: string | null;
}
export interface AgentBatchItem {
id: number;
rowNumber: number;
externalNo?: string | null;
question: string;
answer?: string | null;
status: "pending" | "running" | "success" | "failed" | "cancelled";
errorMessage?: string | null;
modelName?: string | null;
retrieveCount?: number | null;
durationMs?: number | null;
}
export interface AgentBatchJobDetail extends AgentBatchJob {
items: PageResult<AgentBatchItem>;
}
export interface KnowledgeVersion {
id: number;
knowledgeId: number;
snapshotId: number;
versionNo: number;
status: string;
qualityStatus: string;
changeSummary?: Record<string, unknown> | null;
qualityReport?: Record<string, unknown> | null;
processingRuleVersion: string;
generationModel?: string | null;
createdAt: string;
publishedAt?: string | null;
}
export interface KnowledgeManifest {
id: number;
versionId: number;
purpose: string;
applicableQuestions: string;
inapplicableQuestions: string;
coreTopics: string;
entities?: string | null;
boundaries?: string | null;
changeLevel: string;
confirmed: boolean;
}
export interface KnowledgeDetail {
knowledge: KnowledgeItem;
currentVersion?: KnowledgeVersion | null;
manifest?: KnowledgeManifest | null;
}
export interface KnowledgeVersionContent {
version: KnowledgeVersion;
manifest?: KnowledgeManifest | null;
sections: Array<{ id: number; sectionKey: string; title: string; content: string; sourceStart: number; sourceEnd: number; contentHash: string }>;
cards: Array<Record<string, unknown>>;
}
export interface KnowledgeContentSearchItem {
knowledgeId: number;
knowledgeName: string;
sourceTitle?: string | null;
knowledgeType: "course" | "qa" | "general" | "fixed";
knowledgeStatus: number;
lifecycleStatus: "active" | "archived";
sourceStatus: "normal" | "error";
versionId: number;
versionNo: number;
publishedAt?: string | null;
itemType: "section" | "card" | "chunk";
itemTypes?: Array<"section" | "card" | "chunk">;
itemId: number;
sectionId: number;
sectionKey: string;
title: string;
snippet: string;
highlightTerms?: string[];
matchedIn: Array<"title" | "content">;
hasChunks: boolean;
isCurrentVersion: boolean;
canAgentUse: boolean;
}
export interface KnowledgeSyncJob {
id: number;
knowledgeId: number;
mode: string;
status: string;
stage: string;
progress: number;
sourceError?: string | null;
resultSummary?: Record<string, unknown> | null;
createdAt: string;
finishedAt?: string | null;
}
export interface MigrationSummary {
total: number;
counts: Record<string, number>;
}
export interface KnowledgeBatchUpdateResult {
total: number;
success: number;
failed: number;
results: Array<{ knowledgeId: number; ok: boolean; error?: string }>;
}
export interface KnowledgeBatchSyncResult {
total: number;
success: number;
failed: number;
results: Array<{
knowledgeId: number;
name?: string;
ok: boolean;
status?: string;
job?: KnowledgeSyncJob;
error?: string;
}>;
}
export interface RetrievalLogItem {
id: number;
sessionId?: number | null;
question: string;
knowledgeCalled: boolean;
selectedKnowledgeIds?: string | null;
queryTerms?: string[] | null;
status: string;
toolCostMs?: number | null;
totalCostMs?: number | null;
attentionCreated: boolean;
createdAt: string;
}
export interface AttentionItem {
id: number;
sessionId: number;
messageId: number;
userId: number;
triggerMessage: string;
problemSummary: string;
triggerReason: string;
priority: string;
status: string;
handlerNote?: string | null;
targetAvailable?: boolean;
createdAt: string;
}
export interface AttentionConfig {
enabled: boolean;
keywordEnabled: boolean;
aiEnabled: boolean;
knowledgeMissingEnabled: boolean;
urgentTerms: string[];
importantTerms: string[];
normalTerms: string[];
promptTemplate: string;
recognitionItems: AttentionRecognitionItem[];
}
export interface AttentionRecognitionItem {
name: string;
description: string;
priority: "urgent" | "important" | "normal";
enabled: boolean;
}
export interface AttentionPreviewMessage {
messageId: number;
sessionId: number;
userId: number;
userName: string;
userPhone: string;
sessionTitle: string;
question: string;
answer: string;
knowledgeMissing: boolean;
createdAt: string;
}
export interface AttentionPreviewResult {
messageId: number;
sessionId: number;
question: string;
answer: string;
knowledgeMissing: boolean;
needsAttention: boolean;
priority: string;
reason: string;
summary: string;
source: string;
rawOutput: string;
renderedPrompt: string;
matchedItem: string;
}
export interface ChatRecord {
id: number;
userId: number;
userPhone: string;
userName: string;
sourceType: "direct" | "sso";
sourceClientId?: number | null;
sourceName: string;
title: string;
messageCount: number;
lastMessageAt?: string | null;
updatedAt: string;
}
export interface ChatMessageRecord {
id: number;
sessionId: number;
topicSessionId?: number | null;
userId: number;
role: "user" | "assistant";
content: string;
messageStatus: string;
tokenInput?: number | null;
tokenOutput?: number | null;
responseTimeMs?: number | null;
modelId?: number | null;
createdAt: string;
}
export interface AiLogRecord {
id: number;
sessionId?: number | null;
messageId?: number | null;
userId?: number | null;
modelName?: string | null;
knowledgeIds?: string | null;
retrieveCount: number;
retrievedChunks: RetrievedKnowledgeChunk[];
inputToken?: number | null;
outputToken?: number | null;
totalToken?: number | null;
costMs?: number | null;
estimatedCost?: number | null;
currency?: string | null;
routeReason?: string | null;
questionType?: string | null;
knowledgeHit?: boolean;
status: string;
errorMessage?: string | null;
prompt?: string | null;
createdAt: string;
}
export interface RetrievedKnowledgeChunk {
index: number;
knowledgeId?: number | null;
knowledgeName: string;
title: string;
content: string;
sourceUrl?: string | null;
}
export interface ChatDetail {
session: ChatRecord;
messages: ChatMessageRecord[];
messagesPage?: PageResult<ChatMessageRecord>;
focusedMessageId?: number | null;
aiLogs: AiLogRecord[];
topics?: TopicSessionRecord[];
helpCards?: TeacherHelpCardRecord[];
shareDrafts?: ShareDraftRecord[];
}
export interface TeacherHelpCardRecord {
id: number;
userId: number;
topicSessionId: number;
summaryId?: number | null;
content: string;
source: string;
copied: boolean;
copiedAt?: string | null;
createdAt: string;
}
export interface ShareDraftRecord {
id: number;
userId: number;
topicSessionId: number;
summaryId?: number | null;
content: string;
source: string;
copied: boolean;
copiedAt?: string | null;
createdAt: string;
}
export interface TopicSummaryRecord {
id: number;
topicSessionId: number;
userId: number;
schemaVersion: number;
summary: string;
currentFocus?: string | null;
nextObservation?: string | null;
modelName?: string | null;
status: string;
errorMessage?: string | null;
attemptCount: number;
maxAttempts: number;
nextRunAt?: string | null;
lastStartedAt?: string | null;
finishedAt?: string | null;
generatedAt: string;
}
export interface UserGrowthProfileRecord {
id: number;
userId: number;
schemaVersion: number;
reviewText: string;
currentFocus?: string | null;
sourceSummaryIds: number[];
lastTopicSummaryId?: number | null;
updatedBy: string;
createdAt: string;
updatedAt: string;
}
export interface PeriodicReportRecord {
id: number;
userId: number;
schemaVersion: number;
reportType: "weekly" | "monthly" | "stage" | string;
reportTypeLabel: string;
periodStart: string;
periodEnd: string;
title: string;
content: string;
sourceSummaryIds: number[];
sourceTopicIds: number[];
sourceMessageIds: number[];
sourceReportIds: number[];
modelName?: string | null;
status: "success" | "failed" | "empty" | string;
errorMessage?: string | null;
generatedBy: string;
attemptCount: number;
maxAttempts: number;
nextRunAt?: string | null;
lastStartedAt?: string | null;
finishedAt?: string | null;
generatedAt: string;
createdAt: string;
updatedAt: string;
}
export interface TopicSessionRecord {
id: number;
userId: number;
chatSessionId: number;
title: string;
coreQuestion: string;
status: string;
messageCount: number;
tokenInput: number;
tokenOutput: number;
startedAt: string;
endedAt?: string | null;
createdAt?: string | null;
updatedAt?: string | null;
summary?: TopicSummaryRecord | null;
summaryAvailable?: boolean;
}
export interface QuestionInsightSummary {
range: {
dateFrom?: string | null;
dateTo?: string | null;
maxMessages: number;
};
summary: {
scannedMessages: number;
cleanedQuestions: number;
filteredMessages: number;
clusterCount: number;
visibleClusterCount: number;
minCount: number;
cleanerVersion: string;
};
items: QuestionInsightCluster[];
total: number;
page: number;
pageSize: number;
}
export interface QuestionInsightRefreshResult {
processedMessages: number;
acceptedQuestions: number;
filteredMessages: number;
concurrentSkips: number;
hasMore: boolean;
cleanerVersion: string;
}
export interface QuestionInsightCluster {
rank: number;
title: string;
normalized: string;
category: string;
categoryLabel: string;
count: number;
userCount: number;
sessionCount: number;
aiRequestCount: number;
noHitCount: number;
failedCount: number;
needsKnowledgeFollowUp: boolean;
suggestedAction: string;
firstSeenAt: string;
lastSeenAt: string;
topTerms: string[];
variants: Array<{ text: string; count: number }>;
samples: QuestionInsightSample[];
}
export interface QuestionInsightSample {
messageId: number;
sessionId: number;
userId: number;
userName: string;
userPhone: string;
raw: string;
cleaned: string;
createdAt: string;
}
export interface ChatRecordQuery {
keyword?: string;
userId?: number | null;
status?: string;
sourceType?: string;
sourceClientId?: number | null;
dateFrom?: string;
dateTo?: string;
page?: number;
pageSize?: number;
}
export interface FeedbackItem {
id: number;
userId: number;
userName: string;
userPhone: string;
messageId: number;
messageContent: string;
content: string;
isRead: boolean;
createdAt: string;
}
export interface FeedbackDetail extends FeedbackItem {
sessionTitle: string;
messages: Array<{ id: number; role: "user" | "assistant"; content: string; createdAt: string; isTarget: boolean }>;
messagesPage: PageResult<{ id: number; role: "user" | "assistant"; content: string; createdAt: string; isTarget: boolean }>;
}

View File

@@ -0,0 +1,81 @@
export const BUSINESS_TIME_ZONE = "Asia/Shanghai";
const BUSINESS_OFFSET = "+08:00";
const DATE_ONLY_PATTERN = /^\d{4}-\d{2}-\d{2}$/;
const EXPLICIT_OFFSET_PATTERN = /(?:Z|[+-]\d{2}:?\d{2})$/i;
export function parseApiDateTime(value?: string | Date | null): Date | null {
if (!value) return null;
if (value instanceof Date) {
return Number.isNaN(value.getTime()) ? null : value;
}
const text = value.trim();
if (!text) return null;
const isoText = text.includes(" ") ? text.replace(" ", "T") : text;
const normalized = DATE_ONLY_PATTERN.test(isoText)
? `${isoText}T00:00:00${BUSINESS_OFFSET}`
: EXPLICIT_OFFSET_PATTERN.test(isoText)
? isoText
: `${isoText}Z`;
const parsed = new Date(normalized);
return Number.isNaN(parsed.getTime()) ? null : parsed;
}
function businessParts(value?: string | Date | null) {
const parsed = parseApiDateTime(value);
if (!parsed) return null;
const parts = new Intl.DateTimeFormat("zh-CN", {
timeZone: BUSINESS_TIME_ZONE,
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
hour12: false,
hourCycle: "h23",
}).formatToParts(parsed);
return Object.fromEntries(parts.map((part) => [part.type, part.value]));
}
export function formatDateTime(
value?: string | Date | null,
fallback = "-",
): string {
const parts = businessParts(value);
if (!parts) return fallback;
return `${parts.year}-${parts.month}-${parts.day} ${parts.hour}:${parts.minute}:${parts.second}`;
}
export function formatDate(
value?: string | Date | null,
fallback = "-",
): string {
const parts = businessParts(value);
if (!parts) return fallback;
return `${parts.year}-${parts.month}-${parts.day}`;
}
export function formatTime(
value?: string | Date | null,
fallback = "-",
): string {
const parts = businessParts(value);
if (!parts) return fallback;
return `${parts.hour}:${parts.minute}`;
}
export function toUtcIsoFromBusinessDate(
value: string,
boundary: "start" | "end" = "start",
): string | undefined {
if (!DATE_ONLY_PATTERN.test(value)) return undefined;
const localTime = boundary === "start" ? "00:00:00.000" : "23:59:59.999";
return new Date(`${value}T${localTime}${BUSINESS_OFFSET}`).toISOString();
}
export function toUtcIsoFromBusinessDateTime(value: string): string | undefined {
if (!value) return undefined;
const normalized = value.length === 16 ? `${value}:00` : value;
const parsed = new Date(`${normalized}${BUSINESS_OFFSET}`);
return Number.isNaN(parsed.getTime()) ? undefined : parsed.toISOString();
}

View File

@@ -0,0 +1 @@
/// <reference types="vite/client" />

View File

@@ -0,0 +1,17 @@
{
"compilerOptions": {
"target": "ES2022",
"useDefineForClassFields": true,
"module": "ESNext",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"skipLibCheck": true,
"moduleResolution": "Bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "preserve",
"strict": true
},
"include": ["src/**/*.ts", "src/**/*.vue"]
}

View File

@@ -0,0 +1,40 @@
import vue from "@vitejs/plugin-vue";
import { defineConfig } from "vite";
export default defineConfig({
base: "/login/",
plugins: [vue()],
build: {
rollupOptions: {
output: {
manualChunks(id) {
if (!id.includes("node_modules")) return undefined;
if (id.includes("element-plus/es/components/table")) return "el-table";
if (
id.includes("/vue/") ||
id.includes("@vue/") ||
id.includes("@vueuse") ||
id.includes("async-validator") ||
id.includes("@popperjs") ||
id.includes("@floating-ui") ||
id.includes("dayjs")
) {
return "vendor";
}
return undefined;
},
},
},
},
server: {
allowedHosts: ["qa.huiyushuyuan.cn"],
host: "0.0.0.0",
port: 5174,
proxy: {
"/api": {
target: "http://backend:8100",
changeOrigin: true,
},
},
},
});

View File

@@ -0,0 +1,7 @@
.env
.venv/
__pycache__/
*.py[cod]
.pytest_cache/
.mypy_cache/
.ruff_cache/

View File

@@ -0,0 +1,72 @@
APP_NAME=AI Knowledge Base V2 Backend
APP_ENV=local
DEBUG=true
API_PREFIX=/api
DATABASE_URL=mysql+pymysql://ai_kb:ai_kb@127.0.0.1:3306/ai_knowledge_base_v2?charset=utf8mb4
REDIS_URL=
DB_POOL_SIZE=10
DB_MAX_OVERFLOW=20
DB_POOL_TIMEOUT=30
DB_POOL_RECYCLE=1800
AUTO_CREATE_TABLES=false
JWT_SECRET_KEY=change-me-in-production
CONFIG_ENCRYPTION_KEY=
JWT_ALGORITHM=HS256
ACCESS_TOKEN_EXPIRE_MINUTES=43200
MOCK_SMS_ENABLED=true
MOCK_SMS_CODE=123456
SMS_CODE_EXPIRE_MINUTES=5
ALIYUN_SMS_ACCESS_KEY_ID=
ALIYUN_SMS_ACCESS_KEY_SECRET=
ALIYUN_SMS_SIGN_NAME=
ALIYUN_SMS_TEMPLATE_CODE=
ALIYUN_SMS_TEMPLATE_PARAM_KEY=code
ALIYUN_SMS_ENDPOINT=dysmsapi.aliyuncs.com
MOCK_RAG_ENABLED=false
MOCK_MODEL_ENABLED=true
FEISHU_MOCK_ENABLED=false
FEISHU_SEARCH_URL=
FEISHU_APP_ID=
FEISHU_APP_SECRET=
FEISHU_TIMEOUT_SECONDS=20
FEISHU_RETRY_COUNT=2
DEFAULT_DAILY_CHAT_LIMIT=100
DEFAULT_USER_NAME_PREFIX=用户
CHAT_MAX_ACTIVE_REQUESTS=2
CHAT_MAX_QUEUE_SIZE=20
CHAT_QUEUE_TIMEOUT_SECONDS=60
CHAT_ACTIVE_LEASE_SECONDS=900
# 周报在每周一 02:00、月报在每月 1 日 03:00 生成,时间按 PERIODIC_REPORT_TIMEZONE 解释。
PERIODIC_REPORT_WORKER_ENABLED=true
PERIODIC_REPORT_POLL_SECONDS=5
PERIODIC_REPORT_STALE_MINUTES=30
PERIODIC_REPORT_MAX_ATTEMPTS=3
PERIODIC_REPORT_WEEKLY_ENABLED=true
PERIODIC_REPORT_MONTHLY_ENABLED=true
PERIODIC_REPORT_TIMEZONE=Asia/Shanghai
TOPIC_SETTLEMENT_WORKER_ENABLED=true
TOPIC_SETTLEMENT_POLL_SECONDS=2
TOPIC_SETTLEMENT_STALE_MINUTES=30
TOPIC_SETTLEMENT_MAX_ATTEMPTS=3
AGENT_BATCH_WORKER_ENABLED=true
AGENT_BATCH_POLL_SECONDS=2
AGENT_BATCH_STALE_MINUTES=30
AGENT_BATCH_WORKER_CONCURRENCY=10
HUMAN_ATTENTION_WORKER_ENABLED=true
HUMAN_ATTENTION_WORKER_POLL_SECONDS=2
HUMAN_ATTENTION_WORKER_STALE_MINUTES=30
HUMAN_ATTENTION_WORKER_MAX_ATTEMPTS=3
# 本地开发可以使用开发密码;生产环境必须改成高强度密码,且 APP_ENV=production 时不能使用 admin123456
BOOTSTRAP_ADMIN_USERNAME=admin
BOOTSTRAP_ADMIN_PASSWORD=admin123456
BOOTSTRAP_ADMIN_NAME=系统管理员
ALIYUN_NLS_APP_KEY=
ALIYUN_NLS_ACCESS_KEY_ID=
ALIYUN_NLS_ACCESS_KEY_SECRET=
ALIYUN_NLS_ENDPOINT=https://nls-gateway-cn-shanghai.aliyuncs.com/stream/v1/asr

View File

@@ -0,0 +1,7 @@
.env
.venv/
__pycache__/
*.py[cod]
.pytest_cache/
.mypy_cache/
.ruff_cache/

View File

@@ -0,0 +1,19 @@
FROM python:3.12-slim
WORKDIR /app
ENV PYTHONDONTWRITEBYTECODE=1
ENV PYTHONUNBUFFERED=1
RUN apt-get update \
&& apt-get install -y --no-install-recommends fonts-dejavu-core \
&& rm -rf /var/lib/apt/lists/*
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
EXPOSE 8100
CMD ["sh", "-c", "alembic upgrade head && uvicorn app.main:app --host 0.0.0.0 --port 8100 --workers ${BACKEND_WORKERS:-1} --no-access-log"]

View File

@@ -0,0 +1,52 @@
# 大本营答疑助手后端
这是大本营答疑助手的后端服务目录当前处于阶段二第一批建设后端基础工程、核心数据模型、mock 登录能力。
## 技术栈
- FastAPI
- SQLAlchemy
- Alembic
- MySQL 8.x
- Pydantic Settings
- PyJWT
## 当前已包含
- 应用入口和健康检查
- 统一 JSON 响应结构
- 配置读取和 `.env` 示例
- SQLAlchemy 数据库连接
- Alembic 迁移骨架
- 核心 `sys_*` 数据模型
- mock 短信验证码
- 用户手机号验证码登录
- 用户 Token 鉴权
- 当前用户信息接口
## 本地启动
方式一:使用 V2 统一编排。
```bash
cd ai_knowledge_base_v2
docker compose -f docker-compose.dev.yml up --build
```
方式二:本地 Python 直接启动。
```bash
cd ai_knowledge_base_v2/apps/backend
python -m venv .venv
source .venv/bin/activate
pip install -r requirements.txt
cp .env.example .env
uvicorn app.main:app --reload --host 127.0.0.1 --port 8100
```
默认短信验证码为 `.env` 中的 `MOCK_SMS_CODE`,默认值是 `123456`
## 下一步
- 补管理员登录接口。
- 接入 Redis 替换内存短信验证码和 Token 黑名单。

View File

@@ -0,0 +1,37 @@
[alembic]
script_location = alembic
prepend_sys_path = .
sqlalchemy.url = mysql+pymysql://ai_kb:ai_kb@127.0.0.1:3306/ai_knowledge_base_v2?charset=utf8mb4
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARN
handlers = console
qualname =
[logger_sqlalchemy]
level = WARN
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s

View File

@@ -0,0 +1,51 @@
from __future__ import annotations
from logging.config import fileConfig
from alembic import context
from sqlalchemy import engine_from_config, pool
from app.core.config import get_settings
from app.models import Base
config = context.config
if config.config_file_name is not None:
fileConfig(config.config_file_name)
settings = get_settings()
config.set_main_option("sqlalchemy.url", settings.database_url)
target_metadata = Base.metadata
def run_migrations_offline() -> None:
context.configure(
url=settings.database_url,
target_metadata=target_metadata,
literal_binds=True,
dialect_opts={"paramstyle": "named"},
compare_type=True,
)
with context.begin_transaction():
context.run_migrations()
def run_migrations_online() -> None:
connectable = engine_from_config(
config.get_section(config.config_ini_section, {}),
prefix="sqlalchemy.",
poolclass=pool.NullPool,
)
with connectable.connect() as connection:
context.configure(connection=connection, target_metadata=target_metadata, compare_type=True)
with context.begin_transaction():
context.run_migrations()
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()

View File

@@ -0,0 +1 @@

View File

@@ -0,0 +1,226 @@
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
revision = "0001_initial_schema"
down_revision = None
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"sys_user",
sa.Column("id", sa.BigInteger(), autoincrement=True, nullable=False),
sa.Column("phone", sa.String(length=20), nullable=False),
sa.Column("name", sa.String(length=50), nullable=False),
sa.Column("nickname", sa.String(length=50), nullable=True),
sa.Column("status", sa.Integer(), nullable=False),
sa.Column("daily_chat_limit", sa.Integer(), nullable=False),
sa.Column("daily_chat_used", sa.Integer(), nullable=False),
sa.Column("effective_at", sa.DateTime(), nullable=True),
sa.Column("expired_at", sa.DateTime(), nullable=True),
sa.Column("last_login_at", sa.DateTime(), nullable=True),
sa.Column("created_at", sa.DateTime(), server_default=sa.text("now()"), nullable=False),
sa.Column("updated_at", sa.DateTime(), server_default=sa.text("now()"), nullable=False),
sa.Column("is_deleted", sa.Integer(), nullable=False),
sa.PrimaryKeyConstraint("id"),
)
op.create_index("ix_sys_user_phone", "sys_user", ["phone"], unique=True)
op.create_table(
"sys_role",
sa.Column("id", sa.BigInteger(), autoincrement=True, nullable=False),
sa.Column("code", sa.String(length=50), nullable=False),
sa.Column("name", sa.String(length=50), nullable=False),
sa.Column("remark", sa.String(length=255), nullable=True),
sa.Column("created_at", sa.DateTime(), server_default=sa.text("now()"), nullable=False),
sa.Column("updated_at", sa.DateTime(), server_default=sa.text("now()"), nullable=False),
sa.PrimaryKeyConstraint("id"),
)
op.create_index("ix_sys_role_code", "sys_role", ["code"], unique=True)
op.create_table(
"sys_admin",
sa.Column("id", sa.BigInteger(), autoincrement=True, nullable=False),
sa.Column("username", sa.String(length=50), nullable=False),
sa.Column("password", sa.String(length=255), nullable=False),
sa.Column("name", sa.String(length=50), nullable=False),
sa.Column("role_id", sa.BigInteger(), nullable=True),
sa.Column("status", sa.Integer(), nullable=False),
sa.Column("last_login_at", sa.DateTime(), nullable=True),
sa.Column("created_at", sa.DateTime(), server_default=sa.text("now()"), nullable=False),
sa.Column("updated_at", sa.DateTime(), server_default=sa.text("now()"), nullable=False),
sa.ForeignKeyConstraint(["role_id"], ["sys_role.id"]),
sa.PrimaryKeyConstraint("id"),
)
op.create_index("ix_sys_admin_username", "sys_admin", ["username"], unique=True)
op.create_table(
"sys_knowledge",
sa.Column("id", sa.BigInteger(), autoincrement=True, nullable=False),
sa.Column("name", sa.String(length=100), nullable=False),
sa.Column("feishu_space_id", sa.String(length=100), nullable=False),
sa.Column("feishu_node_id", sa.String(length=100), nullable=False),
sa.Column("status", sa.Integer(), nullable=False),
sa.Column("remark", sa.String(length=255), nullable=True),
sa.Column("created_at", sa.DateTime(), server_default=sa.text("now()"), nullable=False),
sa.Column("updated_at", sa.DateTime(), server_default=sa.text("now()"), nullable=False),
sa.PrimaryKeyConstraint("id"),
)
op.create_table(
"sys_user_kb",
sa.Column("id", sa.BigInteger(), autoincrement=True, nullable=False),
sa.Column("user_id", sa.BigInteger(), nullable=False),
sa.Column("knowledge_id", sa.BigInteger(), nullable=False),
sa.Column("effective_at", sa.DateTime(), nullable=True),
sa.Column("expired_at", sa.DateTime(), nullable=True),
sa.Column("created_by", sa.BigInteger(), nullable=True),
sa.Column("created_at", sa.DateTime(), server_default=sa.text("now()"), nullable=False),
sa.Column("updated_at", sa.DateTime(), server_default=sa.text("now()"), nullable=False),
sa.ForeignKeyConstraint(["knowledge_id"], ["sys_knowledge.id"]),
sa.ForeignKeyConstraint(["user_id"], ["sys_user.id"]),
sa.PrimaryKeyConstraint("id"),
)
op.create_index("ix_sys_user_kb_user_id", "sys_user_kb", ["user_id"])
op.create_index("ix_sys_user_kb_knowledge_id", "sys_user_kb", ["knowledge_id"])
op.create_table(
"sys_model",
sa.Column("id", sa.BigInteger(), autoincrement=True, nullable=False),
sa.Column("provider", sa.String(length=50), nullable=False),
sa.Column("model_name", sa.String(length=100), nullable=False),
sa.Column("api_url", sa.String(length=255), nullable=False),
sa.Column("api_key", sa.Text(), nullable=False),
sa.Column("temperature", sa.Numeric(4, 2), nullable=True),
sa.Column("max_token", sa.Integer(), nullable=True),
sa.Column("timeout_second", sa.Integer(), nullable=False),
sa.Column("enabled", sa.Integer(), nullable=False),
sa.PrimaryKeyConstraint("id"),
)
op.create_table(
"sys_prompt",
sa.Column("id", sa.BigInteger(), autoincrement=True, nullable=False),
sa.Column("prompt_content", sa.Text(), nullable=False),
sa.Column("updated_by", sa.BigInteger(), nullable=True),
sa.Column("updated_at", sa.DateTime(), server_default=sa.text("now()"), nullable=False),
sa.PrimaryKeyConstraint("id"),
)
op.create_table(
"sys_system_config",
sa.Column("id", sa.BigInteger(), autoincrement=True, nullable=False),
sa.Column("config_key", sa.String(length=100), nullable=False),
sa.Column("config_value", sa.Text(), nullable=False),
sa.Column("description", sa.String(length=255), nullable=True),
sa.Column("updated_by", sa.BigInteger(), nullable=True),
sa.Column("updated_at", sa.DateTime(), server_default=sa.text("now()"), nullable=False),
sa.PrimaryKeyConstraint("id"),
)
op.create_index("ix_sys_system_config_config_key", "sys_system_config", ["config_key"], unique=True)
op.create_table(
"sys_chat_session",
sa.Column("id", sa.BigInteger(), autoincrement=True, nullable=False),
sa.Column("user_id", sa.BigInteger(), nullable=False),
sa.Column("title", sa.String(length=100), nullable=False),
sa.Column("message_count", sa.Integer(), nullable=False),
sa.Column("last_message_at", sa.DateTime(), nullable=True),
sa.Column("is_deleted", sa.Integer(), nullable=False),
sa.Column("created_at", sa.DateTime(), server_default=sa.text("now()"), nullable=False),
sa.Column("updated_at", sa.DateTime(), server_default=sa.text("now()"), nullable=False),
sa.ForeignKeyConstraint(["user_id"], ["sys_user.id"]),
sa.PrimaryKeyConstraint("id"),
)
op.create_index("ix_sys_chat_session_user_id", "sys_chat_session", ["user_id"])
op.create_table(
"sys_chat_message",
sa.Column("id", sa.BigInteger(), autoincrement=True, nullable=False),
sa.Column("session_id", sa.BigInteger(), nullable=False),
sa.Column("user_id", sa.BigInteger(), nullable=False),
sa.Column("role", sa.String(length=20), nullable=False),
sa.Column("content", sa.Text(), nullable=False),
sa.Column("message_status", sa.String(length=20), nullable=False),
sa.Column("token_input", sa.Integer(), nullable=True),
sa.Column("token_output", sa.Integer(), nullable=True),
sa.Column("response_time_ms", sa.Integer(), nullable=True),
sa.Column("model_id", sa.BigInteger(), nullable=True),
sa.Column("created_at", sa.DateTime(), server_default=sa.text("now()"), nullable=False),
sa.ForeignKeyConstraint(["model_id"], ["sys_model.id"]),
sa.ForeignKeyConstraint(["session_id"], ["sys_chat_session.id"]),
sa.ForeignKeyConstraint(["user_id"], ["sys_user.id"]),
sa.PrimaryKeyConstraint("id"),
)
op.create_index("ix_sys_chat_message_session_id", "sys_chat_message", ["session_id"])
op.create_index("ix_sys_chat_message_user_id", "sys_chat_message", ["user_id"])
op.create_table(
"sys_ai_request_log",
sa.Column("id", sa.BigInteger(), autoincrement=True, nullable=False),
sa.Column("session_id", sa.BigInteger(), nullable=True),
sa.Column("message_id", sa.BigInteger(), nullable=True),
sa.Column("user_id", sa.BigInteger(), nullable=True),
sa.Column("model_name", sa.String(length=100), nullable=True),
sa.Column("prompt", sa.Text(), nullable=True),
sa.Column("knowledge_ids", sa.String(length=500), nullable=True),
sa.Column("retrieve_count", sa.Integer(), nullable=False),
sa.Column("input_token", sa.Integer(), nullable=True),
sa.Column("output_token", sa.Integer(), nullable=True),
sa.Column("total_token", sa.Integer(), nullable=True),
sa.Column("cost_ms", sa.Integer(), nullable=True),
sa.Column("status", sa.String(length=20), nullable=False),
sa.Column("error_message", sa.Text(), nullable=True),
sa.Column("created_at", sa.DateTime(), server_default=sa.text("now()"), nullable=False),
sa.PrimaryKeyConstraint("id"),
)
op.create_index("ix_sys_ai_request_log_session_id", "sys_ai_request_log", ["session_id"])
op.create_index("ix_sys_ai_request_log_message_id", "sys_ai_request_log", ["message_id"])
op.create_index("ix_sys_ai_request_log_user_id", "sys_ai_request_log", ["user_id"])
op.create_table(
"sys_operation_log",
sa.Column("id", sa.BigInteger(), autoincrement=True, nullable=False),
sa.Column("admin_id", sa.BigInteger(), nullable=True),
sa.Column("module", sa.String(length=50), nullable=False),
sa.Column("action", sa.String(length=50), nullable=False),
sa.Column("target_id", sa.BigInteger(), nullable=True),
sa.Column("before_json", sa.Text(), nullable=True),
sa.Column("after_json", sa.Text(), nullable=True),
sa.Column("ip", sa.String(length=50), nullable=True),
sa.Column("result", sa.String(length=20), nullable=False),
sa.Column("created_at", sa.DateTime(), server_default=sa.text("now()"), nullable=False),
sa.PrimaryKeyConstraint("id"),
)
op.create_index("ix_sys_operation_log_admin_id", "sys_operation_log", ["admin_id"])
def downgrade() -> None:
op.drop_index("ix_sys_operation_log_admin_id", table_name="sys_operation_log")
op.drop_table("sys_operation_log")
op.drop_index("ix_sys_ai_request_log_user_id", table_name="sys_ai_request_log")
op.drop_index("ix_sys_ai_request_log_message_id", table_name="sys_ai_request_log")
op.drop_index("ix_sys_ai_request_log_session_id", table_name="sys_ai_request_log")
op.drop_table("sys_ai_request_log")
op.drop_index("ix_sys_chat_message_user_id", table_name="sys_chat_message")
op.drop_index("ix_sys_chat_message_session_id", table_name="sys_chat_message")
op.drop_table("sys_chat_message")
op.drop_index("ix_sys_chat_session_user_id", table_name="sys_chat_session")
op.drop_table("sys_chat_session")
op.drop_index("ix_sys_system_config_config_key", table_name="sys_system_config")
op.drop_table("sys_system_config")
op.drop_table("sys_prompt")
op.drop_table("sys_model")
op.drop_index("ix_sys_user_kb_knowledge_id", table_name="sys_user_kb")
op.drop_index("ix_sys_user_kb_user_id", table_name="sys_user_kb")
op.drop_table("sys_user_kb")
op.drop_table("sys_knowledge")
op.drop_index("ix_sys_admin_username", table_name="sys_admin")
op.drop_table("sys_admin")
op.drop_index("ix_sys_role_code", table_name="sys_role")
op.drop_table("sys_role")
op.drop_index("ix_sys_user_phone", table_name="sys_user")
op.drop_table("sys_user")

View File

@@ -0,0 +1,56 @@
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
revision = "0002_expand_model_config"
down_revision = "0001_initial_schema"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column("sys_model", sa.Column("display_name", sa.String(length=100), nullable=True))
op.add_column(
"sys_model",
sa.Column("api_type", sa.String(length=50), nullable=False, server_default="openai_compatible"),
)
op.add_column("sys_model", sa.Column("base_url", sa.String(length=255), nullable=True))
op.add_column(
"sys_model",
sa.Column("auth_type", sa.String(length=30), nullable=False, server_default="bearer"),
)
op.add_column("sys_model", sa.Column("api_version", sa.String(length=50), nullable=True))
op.add_column("sys_model", sa.Column("top_p", sa.Numeric(4, 2), nullable=True))
op.add_column("sys_model", sa.Column("top_k", sa.Integer(), nullable=True))
op.add_column("sys_model", sa.Column("presence_penalty", sa.Numeric(4, 2), nullable=True))
op.add_column("sys_model", sa.Column("frequency_penalty", sa.Numeric(4, 2), nullable=True))
op.add_column("sys_model", sa.Column("context_window", sa.Integer(), nullable=True))
op.add_column(
"sys_model",
sa.Column("stream_enabled", sa.Integer(), nullable=False, server_default="1"),
)
op.add_column("sys_model", sa.Column("response_format", sa.String(length=50), nullable=True))
op.add_column("sys_model", sa.Column("extra_params", sa.Text(), nullable=True))
op.add_column("sys_model", sa.Column("remark", sa.String(length=255), nullable=True))
op.alter_column("sys_model", "api_type", server_default=None)
op.alter_column("sys_model", "auth_type", server_default=None)
op.alter_column("sys_model", "stream_enabled", server_default=None)
def downgrade() -> None:
op.drop_column("sys_model", "remark")
op.drop_column("sys_model", "extra_params")
op.drop_column("sys_model", "response_format")
op.drop_column("sys_model", "stream_enabled")
op.drop_column("sys_model", "context_window")
op.drop_column("sys_model", "frequency_penalty")
op.drop_column("sys_model", "presence_penalty")
op.drop_column("sys_model", "top_k")
op.drop_column("sys_model", "top_p")
op.drop_column("sys_model", "api_version")
op.drop_column("sys_model", "auth_type")
op.drop_column("sys_model", "base_url")
op.drop_column("sys_model", "api_type")
op.drop_column("sys_model", "display_name")

View File

@@ -0,0 +1,17 @@
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
revision = "0003_ai_log_retrieved_chunks"
down_revision = "0002_expand_model_config"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column("sys_ai_request_log", sa.Column("retrieved_chunks", sa.Text(), nullable=True))
def downgrade() -> None:
op.drop_column("sys_ai_request_log", "retrieved_chunks")

View File

@@ -0,0 +1,17 @@
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
revision = "0004_daily_chat_reset_date"
down_revision = "0003_ai_log_retrieved_chunks"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column("sys_user", sa.Column("daily_chat_reset_date", sa.Date(), nullable=True))
def downgrade() -> None:
op.drop_column("sys_user", "daily_chat_reset_date")

View File

@@ -0,0 +1,19 @@
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
revision = "0005_chat_session_summary"
down_revision = "0004_daily_chat_reset_date"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column("sys_chat_session", sa.Column("summary", sa.Text(), nullable=True))
op.add_column("sys_chat_session", sa.Column("summary_up_to_message_id", sa.BigInteger(), nullable=True))
def downgrade() -> None:
op.drop_column("sys_chat_session", "summary_up_to_message_id")
op.drop_column("sys_chat_session", "summary")

View File

@@ -0,0 +1,44 @@
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import mysql
revision = "0006_expand_ai_request_log_text"
down_revision = "0005_chat_session_summary"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.alter_column(
"sys_ai_request_log",
"prompt",
existing_type=sa.Text(),
type_=mysql.MEDIUMTEXT(),
existing_nullable=True,
)
op.alter_column(
"sys_ai_request_log",
"retrieved_chunks",
existing_type=sa.Text(),
type_=mysql.MEDIUMTEXT(),
existing_nullable=True,
)
def downgrade() -> None:
op.alter_column(
"sys_ai_request_log",
"retrieved_chunks",
existing_type=mysql.MEDIUMTEXT(),
type_=sa.Text(),
existing_nullable=True,
)
op.alter_column(
"sys_ai_request_log",
"prompt",
existing_type=mysql.MEDIUMTEXT(),
type_=sa.Text(),
existing_nullable=True,
)

View File

@@ -0,0 +1,319 @@
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import mysql
revision = "0007_knowledge_agent_rebuild"
down_revision = "0006_expand_ai_request_log_text"
branch_labels = None
depends_on = None
def upgrade() -> None:
for column in [
sa.Column("source_title", sa.String(255), nullable=True),
sa.Column("knowledge_type", sa.String(30), nullable=False, server_default="general"),
sa.Column("review_mode", sa.String(30), nullable=False, server_default="manual"),
sa.Column("lifecycle_status", sa.String(30), nullable=False, server_default="active"),
sa.Column("source_status", sa.String(30), nullable=False, server_default="normal"),
sa.Column("source_error", mysql.MEDIUMTEXT(), nullable=True),
sa.Column("manifest_confirmed", sa.Integer(), nullable=False, server_default="0"),
sa.Column("current_version_id", sa.BigInteger(), nullable=True),
sa.Column("pending_version_id", sa.BigInteger(), nullable=True),
sa.Column("last_synced_at", sa.DateTime(), nullable=True),
sa.Column("last_published_at", sa.DateTime(), nullable=True),
sa.Column("migration_status", sa.String(30), nullable=False, server_default="pending"),
]:
op.add_column("sys_knowledge", column)
op.create_table(
"sys_knowledge_source_snapshot",
sa.Column("id", sa.BigInteger(), autoincrement=True, nullable=False),
sa.Column("knowledge_id", sa.BigInteger(), nullable=False),
sa.Column("sync_job_id", sa.BigInteger(), nullable=True),
sa.Column("source_title", sa.String(255), nullable=False),
sa.Column("source_content", mysql.LONGTEXT(), nullable=False),
sa.Column("content_hash", sa.String(64), nullable=False),
sa.Column("source_identifier", sa.String(255), nullable=False),
sa.Column("fetched_at", sa.DateTime(), server_default=sa.text("now()"), nullable=False),
sa.Column("created_by", sa.BigInteger(), nullable=True),
sa.ForeignKeyConstraint(["knowledge_id"], ["sys_knowledge.id"]),
sa.PrimaryKeyConstraint("id"),
)
_indexes("sys_knowledge_source_snapshot", "knowledge_id", "sync_job_id", "content_hash")
op.create_table(
"sys_knowledge_version",
sa.Column("id", sa.BigInteger(), autoincrement=True, nullable=False),
sa.Column("knowledge_id", sa.BigInteger(), nullable=False),
sa.Column("snapshot_id", sa.BigInteger(), nullable=False),
sa.Column("version_no", sa.Integer(), nullable=False),
sa.Column("status", sa.String(30), nullable=False),
sa.Column("quality_status", sa.String(30), nullable=False, server_default="pending"),
sa.Column("change_summary", mysql.MEDIUMTEXT(), nullable=True),
sa.Column("quality_report", mysql.MEDIUMTEXT(), nullable=True),
sa.Column("processing_rule_version", sa.String(50), nullable=False),
sa.Column("generation_model", sa.String(100), nullable=True),
sa.Column("created_by", sa.BigInteger(), nullable=True),
sa.Column("created_at", sa.DateTime(), server_default=sa.text("now()"), nullable=False),
sa.Column("published_by", sa.BigInteger(), nullable=True),
sa.Column("published_at", sa.DateTime(), nullable=True),
sa.ForeignKeyConstraint(["knowledge_id"], ["sys_knowledge.id"]),
sa.ForeignKeyConstraint(["snapshot_id"], ["sys_knowledge_source_snapshot.id"]),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("knowledge_id", "version_no", name="uq_kb_version_no"),
)
_indexes("sys_knowledge_version", "knowledge_id", "status")
op.create_table(
"sys_knowledge_manifest",
sa.Column("id", sa.BigInteger(), autoincrement=True, nullable=False),
sa.Column("knowledge_id", sa.BigInteger(), nullable=False),
sa.Column("version_id", sa.BigInteger(), nullable=False),
sa.Column("purpose", mysql.MEDIUMTEXT(), nullable=False),
sa.Column("applicable_questions", mysql.MEDIUMTEXT(), nullable=False),
sa.Column("inapplicable_questions", mysql.MEDIUMTEXT(), nullable=False),
sa.Column("core_topics", mysql.MEDIUMTEXT(), nullable=False),
sa.Column("entities", mysql.MEDIUMTEXT(), nullable=True),
sa.Column("boundaries", mysql.MEDIUMTEXT(), nullable=True),
sa.Column("content_hash", sa.String(64), nullable=False),
sa.Column("change_level", sa.String(20), nullable=False, server_default="major"),
sa.Column("confirmed", sa.Integer(), nullable=False, server_default="0"),
sa.Column("confirmed_by", sa.BigInteger(), nullable=True),
sa.Column("confirmed_at", sa.DateTime(), nullable=True),
sa.ForeignKeyConstraint(["knowledge_id"], ["sys_knowledge.id"]),
sa.ForeignKeyConstraint(["version_id"], ["sys_knowledge_version.id"]),
sa.PrimaryKeyConstraint("id"),
)
_indexes("sys_knowledge_manifest", "knowledge_id", "version_id")
op.create_table(
"sys_knowledge_section",
sa.Column("id", sa.BigInteger(), autoincrement=True, nullable=False),
sa.Column("knowledge_id", sa.BigInteger(), nullable=False),
sa.Column("version_id", sa.BigInteger(), nullable=False),
sa.Column("section_key", sa.String(64), nullable=False),
sa.Column("title", sa.String(255), nullable=False),
sa.Column("content", mysql.LONGTEXT(), nullable=False),
sa.Column("source_start", sa.Integer(), nullable=False),
sa.Column("source_end", sa.Integer(), nullable=False),
sa.Column("sort_order", sa.Integer(), nullable=False),
sa.Column("content_hash", sa.String(64), nullable=False),
sa.ForeignKeyConstraint(["knowledge_id"], ["sys_knowledge.id"]),
sa.ForeignKeyConstraint(["version_id"], ["sys_knowledge_version.id"]),
sa.PrimaryKeyConstraint("id"),
)
_indexes("sys_knowledge_section", "knowledge_id", "version_id")
op.create_table(
"sys_knowledge_chunk",
sa.Column("id", sa.BigInteger(), autoincrement=True, nullable=False),
sa.Column("knowledge_id", sa.BigInteger(), nullable=False),
sa.Column("version_id", sa.BigInteger(), nullable=False),
sa.Column("section_id", sa.BigInteger(), nullable=False),
sa.Column("title", sa.String(255), nullable=False),
sa.Column("content", mysql.MEDIUMTEXT(), nullable=False),
sa.Column("normalized_text", mysql.MEDIUMTEXT(), nullable=False),
sa.Column("keywords", mysql.MEDIUMTEXT(), nullable=True),
sa.Column("synonyms", mysql.MEDIUMTEXT(), nullable=True),
sa.Column("source_start", sa.Integer(), nullable=False),
sa.Column("source_end", sa.Integer(), nullable=False),
sa.Column("sort_order", sa.Integer(), nullable=False),
sa.Column("content_hash", sa.String(64), nullable=False),
sa.ForeignKeyConstraint(["knowledge_id"], ["sys_knowledge.id"]),
sa.ForeignKeyConstraint(["version_id"], ["sys_knowledge_version.id"]),
sa.ForeignKeyConstraint(["section_id"], ["sys_knowledge_section.id"]),
sa.PrimaryKeyConstraint("id"),
)
_indexes("sys_knowledge_chunk", "knowledge_id", "version_id", "section_id")
op.create_table(
"sys_knowledge_card",
sa.Column("id", sa.BigInteger(), autoincrement=True, nullable=False),
sa.Column("knowledge_id", sa.BigInteger(), nullable=False),
sa.Column("version_id", sa.BigInteger(), nullable=False),
sa.Column("section_id", sa.BigInteger(), nullable=False),
sa.Column("title", sa.String(255), nullable=False),
sa.Column("summary", mysql.MEDIUMTEXT(), nullable=False),
sa.Column("core_conclusion", mysql.MEDIUMTEXT(), nullable=False),
sa.Column("applicable_questions", mysql.MEDIUMTEXT(), nullable=False),
sa.Column("inapplicable_questions", mysql.MEDIUMTEXT(), nullable=False),
sa.Column("keywords", mysql.MEDIUMTEXT(), nullable=False),
sa.Column("synonyms", mysql.MEDIUMTEXT(), nullable=True),
sa.Column("entities", mysql.MEDIUMTEXT(), nullable=True),
sa.Column("risk_level", sa.String(20), nullable=False, server_default="normal"),
sa.Column("source_range", sa.String(100), nullable=False),
sa.Column("content_hash", sa.String(64), nullable=False),
sa.Column("generation_model", sa.String(100), nullable=True),
sa.Column("generation_rule_version", sa.String(50), nullable=False),
sa.Column("review_status", sa.String(20), nullable=False, server_default="pending"),
sa.ForeignKeyConstraint(["knowledge_id"], ["sys_knowledge.id"]),
sa.ForeignKeyConstraint(["version_id"], ["sys_knowledge_version.id"]),
sa.ForeignKeyConstraint(["section_id"], ["sys_knowledge_section.id"]),
sa.PrimaryKeyConstraint("id"),
)
_indexes("sys_knowledge_card", "knowledge_id", "version_id", "section_id")
_create_job_and_log_tables()
def _create_job_and_log_tables() -> None:
op.create_table(
"sys_knowledge_sync_job",
sa.Column("id", sa.BigInteger(), autoincrement=True, nullable=False),
sa.Column("knowledge_id", sa.BigInteger(), nullable=False),
sa.Column("mode", sa.String(30), nullable=False),
sa.Column("status", sa.String(30), nullable=False),
sa.Column("stage", sa.String(50), nullable=False),
sa.Column("progress", sa.Integer(), nullable=False, server_default="0"),
sa.Column("source_error", mysql.MEDIUMTEXT(), nullable=True),
sa.Column("result_summary", mysql.MEDIUMTEXT(), nullable=True),
sa.Column("requested_by", sa.BigInteger(), nullable=False),
sa.Column("created_at", sa.DateTime(), server_default=sa.text("now()"), nullable=False),
sa.Column("started_at", sa.DateTime(), nullable=True),
sa.Column("finished_at", sa.DateTime(), nullable=True),
sa.ForeignKeyConstraint(["knowledge_id"], ["sys_knowledge.id"]),
sa.PrimaryKeyConstraint("id"),
)
_indexes("sys_knowledge_sync_job", "knowledge_id", "status")
op.create_table(
"sys_knowledge_review_record",
sa.Column("id", sa.BigInteger(), autoincrement=True, nullable=False),
sa.Column("knowledge_id", sa.BigInteger(), nullable=False),
sa.Column("version_id", sa.BigInteger(), nullable=False),
sa.Column("card_id", sa.BigInteger(), nullable=True),
sa.Column("action", sa.String(30), nullable=False),
sa.Column("reason", mysql.MEDIUMTEXT(), nullable=True),
sa.Column("reviewed_by", sa.BigInteger(), nullable=False),
sa.Column("created_at", sa.DateTime(), server_default=sa.text("now()"), nullable=False),
sa.ForeignKeyConstraint(["knowledge_id"], ["sys_knowledge.id"]),
sa.ForeignKeyConstraint(["version_id"], ["sys_knowledge_version.id"]),
sa.ForeignKeyConstraint(["card_id"], ["sys_knowledge_card.id"]),
sa.PrimaryKeyConstraint("id"),
)
_indexes("sys_knowledge_review_record", "knowledge_id", "version_id")
op.create_table(
"sys_knowledge_publish_log",
sa.Column("id", sa.BigInteger(), autoincrement=True, nullable=False),
sa.Column("knowledge_id", sa.BigInteger(), nullable=False),
sa.Column("from_version_id", sa.BigInteger(), nullable=True),
sa.Column("to_version_id", sa.BigInteger(), nullable=True),
sa.Column("action", sa.String(30), nullable=False),
sa.Column("keep_open", sa.Integer(), nullable=False, server_default="0"),
sa.Column("detail_json", mysql.MEDIUMTEXT(), nullable=True),
sa.Column("operated_by", sa.BigInteger(), nullable=False),
sa.Column("created_at", sa.DateTime(), server_default=sa.text("now()"), nullable=False),
sa.ForeignKeyConstraint(["knowledge_id"], ["sys_knowledge.id"]),
sa.PrimaryKeyConstraint("id"),
)
_indexes("sys_knowledge_publish_log", "knowledge_id")
op.create_table(
"sys_knowledge_retrieval_log",
sa.Column("id", sa.BigInteger(), autoincrement=True, nullable=False),
sa.Column("session_id", sa.BigInteger(), nullable=True),
sa.Column("message_id", sa.BigInteger(), nullable=True),
sa.Column("user_id", sa.BigInteger(), nullable=True),
sa.Column("question", mysql.MEDIUMTEXT(), nullable=False),
sa.Column("catalog_snapshot", mysql.LONGTEXT(), nullable=False),
sa.Column("catalog_hash", sa.String(64), nullable=False),
sa.Column("safety_rule_version", sa.String(50), nullable=False),
sa.Column("knowledge_called", sa.Integer(), nullable=False, server_default="0"),
sa.Column("selected_knowledge_ids", sa.String(500), nullable=True),
sa.Column("query_terms", mysql.MEDIUMTEXT(), nullable=True),
sa.Column("tool_trace", mysql.LONGTEXT(), nullable=True),
sa.Column("final_section_ids", sa.String(500), nullable=True),
sa.Column("discarded_conflicts", mysql.MEDIUMTEXT(), nullable=True),
sa.Column("final_answer", mysql.LONGTEXT(), nullable=True),
sa.Column("status", sa.String(20), nullable=False),
sa.Column("tool_cost_ms", sa.Integer(), nullable=True),
sa.Column("total_cost_ms", sa.Integer(), nullable=True),
sa.Column("error_message", mysql.MEDIUMTEXT(), nullable=True),
sa.Column("attention_created", sa.Integer(), nullable=False, server_default="0"),
sa.Column("created_at", sa.DateTime(), server_default=sa.text("now()"), nullable=False),
sa.PrimaryKeyConstraint("id"),
)
_indexes("sys_knowledge_retrieval_log", "session_id", "message_id", "user_id")
op.create_table(
"sys_knowledge_retrieval_candidate",
sa.Column("id", sa.BigInteger(), autoincrement=True, nullable=False),
sa.Column("retrieval_log_id", sa.BigInteger(), nullable=False),
sa.Column("knowledge_id", sa.BigInteger(), nullable=False),
sa.Column("version_id", sa.BigInteger(), nullable=False),
sa.Column("chunk_id", sa.BigInteger(), nullable=False),
sa.Column("section_id", sa.BigInteger(), nullable=False),
sa.Column("lexical_score", sa.String(30), nullable=False),
sa.Column("rerank_score", sa.String(30), nullable=True),
sa.Column("selected", sa.Integer(), nullable=False, server_default="0"),
sa.Column("discard_reason", sa.String(255), nullable=True),
sa.ForeignKeyConstraint(["retrieval_log_id"], ["sys_knowledge_retrieval_log.id"]),
sa.PrimaryKeyConstraint("id"),
)
_indexes("sys_knowledge_retrieval_candidate", "retrieval_log_id", "knowledge_id", "version_id")
op.create_table(
"sys_human_attention_record",
sa.Column("id", sa.BigInteger(), autoincrement=True, nullable=False),
sa.Column("session_id", sa.BigInteger(), nullable=False),
sa.Column("message_id", sa.BigInteger(), nullable=False),
sa.Column("user_id", sa.BigInteger(), nullable=False),
sa.Column("trigger_message", mysql.MEDIUMTEXT(), nullable=False),
sa.Column("problem_summary", mysql.MEDIUMTEXT(), nullable=False),
sa.Column("trigger_reason", mysql.MEDIUMTEXT(), nullable=False),
sa.Column("priority", sa.String(20), nullable=False),
sa.Column("status", sa.String(20), nullable=False, server_default="pending"),
sa.Column("handler_id", sa.BigInteger(), nullable=True),
sa.Column("handler_note", mysql.MEDIUMTEXT(), nullable=True),
sa.Column("created_at", sa.DateTime(), server_default=sa.text("now()"), nullable=False),
sa.Column("updated_at", sa.DateTime(), server_default=sa.text("now()"), nullable=False),
sa.PrimaryKeyConstraint("id"),
)
_indexes("sys_human_attention_record", "session_id", "message_id", "user_id", "priority", "status")
op.create_table(
"sys_human_attention_history",
sa.Column("id", sa.BigInteger(), autoincrement=True, nullable=False),
sa.Column("attention_id", sa.BigInteger(), nullable=False),
sa.Column("from_status", sa.String(20), nullable=True),
sa.Column("to_status", sa.String(20), nullable=False),
sa.Column("note", mysql.MEDIUMTEXT(), nullable=True),
sa.Column("operated_by", sa.BigInteger(), nullable=False),
sa.Column("created_at", sa.DateTime(), server_default=sa.text("now()"), nullable=False),
sa.ForeignKeyConstraint(["attention_id"], ["sys_human_attention_record.id"]),
sa.PrimaryKeyConstraint("id"),
)
_indexes("sys_human_attention_history", "attention_id")
def _indexes(table: str, *columns: str) -> None:
for column in columns:
op.create_index(f"ix_{table}_{column}", table, [column])
def downgrade() -> None:
for table in [
"sys_human_attention_history",
"sys_human_attention_record",
"sys_knowledge_retrieval_candidate",
"sys_knowledge_retrieval_log",
"sys_knowledge_publish_log",
"sys_knowledge_review_record",
"sys_knowledge_sync_job",
"sys_knowledge_card",
"sys_knowledge_chunk",
"sys_knowledge_section",
"sys_knowledge_manifest",
"sys_knowledge_version",
"sys_knowledge_source_snapshot",
]:
op.drop_table(table)
for column in [
"migration_status", "last_published_at", "last_synced_at", "pending_version_id",
"current_version_id", "manifest_confirmed", "source_error", "source_status",
"lifecycle_status", "review_mode", "knowledge_type", "source_title",
]:
op.drop_column("sys_knowledge", column)

View File

@@ -0,0 +1,29 @@
from __future__ import annotations
from alembic import context, op
import sqlalchemy as sa
revision = "0008_unique_kb_node"
down_revision = "0007_knowledge_agent_rebuild"
branch_labels = None
depends_on = None
def upgrade() -> None:
constraints = set() if context.is_offline_mode() else {
item["name"] for item in sa.inspect(op.get_bind()).get_unique_constraints("sys_knowledge")
}
if "uq_knowledge_feishu_node" not in constraints:
op.create_unique_constraint(
"uq_knowledge_feishu_node",
"sys_knowledge",
["feishu_space_id", "feishu_node_id"],
)
def downgrade() -> None:
constraints = {"uq_knowledge_feishu_node"} if context.is_offline_mode() else {
item["name"] for item in sa.inspect(op.get_bind()).get_unique_constraints("sys_knowledge")
}
if "uq_knowledge_feishu_node" in constraints:
op.drop_constraint("uq_knowledge_feishu_node", "sys_knowledge", type_="unique")

View File

@@ -0,0 +1,62 @@
"""log retention, storage snapshots and audit indexes
Revision ID: 0009_logs_storage
Revises: 0008_unique_kb_node
"""
from alembic import context, op
import sqlalchemy as sa
revision = "0009_logs_storage"
down_revision = "0008_unique_kb_node"
branch_labels = None
depends_on = None
def upgrade() -> None:
inspector = None if context.is_offline_mode() else sa.inspect(op.get_bind())
tables = set() if inspector is None else set(inspector.get_table_names())
if "sys_log_retention_policy" not in tables:
op.create_table(
"sys_log_retention_policy",
sa.Column("id", sa.BigInteger(), primary_key=True, autoincrement=True),
sa.Column("retention_days", sa.Integer(), nullable=True),
sa.Column("enabled", sa.Integer(), nullable=False, server_default="0"),
sa.Column("last_run_at", sa.DateTime(), nullable=True),
sa.Column("last_result", sa.Text(), nullable=True),
sa.Column("updated_by", sa.BigInteger(), nullable=True),
sa.Column("updated_at", sa.DateTime(), server_default=sa.func.now(), nullable=False),
)
if "sys_storage_snapshot" not in tables:
op.create_table(
"sys_storage_snapshot",
sa.Column("id", sa.BigInteger(), primary_key=True, autoincrement=True),
sa.Column("scope", sa.String(50), nullable=False),
sa.Column("total_bytes", sa.BigInteger(), nullable=True),
sa.Column("detail_json", sa.Text(), nullable=False),
sa.Column("status", sa.String(20), nullable=False),
sa.Column("error_message", sa.Text(), nullable=True),
sa.Column("created_at", sa.DateTime(), server_default=sa.func.now(), nullable=False),
)
_create_index_if_missing("sys_knowledge_retrieval_log", "ix_retrieval_status_created", ["status", "created_at"])
_create_index_if_missing("sys_knowledge_retrieval_log", "ix_retrieval_created_id", ["created_at", "id"])
_create_index_if_missing("sys_ai_request_log", "ix_ai_log_status_created", ["status", "created_at"])
_create_index_if_missing("sys_operation_log", "ix_operation_module_created", ["module", "created_at"])
_create_index_if_missing("sys_chat_session", "ix_chat_session_deleted_updated", ["is_deleted", "updated_at"])
def _create_index_if_missing(table: str, name: str, columns: list[str]) -> None:
indexes = set() if context.is_offline_mode() else {
item["name"] for item in sa.inspect(op.get_bind()).get_indexes(table)
}
if name not in indexes:
op.create_index(name, table, columns)
def downgrade() -> None:
op.drop_index("ix_chat_session_deleted_updated", table_name="sys_chat_session")
op.drop_index("ix_operation_module_created", table_name="sys_operation_log")
op.drop_index("ix_ai_log_status_created", table_name="sys_ai_request_log")
op.drop_index("ix_retrieval_created_id", table_name="sys_knowledge_retrieval_log")
op.drop_index("ix_retrieval_status_created", table_name="sys_knowledge_retrieval_log")
op.drop_table("sys_storage_snapshot")
op.drop_table("sys_log_retention_policy")

View File

@@ -0,0 +1,29 @@
"""add prompt history metadata and ordering index
Revision ID: 0010_prompt_history_management
Revises: 0009_logs_storage
"""
from alembic import op
import sqlalchemy as sa
revision = "0010_prompt_history_management"
down_revision = "0009_logs_storage"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column(
"sys_prompt",
sa.Column("change_type", sa.String(length=20), nullable=False, server_default="save"),
)
op.add_column("sys_prompt", sa.Column("source_prompt_id", sa.BigInteger(), nullable=True))
op.create_index("ix_sys_prompt_updated_at_id", "sys_prompt", ["updated_at", "id"])
def downgrade() -> None:
op.drop_index("ix_sys_prompt_updated_at_id", table_name="sys_prompt")
op.drop_column("sys_prompt", "source_prompt_id")
op.drop_column("sys_prompt", "change_type")

View File

@@ -0,0 +1,37 @@
"""make knowledge synchronization automatically publish
Revision ID: 0011_knowledge_auto_publish
Revises: 0010_prompt_history_management
"""
from alembic import op
import sqlalchemy as sa
revision = "0011_knowledge_auto_publish"
down_revision = "0010_prompt_history_management"
branch_labels = None
depends_on = None
def upgrade() -> None:
# Keep the legacy column for backward compatibility while removing manual
# review from the active workflow and API.
op.execute("UPDATE sys_knowledge SET review_mode = 'automatic'")
op.alter_column(
"sys_knowledge",
"review_mode",
existing_type=sa.String(length=30),
existing_nullable=False,
server_default="automatic",
)
def downgrade() -> None:
op.alter_column(
"sys_knowledge",
"review_mode",
existing_type=sa.String(length=30),
existing_nullable=False,
server_default="manual",
)

View File

@@ -0,0 +1,55 @@
"""keep only the latest knowledge version
Revision ID: 0012_knowledge_latest_only
Revises: 0011_knowledge_auto_publish
"""
from alembic import op
revision = "0012_knowledge_latest_only"
down_revision = "0011_knowledge_auto_publish"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.execute("UPDATE sys_knowledge SET pending_version_id = NULL")
for table in (
"sys_knowledge_review_record",
"sys_knowledge_card",
"sys_knowledge_chunk",
"sys_knowledge_section",
"sys_knowledge_manifest",
):
op.execute(
f"""
DELETE detail
FROM {table} AS detail
JOIN sys_knowledge AS knowledge ON knowledge.id = detail.knowledge_id
WHERE knowledge.current_version_id IS NULL
OR detail.version_id <> knowledge.current_version_id
"""
)
op.execute(
"""
DELETE version
FROM sys_knowledge_version AS version
JOIN sys_knowledge AS knowledge ON knowledge.id = version.knowledge_id
WHERE knowledge.current_version_id IS NULL
OR version.id <> knowledge.current_version_id
"""
)
op.execute(
"""
DELETE snapshot
FROM sys_knowledge_source_snapshot AS snapshot
LEFT JOIN sys_knowledge_version AS version ON version.snapshot_id = snapshot.id
WHERE version.id IS NULL
"""
)
def downgrade() -> None:
# Removed historical content cannot be reconstructed from the latest version.
pass

View File

@@ -0,0 +1,34 @@
"""add question insight message index
Revision ID: 0013_question_insight_indexes
Revises: 0012_knowledge_latest_only
"""
from alembic import context, op
import sqlalchemy as sa
revision = "0013_question_insight_indexes"
down_revision = "0012_knowledge_latest_only"
branch_labels = None
depends_on = None
def upgrade() -> None:
_create_index_if_missing("sys_chat_message", "ix_chat_message_role_created", ["role", "created_at"])
def downgrade() -> None:
indexes = {"ix_chat_message_role_created"} if context.is_offline_mode() else {
item["name"] for item in sa.inspect(op.get_bind()).get_indexes("sys_chat_message")
}
if "ix_chat_message_role_created" in indexes:
op.drop_index("ix_chat_message_role_created", table_name="sys_chat_message")
def _create_index_if_missing(table: str, name: str, columns: list[str]) -> None:
indexes = set() if context.is_offline_mode() else {
item["name"] for item in sa.inspect(op.get_bind()).get_indexes(table)
}
if name not in indexes:
op.create_index(name, table, columns)

View File

@@ -0,0 +1,217 @@
"""add entitlement plans and topic sessions
Revision ID: 0014_entitlements_topics
Revises: 0013_question_insight_indexes
"""
from alembic import context, op
import sqlalchemy as sa
revision = "0014_entitlements_topics"
down_revision = "0013_question_insight_indexes"
branch_labels = None
depends_on = None
PRIMARY_KEY_TYPE = sa.BigInteger().with_variant(sa.Integer(), "sqlite")
def upgrade() -> None:
inspector = None if context.is_offline_mode() else sa.inspect(op.get_bind())
tables = set() if inspector is None else set(inspector.get_table_names())
if "sys_entitlement_plan" not in tables:
op.create_table(
"sys_entitlement_plan",
sa.Column("id", PRIMARY_KEY_TYPE, primary_key=True, autoincrement=True),
sa.Column("name", sa.String(80), nullable=False),
sa.Column("plan_type", sa.String(30), nullable=False),
sa.Column("description", sa.String(255), nullable=True),
sa.Column("validity_days", sa.Integer(), nullable=True),
sa.Column("monthly_topic_limit", sa.Integer(), nullable=True),
sa.Column("enable_growth_profile", sa.Integer(), nullable=False, server_default="0"),
sa.Column("enable_periodic_reports", sa.Integer(), nullable=False, server_default="0"),
sa.Column("allow_help_card", sa.Integer(), nullable=False, server_default="1"),
sa.Column("allow_share_draft", sa.Integer(), nullable=False, server_default="1"),
sa.Column("deduct_quota", sa.Integer(), nullable=False, server_default="1"),
sa.Column("status", sa.Integer(), nullable=False, server_default="1"),
sa.Column("sort_order", sa.Integer(), nullable=False, server_default="0"),
sa.Column("created_at", sa.DateTime(), server_default=sa.func.now(), nullable=False),
sa.Column("updated_at", sa.DateTime(), server_default=sa.func.now(), nullable=False),
)
op.create_index("ix_sys_entitlement_plan_plan_type", "sys_entitlement_plan", ["plan_type"])
op.create_index("ix_sys_entitlement_plan_status", "sys_entitlement_plan", ["status"])
_seed_default_plans()
if "sys_user_entitlement" not in tables:
op.create_table(
"sys_user_entitlement",
sa.Column("id", PRIMARY_KEY_TYPE, primary_key=True, autoincrement=True),
sa.Column("user_id", sa.BigInteger(), sa.ForeignKey("sys_user.id"), nullable=False),
sa.Column("plan_id", sa.BigInteger(), sa.ForeignKey("sys_entitlement_plan.id"), nullable=False),
sa.Column("status", sa.String(20), nullable=False, server_default="active"),
sa.Column("effective_at", sa.DateTime(), nullable=True),
sa.Column("expired_at", sa.DateTime(), nullable=True),
sa.Column("assigned_by", sa.BigInteger(), nullable=True),
sa.Column("remark", sa.String(255), nullable=True),
sa.Column("created_at", sa.DateTime(), server_default=sa.func.now(), nullable=False),
sa.Column("updated_at", sa.DateTime(), server_default=sa.func.now(), nullable=False),
)
op.create_index("ix_sys_user_entitlement_user_id", "sys_user_entitlement", ["user_id"])
op.create_index("ix_sys_user_entitlement_plan_id", "sys_user_entitlement", ["plan_id"])
op.create_index("ix_sys_user_entitlement_status", "sys_user_entitlement", ["status"])
if "sys_user_entitlement_log" not in tables:
op.create_table(
"sys_user_entitlement_log",
sa.Column("id", PRIMARY_KEY_TYPE, primary_key=True, autoincrement=True),
sa.Column("user_id", sa.BigInteger(), nullable=False),
sa.Column("entitlement_id", sa.BigInteger(), nullable=True),
sa.Column("from_plan_id", sa.BigInteger(), nullable=True),
sa.Column("to_plan_id", sa.BigInteger(), nullable=True),
sa.Column("action", sa.String(30), nullable=False),
sa.Column("detail_json", sa.Text(), nullable=True),
sa.Column("operated_by", sa.BigInteger(), nullable=True),
sa.Column("created_at", sa.DateTime(), nullable=False),
)
op.create_index("ix_sys_user_entitlement_log_user_id", "sys_user_entitlement_log", ["user_id"])
if "sys_topic_session" not in tables:
op.create_table(
"sys_topic_session",
sa.Column("id", PRIMARY_KEY_TYPE, primary_key=True, autoincrement=True),
sa.Column("user_id", sa.BigInteger(), sa.ForeignKey("sys_user.id"), nullable=False),
sa.Column("chat_session_id", sa.BigInteger(), sa.ForeignKey("sys_chat_session.id"), nullable=False),
sa.Column("title", sa.String(120), nullable=False),
sa.Column("core_question", sa.Text(), nullable=False),
sa.Column("status", sa.String(20), nullable=False, server_default="active"),
sa.Column("message_count", sa.Integer(), nullable=False, server_default="0"),
sa.Column("token_input", sa.Integer(), nullable=False, server_default="0"),
sa.Column("token_output", sa.Integer(), nullable=False, server_default="0"),
sa.Column("quota_deducted", sa.Integer(), nullable=False, server_default="0"),
sa.Column("recommended_homework", sa.Text(), nullable=True),
sa.Column("help_card_generated", sa.Integer(), nullable=False, server_default="0"),
sa.Column("share_draft_generated", sa.Integer(), nullable=False, server_default="0"),
sa.Column("started_at", sa.DateTime(), server_default=sa.func.now(), nullable=False),
sa.Column("ended_at", sa.DateTime(), nullable=True),
sa.Column("created_at", sa.DateTime(), server_default=sa.func.now(), nullable=False),
sa.Column("updated_at", sa.DateTime(), server_default=sa.func.now(), nullable=False),
)
op.create_index("ix_sys_topic_session_user_id", "sys_topic_session", ["user_id"])
op.create_index("ix_sys_topic_session_chat_session_id", "sys_topic_session", ["chat_session_id"])
op.create_index("ix_sys_topic_session_status", "sys_topic_session", ["status"])
columns = set() if inspector is None else {column["name"] for column in inspector.get_columns("sys_chat_message")}
if "topic_session_id" not in columns:
op.add_column("sys_chat_message", sa.Column("topic_session_id", sa.BigInteger(), nullable=True))
op.create_index("ix_sys_chat_message_topic_session_id", "sys_chat_message", ["topic_session_id"])
def downgrade() -> None:
inspector = None if context.is_offline_mode() else sa.inspect(op.get_bind())
columns = {"topic_session_id"} if inspector is None else {
column["name"] for column in inspector.get_columns("sys_chat_message")
}
if "topic_session_id" in columns:
op.drop_index("ix_sys_chat_message_topic_session_id", table_name="sys_chat_message")
op.drop_column("sys_chat_message", "topic_session_id")
tables = {
"sys_topic_session",
"sys_user_entitlement_log",
"sys_user_entitlement",
"sys_entitlement_plan",
} if inspector is None else set(inspector.get_table_names())
if "sys_topic_session" in tables:
op.drop_index("ix_sys_topic_session_status", table_name="sys_topic_session")
op.drop_index("ix_sys_topic_session_chat_session_id", table_name="sys_topic_session")
op.drop_index("ix_sys_topic_session_user_id", table_name="sys_topic_session")
op.drop_table("sys_topic_session")
if "sys_user_entitlement_log" in tables:
op.drop_index("ix_sys_user_entitlement_log_user_id", table_name="sys_user_entitlement_log")
op.drop_table("sys_user_entitlement_log")
if "sys_user_entitlement" in tables:
op.drop_index("ix_sys_user_entitlement_status", table_name="sys_user_entitlement")
op.drop_index("ix_sys_user_entitlement_plan_id", table_name="sys_user_entitlement")
op.drop_index("ix_sys_user_entitlement_user_id", table_name="sys_user_entitlement")
op.drop_table("sys_user_entitlement")
if "sys_entitlement_plan" in tables:
op.drop_index("ix_sys_entitlement_plan_status", table_name="sys_entitlement_plan")
op.drop_index("ix_sys_entitlement_plan_plan_type", table_name="sys_entitlement_plan")
op.drop_table("sys_entitlement_plan")
def _seed_default_plans() -> None:
op.bulk_insert(
sa.table(
"sys_entitlement_plan",
sa.column("name", sa.String(80)),
sa.column("plan_type", sa.String(30)),
sa.column("description", sa.String(255)),
sa.column("validity_days", sa.Integer()),
sa.column("monthly_topic_limit", sa.Integer()),
sa.column("enable_growth_profile", sa.Integer()),
sa.column("enable_periodic_reports", sa.Integer()),
sa.column("allow_help_card", sa.Integer()),
sa.column("allow_share_draft", sa.Integer()),
sa.column("deduct_quota", sa.Integer()),
sa.column("status", sa.Integer()),
sa.column("sort_order", sa.Integer()),
),
[
{
"name": "大本营基础版",
"plan_type": "basic",
"description": "随大本营提供,支持基础知识查询、功课方向和求助卡生成。",
"validity_days": None,
"monthly_topic_limit": 30,
"enable_growth_profile": 0,
"enable_periodic_reports": 0,
"allow_help_card": 1,
"allow_share_draft": 1,
"deduct_quota": 1,
"status": 1,
"sort_order": 10,
},
{
"name": "五个月深度陪伴版",
"plan_type": "deep",
"description": "支持近期实修回顾、周期实修回顾和更高主题会话额度。",
"validity_days": 150,
"monthly_topic_limit": 90,
"enable_growth_profile": 1,
"enable_periodic_reports": 1,
"allow_help_card": 1,
"allow_share_draft": 1,
"deduct_quota": 1,
"status": 1,
"sort_order": 20,
},
{
"name": "高频加购包",
"plan_type": "addon",
"description": "用于少量高频用户补充主题会话额度。",
"validity_days": 31,
"monthly_topic_limit": 30,
"enable_growth_profile": 0,
"enable_periodic_reports": 0,
"allow_help_card": 1,
"allow_share_draft": 1,
"deduct_quota": 1,
"status": 1,
"sort_order": 30,
},
{
"name": "老师工作版",
"plan_type": "teacher",
"description": "内部老师使用,不消耗普通学员权益额度。",
"validity_days": None,
"monthly_topic_limit": None,
"enable_growth_profile": 0,
"enable_periodic_reports": 0,
"allow_help_card": 1,
"allow_share_draft": 1,
"deduct_quota": 0,
"status": 1,
"sort_order": 40,
},
],
)

View File

@@ -0,0 +1,106 @@
"""add topic summaries and growth profiles
Revision ID: 0015_growth_profiles
Revises: 0014_entitlements_topics
"""
from alembic import context, op
import sqlalchemy as sa
revision = "0015_growth_profiles"
down_revision = "0014_entitlements_topics"
branch_labels = None
depends_on = None
PRIMARY_KEY_TYPE = sa.BigInteger().with_variant(sa.Integer(), "sqlite")
def upgrade() -> None:
inspector = None if context.is_offline_mode() else sa.inspect(op.get_bind())
tables = set() if inspector is None else set(inspector.get_table_names())
if "sys_topic_summary" not in tables:
op.create_table(
"sys_topic_summary",
sa.Column("id", PRIMARY_KEY_TYPE, primary_key=True, autoincrement=True),
sa.Column("topic_session_id", sa.BigInteger(), sa.ForeignKey("sys_topic_session.id"), nullable=False),
sa.Column("user_id", sa.BigInteger(), sa.ForeignKey("sys_user.id"), nullable=False),
sa.Column("summary", sa.Text(), nullable=False),
sa.Column("main_events", sa.Text(), nullable=True),
sa.Column("emotions", sa.Text(), nullable=True),
sa.Column("body_feelings", sa.Text(), nullable=True),
sa.Column("beliefs", sa.Text(), nullable=True),
sa.Column("recommended_homework", sa.Text(), nullable=True),
sa.Column("insights", sa.Text(), nullable=True),
sa.Column("next_observation", sa.Text(), nullable=True),
sa.Column("model_name", sa.String(100), nullable=True),
sa.Column("status", sa.String(20), nullable=False, server_default="success"),
sa.Column("error_message", sa.Text(), nullable=True),
sa.Column("generated_at", sa.DateTime(), server_default=sa.func.now(), nullable=False),
sa.Column("created_at", sa.DateTime(), server_default=sa.func.now(), nullable=False),
sa.Column("updated_at", sa.DateTime(), server_default=sa.func.now(), nullable=False),
sa.UniqueConstraint("topic_session_id", name="uq_sys_topic_summary_topic"),
)
op.create_index("ix_sys_topic_summary_topic_session_id", "sys_topic_summary", ["topic_session_id"])
op.create_index("ix_sys_topic_summary_user_id", "sys_topic_summary", ["user_id"])
op.create_index("ix_sys_topic_summary_status", "sys_topic_summary", ["status"])
if "sys_user_growth_profile" not in tables:
op.create_table(
"sys_user_growth_profile",
sa.Column("id", PRIMARY_KEY_TYPE, primary_key=True, autoincrement=True),
sa.Column("user_id", sa.BigInteger(), sa.ForeignKey("sys_user.id"), nullable=False),
sa.Column("profile_text", sa.Text(), nullable=False),
sa.Column("recurring_topics", sa.Text(), nullable=True),
sa.Column("common_emotions", sa.Text(), nullable=True),
sa.Column("body_patterns", sa.Text(), nullable=True),
sa.Column("relation_patterns", sa.Text(), nullable=True),
sa.Column("homework_done", sa.Text(), nullable=True),
sa.Column("effective_homework", sa.Text(), nullable=True),
sa.Column("recent_progress", sa.Text(), nullable=True),
sa.Column("last_topic_summary_id", sa.BigInteger(), nullable=True),
sa.Column("updated_by", sa.String(30), nullable=False, server_default="system"),
sa.Column("created_at", sa.DateTime(), server_default=sa.func.now(), nullable=False),
sa.Column("updated_at", sa.DateTime(), server_default=sa.func.now(), nullable=False),
sa.UniqueConstraint("user_id", name="uq_sys_user_growth_profile_user"),
)
op.create_index("ix_sys_user_growth_profile_user_id", "sys_user_growth_profile", ["user_id"])
if "sys_growth_profile_revision" not in tables:
op.create_table(
"sys_growth_profile_revision",
sa.Column("id", PRIMARY_KEY_TYPE, primary_key=True, autoincrement=True),
sa.Column("user_id", sa.BigInteger(), sa.ForeignKey("sys_user.id"), nullable=False),
sa.Column("profile_id", sa.BigInteger(), sa.ForeignKey("sys_user_growth_profile.id"), nullable=False),
sa.Column("topic_summary_id", sa.BigInteger(), nullable=True),
sa.Column("before_json", sa.Text(), nullable=True),
sa.Column("after_json", sa.Text(), nullable=False),
sa.Column("reason", sa.String(50), nullable=False, server_default="topic_summary"),
sa.Column("created_at", sa.DateTime(), server_default=sa.func.now(), nullable=False),
)
op.create_index("ix_sys_growth_profile_revision_user_id", "sys_growth_profile_revision", ["user_id"])
op.create_index("ix_sys_growth_profile_revision_profile_id", "sys_growth_profile_revision", ["profile_id"])
op.create_index("ix_sys_growth_profile_revision_topic_summary_id", "sys_growth_profile_revision", ["topic_summary_id"])
def downgrade() -> None:
inspector = None if context.is_offline_mode() else sa.inspect(op.get_bind())
tables = {
"sys_growth_profile_revision",
"sys_user_growth_profile",
"sys_topic_summary",
} if inspector is None else set(inspector.get_table_names())
if "sys_growth_profile_revision" in tables:
op.drop_index("ix_sys_growth_profile_revision_topic_summary_id", table_name="sys_growth_profile_revision")
op.drop_index("ix_sys_growth_profile_revision_profile_id", table_name="sys_growth_profile_revision")
op.drop_index("ix_sys_growth_profile_revision_user_id", table_name="sys_growth_profile_revision")
op.drop_table("sys_growth_profile_revision")
if "sys_user_growth_profile" in tables:
op.drop_index("ix_sys_user_growth_profile_user_id", table_name="sys_user_growth_profile")
op.drop_table("sys_user_growth_profile")
if "sys_topic_summary" in tables:
op.drop_index("ix_sys_topic_summary_status", table_name="sys_topic_summary")
op.drop_index("ix_sys_topic_summary_user_id", table_name="sys_topic_summary")
op.drop_index("ix_sys_topic_summary_topic_session_id", table_name="sys_topic_summary")
op.drop_table("sys_topic_summary")

View File

@@ -0,0 +1,51 @@
"""add teacher help cards
Revision ID: 0016_teacher_help_cards
Revises: 0015_growth_profiles
"""
from alembic import context, op
import sqlalchemy as sa
revision = "0016_teacher_help_cards"
down_revision = "0015_growth_profiles"
branch_labels = None
depends_on = None
PRIMARY_KEY_TYPE = sa.BigInteger().with_variant(sa.Integer(), "sqlite")
def upgrade() -> None:
inspector = None if context.is_offline_mode() else sa.inspect(op.get_bind())
tables = set() if inspector is None else set(inspector.get_table_names())
if "sys_teacher_help_card" in tables:
return
op.create_table(
"sys_teacher_help_card",
sa.Column("id", PRIMARY_KEY_TYPE, primary_key=True, autoincrement=True),
sa.Column("user_id", sa.BigInteger(), sa.ForeignKey("sys_user.id"), nullable=False),
sa.Column("topic_session_id", sa.BigInteger(), sa.ForeignKey("sys_topic_session.id"), nullable=False),
sa.Column("summary_id", sa.BigInteger(), nullable=True),
sa.Column("content", sa.Text(), nullable=False),
sa.Column("source", sa.String(30), nullable=False, server_default="topic_summary"),
sa.Column("copied", sa.Integer(), nullable=False, server_default="0"),
sa.Column("copied_at", sa.DateTime(), nullable=True),
sa.Column("created_at", sa.DateTime(), server_default=sa.func.now(), nullable=False),
)
op.create_index("ix_sys_teacher_help_card_user_id", "sys_teacher_help_card", ["user_id"])
op.create_index("ix_sys_teacher_help_card_summary_id", "sys_teacher_help_card", ["summary_id"])
op.create_index("ix_sys_teacher_help_card_user_created", "sys_teacher_help_card", ["user_id", "created_at"])
op.create_index("ix_sys_teacher_help_card_topic_created", "sys_teacher_help_card", ["topic_session_id", "created_at"])
def downgrade() -> None:
inspector = None if context.is_offline_mode() else sa.inspect(op.get_bind())
tables = {"sys_teacher_help_card"} if inspector is None else set(inspector.get_table_names())
if "sys_teacher_help_card" not in tables:
return
op.drop_index("ix_sys_teacher_help_card_topic_created", table_name="sys_teacher_help_card")
op.drop_index("ix_sys_teacher_help_card_user_created", table_name="sys_teacher_help_card")
op.drop_index("ix_sys_teacher_help_card_summary_id", table_name="sys_teacher_help_card")
op.drop_index("ix_sys_teacher_help_card_user_id", table_name="sys_teacher_help_card")
op.drop_table("sys_teacher_help_card")

View File

@@ -0,0 +1,51 @@
"""add share drafts
Revision ID: 0017_share_drafts
Revises: 0016_teacher_help_cards
"""
from alembic import context, op
import sqlalchemy as sa
revision = "0017_share_drafts"
down_revision = "0016_teacher_help_cards"
branch_labels = None
depends_on = None
PRIMARY_KEY_TYPE = sa.BigInteger().with_variant(sa.Integer(), "sqlite")
def upgrade() -> None:
inspector = None if context.is_offline_mode() else sa.inspect(op.get_bind())
tables = set() if inspector is None else set(inspector.get_table_names())
if "sys_share_draft" in tables:
return
op.create_table(
"sys_share_draft",
sa.Column("id", PRIMARY_KEY_TYPE, primary_key=True, autoincrement=True),
sa.Column("user_id", sa.BigInteger(), sa.ForeignKey("sys_user.id"), nullable=False),
sa.Column("topic_session_id", sa.BigInteger(), sa.ForeignKey("sys_topic_session.id"), nullable=False),
sa.Column("summary_id", sa.BigInteger(), nullable=True),
sa.Column("content", sa.Text(), nullable=False),
sa.Column("source", sa.String(30), nullable=False, server_default="topic_summary"),
sa.Column("copied", sa.Integer(), nullable=False, server_default="0"),
sa.Column("copied_at", sa.DateTime(), nullable=True),
sa.Column("created_at", sa.DateTime(), server_default=sa.func.now(), nullable=False),
)
op.create_index("ix_sys_share_draft_user_id", "sys_share_draft", ["user_id"])
op.create_index("ix_sys_share_draft_summary_id", "sys_share_draft", ["summary_id"])
op.create_index("ix_sys_share_draft_user_created", "sys_share_draft", ["user_id", "created_at"])
op.create_index("ix_sys_share_draft_topic_created", "sys_share_draft", ["topic_session_id", "created_at"])
def downgrade() -> None:
inspector = None if context.is_offline_mode() else sa.inspect(op.get_bind())
tables = {"sys_share_draft"} if inspector is None else set(inspector.get_table_names())
if "sys_share_draft" not in tables:
return
op.drop_index("ix_sys_share_draft_topic_created", table_name="sys_share_draft")
op.drop_index("ix_sys_share_draft_user_created", table_name="sys_share_draft")
op.drop_index("ix_sys_share_draft_summary_id", table_name="sys_share_draft")
op.drop_index("ix_sys_share_draft_user_id", table_name="sys_share_draft")
op.drop_table("sys_share_draft")

View File

@@ -0,0 +1,71 @@
"""add ai cost tracking fields
Revision ID: 0018_ai_cost_tracking
Revises: 0017_share_drafts
"""
from alembic import context, op
import sqlalchemy as sa
revision = "0018_ai_cost_tracking"
down_revision = "0017_share_drafts"
branch_labels = None
depends_on = None
def upgrade() -> None:
inspector = None if context.is_offline_mode() else sa.inspect(op.get_bind())
model_columns = set() if inspector is None else {column["name"] for column in inspector.get_columns("sys_model")}
_add_column_if_missing(model_columns, "sys_model", sa.Column("input_price_per_1k", sa.Numeric(12, 6), nullable=True))
_add_column_if_missing(model_columns, "sys_model", sa.Column("output_price_per_1k", sa.Numeric(12, 6), nullable=True))
_add_column_if_missing(model_columns, "sys_model", sa.Column("currency", sa.String(10), nullable=False, server_default="CNY"))
_add_column_if_missing(model_columns, "sys_model", sa.Column("usage_scenarios", sa.String(255), nullable=True))
_add_column_if_missing(model_columns, "sys_model", sa.Column("allow_summary", sa.Integer(), nullable=False, server_default="1"))
_add_column_if_missing(model_columns, "sys_model", sa.Column("allow_report", sa.Integer(), nullable=False, server_default="1"))
_add_column_if_missing(model_columns, "sys_model", sa.Column("allow_fixed_info", sa.Integer(), nullable=False, server_default="1"))
_add_column_if_missing(model_columns, "sys_model", sa.Column("allow_deep_chat", sa.Integer(), nullable=False, server_default="1"))
log_columns = set() if inspector is None else {column["name"] for column in inspector.get_columns("sys_ai_request_log")}
_add_column_if_missing(log_columns, "sys_ai_request_log", sa.Column("model_id", sa.BigInteger(), nullable=True))
_add_column_if_missing(log_columns, "sys_ai_request_log", sa.Column("estimated_cost", sa.Numeric(18, 6), nullable=True))
_add_column_if_missing(log_columns, "sys_ai_request_log", sa.Column("currency", sa.String(10), nullable=True))
_add_column_if_missing(log_columns, "sys_ai_request_log", sa.Column("route_reason", sa.String(255), nullable=True))
_add_column_if_missing(log_columns, "sys_ai_request_log", sa.Column("question_type", sa.String(50), nullable=True))
_add_column_if_missing(log_columns, "sys_ai_request_log", sa.Column("knowledge_hit", sa.Integer(), nullable=False, server_default="0"))
if "model_id" not in log_columns:
op.create_index("ix_sys_ai_request_log_model_id", "sys_ai_request_log", ["model_id"])
def downgrade() -> None:
inspector = None if context.is_offline_mode() else sa.inspect(op.get_bind())
log_columns = {
"model_id", "estimated_cost", "currency", "route_reason", "question_type", "knowledge_hit"
} if inspector is None else {column["name"] for column in inspector.get_columns("sys_ai_request_log")}
if "model_id" in log_columns:
op.drop_index("ix_sys_ai_request_log_model_id", table_name="sys_ai_request_log")
for column in ["knowledge_hit", "question_type", "route_reason", "currency", "estimated_cost", "model_id"]:
if column in log_columns:
op.drop_column("sys_ai_request_log", column)
model_columns = {
"input_price_per_1k", "output_price_per_1k", "currency", "usage_scenarios",
"allow_summary", "allow_report", "allow_fixed_info", "allow_deep_chat",
} if inspector is None else {column["name"] for column in inspector.get_columns("sys_model")}
for column in [
"allow_deep_chat",
"allow_fixed_info",
"allow_report",
"allow_summary",
"usage_scenarios",
"currency",
"output_price_per_1k",
"input_price_per_1k",
]:
if column in model_columns:
op.drop_column("sys_model", column)
def _add_column_if_missing(columns: set[str], table: str, column: sa.Column) -> None:
if column.name not in columns:
op.add_column(table, column)

View File

@@ -0,0 +1,54 @@
"""periodic reports
Revision ID: 0019_periodic_reports
Revises: 0018_ai_cost_tracking
Create Date: 2026-07-31 00:00:00.000000
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
revision = "0019_periodic_reports"
down_revision = "0018_ai_cost_tracking"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"sys_periodic_report",
sa.Column("id", sa.BigInteger().with_variant(sa.Integer(), "sqlite"), primary_key=True, autoincrement=True),
sa.Column("user_id", sa.BigInteger().with_variant(sa.Integer(), "sqlite"), nullable=False),
sa.Column("report_type", sa.String(length=30), nullable=False),
sa.Column("period_start", sa.DateTime(), nullable=False),
sa.Column("period_end", sa.DateTime(), nullable=False),
sa.Column("title", sa.String(length=160), nullable=False),
# MySQL 8.4 rejects literal defaults on TEXT columns. The ORM already
# supplies an empty string while a report job is pending.
sa.Column("content", sa.Text(), nullable=False),
sa.Column("source_summary_ids", sa.Text(), nullable=True),
sa.Column("source_topic_ids", sa.Text(), nullable=True),
sa.Column("model_name", sa.String(length=100), nullable=True),
sa.Column("status", sa.String(length=20), nullable=False, server_default="success"),
sa.Column("error_message", sa.Text(), nullable=True),
sa.Column("generated_by", sa.String(length=30), nullable=False, server_default="manual"),
sa.Column("generated_at", sa.DateTime(), nullable=False, server_default=sa.func.now()),
sa.Column("created_at", sa.DateTime(), nullable=False, server_default=sa.func.now()),
sa.Column("updated_at", sa.DateTime(), nullable=False, server_default=sa.func.now()),
sa.ForeignKeyConstraint(["user_id"], ["sys_user.id"]),
sa.UniqueConstraint("user_id", "report_type", "period_start", "period_end", name="uq_sys_periodic_report_period"),
)
op.create_index("ix_sys_periodic_report_user_id", "sys_periodic_report", ["user_id"])
op.create_index("ix_sys_periodic_report_status", "sys_periodic_report", ["status"])
op.create_index("ix_sys_periodic_report_user_type_created", "sys_periodic_report", ["user_id", "report_type", "created_at"])
op.create_index("ix_sys_periodic_report_status_created", "sys_periodic_report", ["status", "created_at"])
def downgrade() -> None:
op.drop_index("ix_sys_periodic_report_status_created", table_name="sys_periodic_report")
op.drop_index("ix_sys_periodic_report_user_type_created", table_name="sys_periodic_report")
op.drop_index("ix_sys_periodic_report_status", table_name="sys_periodic_report")
op.drop_index("ix_sys_periodic_report_user_id", table_name="sys_periodic_report")
op.drop_table("sys_periodic_report")

View File

@@ -0,0 +1,100 @@
"""persist cleaned question insights
Revision ID: 0020_question_insight_store
Revises: 0019_periodic_reports
Create Date: 2026-07-31 00:00:00.000000
"""
from __future__ import annotations
from alembic import context, op
import sqlalchemy as sa
revision = "0020_question_insight_store"
down_revision = "0019_periodic_reports"
branch_labels = None
depends_on = None
def upgrade() -> None:
table_name = "sys_question_insight_cleaned_question"
table_exists = False
existing_indexes: set[str] = set()
if not context.is_offline_mode():
inspector = sa.inspect(op.get_bind())
table_exists = inspector.has_table(table_name)
if table_exists:
existing_indexes = {item["name"] for item in inspector.get_indexes(table_name)}
if not table_exists:
op.create_table(
table_name,
sa.Column(
"id",
sa.BigInteger().with_variant(sa.Integer(), "sqlite"),
primary_key=True,
autoincrement=True,
),
sa.Column("message_id", sa.BigInteger(), nullable=False),
sa.Column("session_id", sa.BigInteger(), nullable=False),
sa.Column("user_id", sa.BigInteger(), nullable=False),
sa.Column("part_index", sa.Integer(), nullable=False),
sa.Column("cleaner_version", sa.String(length=20), nullable=False),
sa.Column("source_hash", sa.String(length=64), nullable=False),
sa.Column("cleaned_text", sa.Text(), nullable=False),
sa.Column("normalized_text", sa.Text(), nullable=False),
sa.Column("category", sa.String(length=50), nullable=False, server_default="other"),
sa.Column("tokens_json", sa.Text(), nullable=False),
sa.Column("accepted", sa.Integer(), nullable=False, server_default="1"),
sa.Column("filtered_reason", sa.String(length=100), nullable=True),
sa.Column("source_created_at", sa.DateTime(), nullable=False),
sa.Column("created_at", sa.DateTime(), nullable=False, server_default=sa.func.now()),
sa.Column("updated_at", sa.DateTime(), nullable=False, server_default=sa.func.now()),
sa.ForeignKeyConstraint(
["message_id"],
["sys_chat_message.id"],
ondelete="CASCADE",
),
sa.UniqueConstraint(
"message_id",
"cleaner_version",
"part_index",
name="uq_question_insight_message_version_part",
),
)
indexes = {
"ix_sys_question_insight_cleaned_question_session_id": ["session_id"],
"ix_sys_question_insight_cleaned_question_user_id": ["user_id"],
"ix_question_insight_version_accepted_created": ["cleaner_version", "accepted", "source_created_at"],
"ix_question_insight_category_created": ["category", "source_created_at"],
"ix_question_insight_session_created": ["session_id", "source_created_at"],
}
for index_name, columns in indexes.items():
if index_name not in existing_indexes:
op.create_index(index_name, table_name, columns)
def downgrade() -> None:
op.drop_index(
"ix_question_insight_session_created",
table_name="sys_question_insight_cleaned_question",
)
op.drop_index(
"ix_question_insight_category_created",
table_name="sys_question_insight_cleaned_question",
)
op.drop_index(
"ix_question_insight_version_accepted_created",
table_name="sys_question_insight_cleaned_question",
)
op.drop_index(
"ix_sys_question_insight_cleaned_question_user_id",
table_name="sys_question_insight_cleaned_question",
)
op.drop_index(
"ix_sys_question_insight_cleaned_question_session_id",
table_name="sys_question_insight_cleaned_question",
)
op.drop_table("sys_question_insight_cleaned_question")

View File

@@ -0,0 +1,62 @@
"""add durable periodic report job fields
Revision ID: 0021_periodic_report_async_jobs
Revises: 0020_question_insight_store
Create Date: 2026-07-31 00:00:00.000000
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
revision = "0021_periodic_report_async_jobs"
down_revision = "0020_question_insight_store"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column(
"sys_periodic_report",
sa.Column("attempt_count", sa.Integer(), nullable=False, server_default="0"),
)
op.add_column(
"sys_periodic_report",
sa.Column("max_attempts", sa.Integer(), nullable=False, server_default="3"),
)
op.add_column("sys_periodic_report", sa.Column("next_run_at", sa.DateTime(), nullable=True))
op.add_column("sys_periodic_report", sa.Column("locked_at", sa.DateTime(), nullable=True))
op.add_column("sys_periodic_report", sa.Column("locked_by", sa.String(length=120), nullable=True))
op.add_column("sys_periodic_report", sa.Column("last_started_at", sa.DateTime(), nullable=True))
op.add_column("sys_periodic_report", sa.Column("finished_at", sa.DateTime(), nullable=True))
op.execute(
"UPDATE sys_periodic_report "
"SET finished_at = generated_at "
"WHERE status IN ('success', 'empty', 'failed')"
)
op.execute("UPDATE sys_entitlement_plan SET status = 0 WHERE plan_type = 'teacher'")
op.create_index(
"ix_periodic_report_job_due",
"sys_periodic_report",
["status", "next_run_at", "id"],
)
op.create_index(
"ix_periodic_report_job_stale",
"sys_periodic_report",
["status", "locked_at"],
)
def downgrade() -> None:
op.execute("UPDATE sys_entitlement_plan SET status = 1 WHERE plan_type = 'teacher'")
op.drop_index("ix_periodic_report_job_stale", table_name="sys_periodic_report")
op.drop_index("ix_periodic_report_job_due", table_name="sys_periodic_report")
op.drop_column("sys_periodic_report", "finished_at")
op.drop_column("sys_periodic_report", "last_started_at")
op.drop_column("sys_periodic_report", "locked_by")
op.drop_column("sys_periodic_report", "locked_at")
op.drop_column("sys_periodic_report", "next_run_at")
op.drop_column("sys_periodic_report", "max_attempts")
op.drop_column("sys_periodic_report", "attempt_count")

View File

@@ -0,0 +1,52 @@
"""add multi-model routing support
Revision ID: 0022_model_routing
Revises: 0021_periodic_report_async_jobs
"""
from alembic import op
import sqlalchemy as sa
revision = "0022_model_routing"
down_revision = "0021_periodic_report_async_jobs"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column(
"sys_model",
sa.Column("is_default", sa.Integer(), nullable=False, server_default="0"),
)
op.create_index(
"ix_sys_model_available_default",
"sys_model",
["enabled", "is_default", "id"],
unique=False,
)
# 兼容旧数据:原来唯一启用的模型直接成为默认主模型。
op.execute(
"""
UPDATE sys_model
SET is_default = 1
WHERE enabled = 1
AND id = (
SELECT selected.id
FROM (
SELECT id
FROM sys_model
WHERE enabled = 1
ORDER BY id DESC
LIMIT 1
) AS selected
)
"""
)
def downgrade() -> None:
# 回滚到旧语义前只保留默认模型为启用,避免旧代码随机选到分流模型。
op.execute("UPDATE sys_model SET enabled = CASE WHEN is_default = 1 THEN 1 ELSE 0 END")
op.drop_index("ix_sys_model_available_default", table_name="sys_model")
op.drop_column("sys_model", "is_default")

View File

@@ -0,0 +1,71 @@
"""add simple knowledge model capability
Revision ID: 0023_simple_knowledge_route
Revises: 0022_model_routing
"""
from alembic import op
import sqlalchemy as sa
revision = "0023_simple_knowledge_route"
down_revision = "0022_model_routing"
branch_labels = None
depends_on = None
def upgrade() -> None:
# Existing models retain the capability so deployment behavior stays on the
# current default model until an administrator explicitly reassigns it.
op.add_column(
"sys_model",
sa.Column("allow_simple_knowledge", sa.Integer(), nullable=False, server_default="1"),
)
# Materialize the new three-state setting so the admin UI and runtime show
# the same value even when the legacy emergency switch had been disabled.
op.execute(
"""
INSERT IGNORE INTO sys_system_config (config_key, config_value, description)
SELECT
'chat_model_routing_mode',
CASE
WHEN EXISTS (
SELECT 1
FROM sys_system_config
WHERE config_key = 'fixed_info_model_routing_enabled'
AND LOWER(TRIM(config_value)) NOT IN ('1', 'true', 'yes', 'on', '启用')
) THEN 'off'
ELSE 'fixed_only'
END,
'正式问答模型分流规则off / fixed_only / conservative'
"""
)
def downgrade() -> None:
# Preserve the closest legacy behavior before removing the new setting.
op.execute(
"""
UPDATE sys_system_config AS legacy
JOIN sys_system_config AS current_mode
ON current_mode.config_key = 'chat_model_routing_mode'
SET legacy.config_value = CASE
WHEN current_mode.config_value = 'off' THEN 'false'
ELSE 'true'
END
WHERE legacy.config_key = 'fixed_info_model_routing_enabled'
"""
)
op.execute(
"""
INSERT IGNORE INTO sys_system_config (config_key, config_value, description)
SELECT
'fixed_info_model_routing_enabled',
CASE WHEN config_value = 'off' THEN 'false' ELSE 'true' END,
'固定信息模型分流'
FROM sys_system_config
WHERE config_key = 'chat_model_routing_mode'
"""
)
op.execute("DELETE FROM sys_system_config WHERE config_key = 'chat_model_routing_mode'")
op.drop_column("sys_model", "allow_simple_knowledge")

View File

@@ -0,0 +1,59 @@
"""add durable topic settlement job fields
Revision ID: 0024_topic_settlement_jobs
Revises: 0023_simple_knowledge_route
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
revision = "0024_topic_settlement_jobs"
down_revision = "0023_simple_knowledge_route"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column(
"sys_topic_summary",
sa.Column("attempt_count", sa.Integer(), nullable=False, server_default="0"),
)
op.add_column(
"sys_topic_summary",
sa.Column("max_attempts", sa.Integer(), nullable=False, server_default="3"),
)
op.add_column("sys_topic_summary", sa.Column("next_run_at", sa.DateTime(), nullable=True))
op.add_column("sys_topic_summary", sa.Column("locked_at", sa.DateTime(), nullable=True))
op.add_column("sys_topic_summary", sa.Column("locked_by", sa.String(length=120), nullable=True))
op.add_column("sys_topic_summary", sa.Column("last_started_at", sa.DateTime(), nullable=True))
op.add_column("sys_topic_summary", sa.Column("finished_at", sa.DateTime(), nullable=True))
op.execute(
"UPDATE sys_topic_summary "
"SET finished_at = generated_at "
"WHERE status IN ('success', 'fallback', 'failed')"
)
op.create_index(
"ix_topic_summary_job_due",
"sys_topic_summary",
["status", "next_run_at", "id"],
)
op.create_index(
"ix_topic_summary_job_stale",
"sys_topic_summary",
["status", "locked_at"],
)
def downgrade() -> None:
op.drop_index("ix_topic_summary_job_stale", table_name="sys_topic_summary")
op.drop_index("ix_topic_summary_job_due", table_name="sys_topic_summary")
op.drop_column("sys_topic_summary", "finished_at")
op.drop_column("sys_topic_summary", "last_started_at")
op.drop_column("sys_topic_summary", "locked_by")
op.drop_column("sys_topic_summary", "locked_at")
op.drop_column("sys_topic_summary", "next_run_at")
op.drop_column("sys_topic_summary", "max_attempts")
op.drop_column("sys_topic_summary", "attempt_count")

View File

@@ -0,0 +1,70 @@
"""replace long-term growth profiling with recent practice review
Revision ID: 0025_recent_practice_review
Revises: 0024_topic_settlement_jobs
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
revision = "0025_recent_practice_review"
down_revision = "0024_topic_settlement_jobs"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column(
"sys_topic_summary",
sa.Column("schema_version", sa.Integer(), nullable=False, server_default="1"),
)
op.alter_column("sys_topic_summary", "schema_version", server_default="2")
op.add_column(
"sys_user_growth_profile",
sa.Column("schema_version", sa.Integer(), nullable=False, server_default="1"),
)
op.add_column("sys_user_growth_profile", sa.Column("recent_review", sa.Text(), nullable=True))
op.add_column("sys_user_growth_profile", sa.Column("current_focus", sa.Text(), nullable=True))
op.add_column("sys_user_growth_profile", sa.Column("source_summary_ids", sa.Text(), nullable=True))
op.alter_column("sys_user_growth_profile", "schema_version", server_default="2")
op.add_column(
"sys_periodic_report",
sa.Column("schema_version", sa.Integer(), nullable=False, server_default="1"),
)
op.alter_column("sys_periodic_report", "schema_version", server_default="2")
# 仅替换系统初始深度版的旧说明,避免覆盖管理员已经自定义的文案。
op.execute(
sa.text(
"UPDATE sys_entitlement_plan "
"SET description = :new_description "
"WHERE plan_type = 'deep' AND description = :old_description"
).bindparams(
new_description="支持近期实修回顾、周期实修回顾和更高主题会话额度。",
old_description="支持长期成长档案、阶段报告和更高主题会话额度。",
)
)
def downgrade() -> None:
op.execute(
sa.text(
"UPDATE sys_entitlement_plan "
"SET description = :old_description "
"WHERE plan_type = 'deep' AND description = :new_description"
).bindparams(
old_description="支持长期成长档案、阶段报告和更高主题会话额度。",
new_description="支持近期实修回顾、周期实修回顾和更高主题会话额度。",
)
)
op.drop_column("sys_periodic_report", "schema_version")
op.drop_column("sys_user_growth_profile", "source_summary_ids")
op.drop_column("sys_user_growth_profile", "current_focus")
op.drop_column("sys_user_growth_profile", "recent_review")
op.drop_column("sys_user_growth_profile", "schema_version")
op.drop_column("sys_topic_summary", "schema_version")

View File

@@ -0,0 +1,52 @@
"""add entitlement renewal idempotency and expiry index
Revision ID: 0026_entitlement_renewal
Revises: 0025_recent_practice_review
"""
from __future__ import annotations
from alembic import context, op
import sqlalchemy as sa
revision = "0026_entitlement_renewal"
down_revision = "0025_recent_practice_review"
branch_labels = None
depends_on = None
def upgrade() -> None:
inspector = None if context.is_offline_mode() else sa.inspect(op.get_bind())
log_columns = set() if inspector is None else {
column["name"] for column in inspector.get_columns("sys_user_entitlement_log")
}
if "request_key" not in log_columns:
op.add_column("sys_user_entitlement_log", sa.Column("request_key", sa.String(160), nullable=True))
log_indexes = set() if inspector is None else {
index["name"] for index in inspector.get_indexes("sys_user_entitlement_log")
}
if "ux_entitlement_log_request_key" not in log_indexes:
op.create_index(
"ux_entitlement_log_request_key",
"sys_user_entitlement_log",
["request_key"],
unique=True,
)
entitlement_indexes = set() if inspector is None else {
index["name"] for index in inspector.get_indexes("sys_user_entitlement")
}
if "ix_entitlement_status_expiry" not in entitlement_indexes:
op.create_index(
"ix_entitlement_status_expiry",
"sys_user_entitlement",
["status", "expired_at", "user_id"],
)
def downgrade() -> None:
op.drop_index("ix_entitlement_status_expiry", table_name="sys_user_entitlement")
op.drop_index("ux_entitlement_log_request_key", table_name="sys_user_entitlement_log")
op.drop_column("sys_user_entitlement_log", "request_key")

View File

@@ -0,0 +1,49 @@
"""add versioned content generation configs
Revision ID: 0027_content_generation
Revises: 0026_entitlement_renewal
"""
from __future__ import annotations
from alembic import context, op
import sqlalchemy as sa
revision = "0027_content_generation"
down_revision = "0026_entitlement_renewal"
branch_labels = None
depends_on = None
PRIMARY_KEY_TYPE = sa.BigInteger().with_variant(sa.Integer(), "sqlite")
def upgrade() -> None:
inspector = None if context.is_offline_mode() else sa.inspect(op.get_bind())
tables = set() if inspector is None else set(inspector.get_table_names())
if "sys_content_generation_config" not in tables:
op.create_table(
"sys_content_generation_config",
sa.Column("id", PRIMARY_KEY_TYPE, primary_key=True, autoincrement=True),
sa.Column("config_type", sa.String(length=30), nullable=False),
sa.Column("template_content", sa.Text(), nullable=False),
sa.Column("instruction_content", sa.Text(), nullable=False),
sa.Column("change_type", sa.String(length=20), nullable=False, server_default="save"),
sa.Column("source_config_id", sa.BigInteger(), nullable=True),
sa.Column("updated_by", sa.BigInteger(), nullable=True),
sa.Column("updated_at", sa.DateTime(), server_default=sa.func.now(), nullable=False),
)
op.create_index(
"ix_content_generation_type_updated",
"sys_content_generation_config",
["config_type", "updated_at", "id"],
)
def downgrade() -> None:
inspector = None if context.is_offline_mode() else sa.inspect(op.get_bind())
tables = {"sys_content_generation_config"} if inspector is None else set(inspector.get_table_names())
if "sys_content_generation_config" not in tables:
return
op.drop_index("ix_content_generation_type_updated", table_name="sys_content_generation_config")
op.drop_table("sys_content_generation_config")

View File

@@ -0,0 +1,84 @@
"""raise the default chat concurrency to thirty
Revision ID: 0028_chat_concurrency
Revises: 0027_content_generation
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
revision = "0028_chat_concurrency"
down_revision = "0027_content_generation"
branch_labels = None
depends_on = None
def upgrade() -> None:
config = sa.table(
"sys_system_config",
sa.column("config_key", sa.String(length=100)),
sa.column("config_value", sa.Text()),
sa.column("description", sa.String(length=255)),
)
connection = op.get_bind()
_set_default(
connection,
config,
key="chat_max_active_requests",
old_default="2",
new_default="30",
description="用户正式问答的全局执行上限Redis 可用时由所有后端实例共享,超过后进入排队。",
)
_set_default(
connection,
config,
key="chat_max_queue_size",
old_default="20",
new_default="90",
description="超过最大并发后允许等待的请求数量。默认按并发数的 3 倍预留。",
)
_set_default(
connection,
config,
key="chat_queue_timeout_seconds",
old_default="60",
new_default="90",
description="请求在排队中超过该时间后自动结束。默认可覆盖约三轮请求释放。",
)
def downgrade() -> None:
# 运行时配置可能已经被管理员调整,降级代码版本时不覆盖现场值。
pass
def _set_default(
connection,
config,
*,
key: str,
old_default: str,
new_default: str,
description: str,
) -> None:
current = connection.execute(
sa.select(config.c.config_value).where(config.c.config_key == key)
).scalar_one_or_none()
if current is None:
connection.execute(
config.insert().values(
config_key=key,
config_value=new_default,
description=description,
)
)
return
if str(current).strip() == old_default:
connection.execute(
config.update()
.where(config.c.config_key == key)
.values(config_value=new_default, description=description)
)

View File

@@ -0,0 +1,77 @@
"""add trusted application sso integration
Revision ID: 0029_sso_integration
Revises: 0028_chat_concurrency
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
revision = "0029_sso_integration"
down_revision = "0028_chat_concurrency"
branch_labels = None
depends_on = None
PRIMARY_KEY_TYPE = sa.BigInteger().with_variant(sa.Integer(), "sqlite")
def upgrade() -> None:
op.create_table(
"sys_sso_client",
sa.Column("id", PRIMARY_KEY_TYPE, primary_key=True, autoincrement=True),
sa.Column("app_id", sa.String(length=80), nullable=False),
sa.Column("name", sa.String(length=100), nullable=False),
sa.Column("client_secret", sa.Text(), nullable=False),
sa.Column("redirect_uris", sa.Text(), nullable=False),
sa.Column("status", sa.Integer(), nullable=False, server_default="1"),
sa.Column("created_by", sa.BigInteger(), nullable=True),
sa.Column("last_used_at", sa.DateTime(), nullable=True),
sa.Column("created_at", sa.DateTime(), server_default=sa.func.now(), nullable=False),
sa.Column("updated_at", sa.DateTime(), server_default=sa.func.now(), nullable=False),
)
op.create_index("ix_sys_sso_client_app_id", "sys_sso_client", ["app_id"], unique=True)
op.create_index("ix_sys_sso_client_status", "sys_sso_client", ["status"])
op.create_table(
"sys_user_external_identity",
sa.Column("id", PRIMARY_KEY_TYPE, primary_key=True, autoincrement=True),
sa.Column("client_id", sa.BigInteger(), nullable=False),
sa.Column("user_id", sa.BigInteger(), nullable=False),
sa.Column("external_user_id", sa.String(length=120), nullable=False),
sa.Column("phone_snapshot", sa.String(length=20), nullable=True),
sa.Column("display_name_snapshot", sa.String(length=100), nullable=True),
sa.Column("last_login_at", sa.DateTime(), nullable=True),
sa.Column("created_at", sa.DateTime(), server_default=sa.func.now(), nullable=False),
sa.Column("updated_at", sa.DateTime(), server_default=sa.func.now(), nullable=False),
sa.ForeignKeyConstraint(["client_id"], ["sys_sso_client.id"]),
sa.ForeignKeyConstraint(["user_id"], ["sys_user.id"]),
sa.UniqueConstraint("client_id", "external_user_id", name="uq_sso_identity_client_external"),
sa.UniqueConstraint("client_id", "user_id", name="uq_sso_identity_client_user"),
)
op.create_index("ix_sys_user_external_identity_client_id", "sys_user_external_identity", ["client_id"])
op.create_index("ix_sso_identity_user", "sys_user_external_identity", ["user_id"])
op.create_table(
"sys_sso_login_audit",
sa.Column("id", PRIMARY_KEY_TYPE, primary_key=True, autoincrement=True),
sa.Column("client_id", sa.BigInteger(), nullable=True),
sa.Column("user_id", sa.BigInteger(), nullable=True),
sa.Column("external_user_id", sa.String(length=120), nullable=True),
sa.Column("action", sa.String(length=30), nullable=False),
sa.Column("status", sa.String(length=20), nullable=False),
sa.Column("error_message", sa.String(length=500), nullable=True),
sa.Column("ip", sa.String(length=50), nullable=True),
sa.Column("created_at", sa.DateTime(), server_default=sa.func.now(), nullable=False),
)
op.create_index("ix_sso_audit_client_created", "sys_sso_login_audit", ["client_id", "created_at"])
op.create_index("ix_sso_audit_user_created", "sys_sso_login_audit", ["user_id", "created_at"])
op.create_index("ix_sso_audit_status_created", "sys_sso_login_audit", ["status", "created_at"])
def downgrade() -> None:
op.drop_table("sys_sso_login_audit")
op.drop_table("sys_user_external_identity")
op.drop_table("sys_sso_client")

View File

@@ -0,0 +1,56 @@
"""add sso auto registration and chat source isolation
Revision ID: 0030_sso_chat_source
Revises: 0029_sso_integration
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
revision = "0030_sso_chat_source"
down_revision = "0029_sso_integration"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column(
"sys_sso_client",
sa.Column("allow_auto_register", sa.Integer(), nullable=False, server_default="0"),
)
op.add_column(
"sys_sso_client",
sa.Column("default_entitlement_plan_id", sa.BigInteger(), nullable=True),
)
op.add_column(
"sys_user",
sa.Column("registration_source", sa.String(length=30), nullable=False, server_default="direct"),
)
op.add_column("sys_user", sa.Column("registration_client_id", sa.BigInteger(), nullable=True))
op.create_index("ix_sys_user_registration_client_id", "sys_user", ["registration_client_id"])
op.add_column(
"sys_chat_session",
sa.Column("source_type", sa.String(length=20), nullable=False, server_default="direct"),
)
op.add_column("sys_chat_session", sa.Column("source_client_id", sa.BigInteger(), nullable=True))
op.create_index(
"ix_chat_session_user_source_active_updated",
"sys_chat_session",
["user_id", "source_type", "source_client_id", "is_deleted", "updated_at"],
)
def downgrade() -> None:
op.drop_index("ix_chat_session_user_source_active_updated", table_name="sys_chat_session")
op.drop_column("sys_chat_session", "source_client_id")
op.drop_column("sys_chat_session", "source_type")
op.drop_index("ix_sys_user_registration_client_id", table_name="sys_user")
op.drop_column("sys_user", "registration_client_id")
op.drop_column("sys_user", "registration_source")
op.drop_column("sys_sso_client", "default_entitlement_plan_id")
op.drop_column("sys_sso_client", "allow_auto_register")

View File

@@ -0,0 +1,30 @@
"""add administrator permission management
Revision ID: 0031_admin_permissions
Revises: 0030_sso_chat_source
"""
from alembic import op
import sqlalchemy as sa
revision = "0031_admin_permissions"
down_revision = "0030_sso_chat_source"
branch_labels = None
depends_on = None
def upgrade() -> None:
# MySQL does not permit a DEFAULT on TEXT on all supported versions.
op.add_column("sys_role", sa.Column("permissions", sa.Text(), nullable=True))
op.execute("UPDATE sys_role SET permissions = '[]' WHERE permissions IS NULL")
op.alter_column("sys_role", "permissions", existing_type=sa.Text(), nullable=False)
op.add_column("sys_admin", sa.Column("must_change_password", sa.Integer(), nullable=False, server_default="1"))
op.add_column("sys_admin", sa.Column("is_super_admin", sa.Integer(), nullable=False, server_default="0"))
# Existing installations have one bootstrap administrator. Preserve it as the immutable super administrator.
op.execute("UPDATE sys_admin SET is_super_admin = 1, must_change_password = 0 WHERE id = (SELECT id FROM (SELECT MIN(id) AS id FROM sys_admin) AS first_admin)")
def downgrade() -> None:
op.drop_column("sys_admin", "is_super_admin")
op.drop_column("sys_admin", "must_change_password")
op.drop_column("sys_role", "permissions")

View File

@@ -0,0 +1,36 @@
"""add message feedback management
Revision ID: 0032_message_feedback
Revises: 0031_admin_permissions
"""
from alembic import op
import sqlalchemy as sa
revision = "0032_message_feedback"
down_revision = "0031_admin_permissions"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"sys_message_feedback",
sa.Column("id", sa.BigInteger(), primary_key=True, autoincrement=True),
sa.Column("user_id", sa.BigInteger(), sa.ForeignKey("sys_user.id"), nullable=False),
sa.Column("session_id", sa.BigInteger(), sa.ForeignKey("sys_chat_session.id"), nullable=False),
sa.Column("message_id", sa.BigInteger(), sa.ForeignKey("sys_chat_message.id"), nullable=False),
sa.Column("content", sa.String(200), nullable=False),
sa.Column("is_read", sa.Integer(), nullable=False, server_default="0"),
sa.Column("read_by", sa.BigInteger(), sa.ForeignKey("sys_admin.id"), nullable=True),
sa.Column("read_at", sa.DateTime(), nullable=True),
sa.Column("created_at", sa.DateTime(), nullable=False, server_default=sa.func.now()),
sa.UniqueConstraint("user_id", "message_id", name="uq_message_feedback_user_message"),
)
op.create_index("ix_message_feedback_user_id", "sys_message_feedback", ["user_id"])
op.create_index("ix_message_feedback_session_id", "sys_message_feedback", ["session_id"])
op.create_index("ix_message_feedback_message_id", "sys_message_feedback", ["message_id"])
op.create_index("ix_message_feedback_read_created", "sys_message_feedback", ["is_read", "created_at"])
def downgrade() -> None:
op.drop_table("sys_message_feedback")

View File

@@ -0,0 +1,74 @@
"""add persistent Agent batch tests
Revision ID: 0033_agent_batch_tests
Revises: 0032_message_feedback
"""
from alembic import op
import sqlalchemy as sa
revision = "0033_agent_batch_tests"
down_revision = "0032_message_feedback"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"sys_agent_batch_test",
sa.Column("id", sa.BigInteger(), primary_key=True, autoincrement=True),
sa.Column("name", sa.String(160), nullable=False),
sa.Column("original_filename", sa.String(255), nullable=False),
sa.Column("status", sa.String(20), nullable=False, server_default="pending"),
sa.Column("total_count", sa.Integer(), nullable=False, server_default="0"),
sa.Column("success_count", sa.Integer(), nullable=False, server_default="0"),
sa.Column("failed_count", sa.Integer(), nullable=False, server_default="0"),
sa.Column("processed_count", sa.Integer(), nullable=False, server_default="0"),
sa.Column("prompt_content", sa.Text(), nullable=False),
sa.Column("model_id", sa.BigInteger(), sa.ForeignKey("sys_model.id"), nullable=False),
sa.Column("model_name", sa.String(100), nullable=False),
sa.Column("knowledge_ids", sa.Text(), nullable=False),
sa.Column("knowledge_names", sa.Text(), nullable=False),
sa.Column("generation_config", sa.Text(), nullable=False),
sa.Column("created_by", sa.BigInteger(), sa.ForeignKey("sys_admin.id"), nullable=False),
sa.Column("started_at", sa.DateTime(), nullable=True),
sa.Column("finished_at", sa.DateTime(), nullable=True),
sa.Column("cancelled_at", sa.DateTime(), nullable=True),
sa.Column("created_at", sa.DateTime(), nullable=False, server_default=sa.func.now()),
sa.Column("updated_at", sa.DateTime(), nullable=False, server_default=sa.func.now()),
)
op.create_index("ix_agent_batch_test_creator_created", "sys_agent_batch_test", ["created_by", "created_at"])
op.create_index("ix_agent_batch_test_status_created", "sys_agent_batch_test", ["status", "created_at"])
op.create_table(
"sys_agent_batch_test_item",
sa.Column("id", sa.BigInteger(), primary_key=True, autoincrement=True),
sa.Column("batch_id", sa.BigInteger(), sa.ForeignKey("sys_agent_batch_test.id", ondelete="CASCADE"), nullable=False),
sa.Column("row_number", sa.Integer(), nullable=False),
sa.Column("external_no", sa.String(100), nullable=True),
sa.Column("question", sa.Text(), nullable=False),
sa.Column("answer", sa.Text(), nullable=True),
sa.Column("status", sa.String(20), nullable=False, server_default="pending"),
sa.Column("error_message", sa.Text(), nullable=True),
sa.Column("model_name", sa.String(100), nullable=True),
sa.Column("knowledge_ids", sa.Text(), nullable=True),
sa.Column("retrieve_count", sa.Integer(), nullable=True),
sa.Column("duration_ms", sa.Integer(), nullable=True),
sa.Column("attempt_count", sa.Integer(), nullable=False, server_default="0"),
sa.Column("max_attempts", sa.Integer(), nullable=False, server_default="2"),
sa.Column("next_run_at", sa.DateTime(), nullable=True),
sa.Column("locked_at", sa.DateTime(), nullable=True),
sa.Column("locked_by", sa.String(120), nullable=True),
sa.Column("started_at", sa.DateTime(), nullable=True),
sa.Column("finished_at", sa.DateTime(), nullable=True),
sa.Column("created_at", sa.DateTime(), nullable=False, server_default=sa.func.now()),
sa.Column("updated_at", sa.DateTime(), nullable=False, server_default=sa.func.now()),
)
op.create_index("ix_agent_batch_item_job_order", "sys_agent_batch_test_item", ["batch_id", "row_number"])
op.create_index("ix_agent_batch_item_claim", "sys_agent_batch_test_item", ["status", "next_run_at", "id"])
op.create_index("ix_agent_batch_item_stale", "sys_agent_batch_test_item", ["status", "locked_at"])
def downgrade() -> None:
op.drop_table("sys_agent_batch_test_item")
op.drop_table("sys_agent_batch_test")

View File

@@ -0,0 +1,30 @@
"""add Agent batch execution mode and concurrency limit
Revision ID: 0034_agent_batch_concurrency
Revises: 0033_agent_batch_tests
"""
from alembic import op
import sqlalchemy as sa
revision = "0034_agent_batch_concurrency"
down_revision = "0033_agent_batch_tests"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column(
"sys_agent_batch_test",
sa.Column("execution_mode", sa.String(20), nullable=False, server_default="sequential"),
)
op.add_column(
"sys_agent_batch_test",
sa.Column("concurrency_limit", sa.Integer(), nullable=False, server_default="1"),
)
def downgrade() -> None:
op.drop_column("sys_agent_batch_test", "concurrency_limit")
op.drop_column("sys_agent_batch_test", "execution_mode")

View File

@@ -0,0 +1,24 @@
"""add configurable variables to content generation configs
Revision ID: 0035_content_gen_variables
Revises: 0034_agent_batch_concurrency
"""
import sqlalchemy as sa
from alembic import op
revision = "0035_content_gen_variables"
down_revision = "0034_agent_batch_concurrency"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column("sys_content_generation_config", sa.Column("variables_json", sa.Text(), nullable=True))
op.execute("UPDATE sys_content_generation_config SET variables_json = '[]' WHERE variables_json IS NULL")
op.alter_column("sys_content_generation_config", "variables_json", existing_type=sa.Text(), nullable=False)
def downgrade() -> None:
op.drop_column("sys_content_generation_config", "variables_json")

View File

@@ -0,0 +1,65 @@
"""remove monthly topic quota while preserving practice review data
Revision ID: 0036_remove_topic_quota
Revises: 0035_content_gen_variables
"""
import sqlalchemy as sa
from alembic import op
revision = "0036_remove_topic_quota"
down_revision = "0035_content_gen_variables"
branch_labels = None
depends_on = None
OLD_REVIEW_KEY = "topic_auto_settle_successful_rounds"
NEW_REVIEW_KEY = "practice_review_auto_settle_successful_rounds"
def _rename_config_key(old_key: str, new_key: str) -> None:
connection = op.get_bind()
old_exists = connection.execute(
sa.text("SELECT COUNT(*) FROM sys_system_config WHERE config_key = :key"),
{"key": old_key},
).scalar()
if not old_exists:
return
new_exists = connection.execute(
sa.text("SELECT COUNT(*) FROM sys_system_config WHERE config_key = :key"),
{"key": new_key},
).scalar()
if new_exists:
connection.execute(
sa.text("DELETE FROM sys_system_config WHERE config_key = :key"),
{"key": old_key},
)
return
connection.execute(
sa.text("UPDATE sys_system_config SET config_key = :new_key WHERE config_key = :old_key"),
{"new_key": new_key, "old_key": old_key},
)
def upgrade() -> None:
_rename_config_key(OLD_REVIEW_KEY, NEW_REVIEW_KEY)
op.drop_column("sys_entitlement_plan", "deduct_quota")
op.drop_column("sys_entitlement_plan", "monthly_topic_limit")
op.drop_column("sys_topic_session", "quota_deducted")
def downgrade() -> None:
op.add_column(
"sys_topic_session",
sa.Column("quota_deducted", sa.Integer(), nullable=False, server_default="0"),
)
op.add_column(
"sys_entitlement_plan",
sa.Column("monthly_topic_limit", sa.Integer(), nullable=True),
)
op.add_column(
"sys_entitlement_plan",
sa.Column("deduct_quota", sa.Integer(), nullable=False, server_default="1"),
)
_rename_config_key(NEW_REVIEW_KEY, OLD_REVIEW_KEY)

View File

@@ -0,0 +1,38 @@
"""track chat and weekly-report sources for configurable periodic reports
Revision ID: 0037_report_sources
Revises: 0036_remove_topic_quota
"""
import sqlalchemy as sa
from alembic import op
revision = "0037_report_sources"
down_revision = "0036_remove_topic_quota"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column("sys_periodic_report", sa.Column("source_message_ids", sa.Text(), nullable=True))
op.add_column("sys_periodic_report", sa.Column("source_report_ids", sa.Text(), nullable=True))
op.alter_column(
"sys_periodic_report",
"schema_version",
server_default="3",
existing_type=sa.Integer(),
existing_nullable=False,
)
def downgrade() -> None:
op.alter_column(
"sys_periodic_report",
"schema_version",
server_default="2",
existing_type=sa.Integer(),
existing_nullable=False,
)
op.drop_column("sys_periodic_report", "source_report_ids")
op.drop_column("sys_periodic_report", "source_message_ids")

View File

@@ -0,0 +1,42 @@
"""add user behavior analytics
Revision ID: 0038_user_behavior
Revises: 0037_report_sources
"""
from alembic import op
import sqlalchemy as sa
revision = "0038_user_behavior"
down_revision = "0037_report_sources"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"sys_user_behavior_event",
sa.Column("id", sa.BigInteger(), autoincrement=True, nullable=False),
sa.Column("client_event_id", sa.String(length=36), nullable=False),
sa.Column("user_id", sa.BigInteger(), nullable=False),
sa.Column("event_code", sa.String(length=64), nullable=False),
sa.Column("event_name", sa.String(length=100), nullable=False),
sa.Column("event_type", sa.String(length=20), nullable=False),
sa.Column("target_type", sa.String(length=30), nullable=True),
sa.Column("target_id", sa.BigInteger(), nullable=True),
sa.Column("occurred_at", sa.DateTime(), nullable=False),
sa.Column("created_at", sa.DateTime(), server_default=sa.text("CURRENT_TIMESTAMP"), nullable=False),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("client_event_id", name="uq_user_behavior_client_event"),
)
op.create_index("ix_user_behavior_occurred", "sys_user_behavior_event", ["occurred_at", "id"])
op.create_index("ix_user_behavior_user_occurred", "sys_user_behavior_event", ["user_id", "occurred_at", "id"])
op.create_index("ix_user_behavior_code_occurred", "sys_user_behavior_event", ["event_code", "occurred_at", "id"])
def downgrade() -> None:
op.drop_index("ix_user_behavior_code_occurred", table_name="sys_user_behavior_event")
op.drop_index("ix_user_behavior_user_occurred", table_name="sys_user_behavior_event")
op.drop_index("ix_user_behavior_occurred", table_name="sys_user_behavior_event")
op.drop_table("sys_user_behavior_event")

View File

@@ -0,0 +1,52 @@
"""add durable human attention screening jobs
Revision ID: 0039_attention_config
Revises: 0038_user_behavior
"""
from alembic import op
import sqlalchemy as sa
revision = "0039_attention_config"
down_revision = "0038_user_behavior"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"sys_human_attention_job",
sa.Column("id", sa.BigInteger(), autoincrement=True, nullable=False),
sa.Column("session_id", sa.BigInteger(), nullable=False),
sa.Column("message_id", sa.BigInteger(), nullable=False),
sa.Column("retrieval_log_id", sa.BigInteger(), nullable=True),
sa.Column("user_id", sa.BigInteger(), nullable=False),
sa.Column("question", sa.Text(), nullable=False),
sa.Column("answer", sa.Text(), nullable=False),
sa.Column("knowledge_missing", sa.Integer(), server_default="0", nullable=False),
sa.Column("config_snapshot", sa.Text(), nullable=False),
sa.Column("status", sa.String(length=20), server_default="pending", nullable=False),
sa.Column("attempt_count", sa.Integer(), server_default="0", nullable=False),
sa.Column("max_attempts", sa.Integer(), server_default="3", nullable=False),
sa.Column("next_run_at", sa.DateTime(), nullable=True),
sa.Column("locked_at", sa.DateTime(), nullable=True),
sa.Column("locked_by", sa.String(length=100), nullable=True),
sa.Column("error_message", sa.Text(), nullable=True),
sa.Column("created_at", sa.DateTime(), server_default=sa.text("CURRENT_TIMESTAMP"), nullable=False),
sa.Column("finished_at", sa.DateTime(), nullable=True),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("message_id", name="uq_human_attention_job_message"),
)
op.create_index("ix_human_attention_job_status_next", "sys_human_attention_job", ["status", "next_run_at", "id"])
op.create_index("ix_human_attention_job_user", "sys_human_attention_job", ["user_id"])
op.create_index("ix_human_attention_job_session", "sys_human_attention_job", ["session_id"])
op.create_index("ix_human_attention_job_retrieval", "sys_human_attention_job", ["retrieval_log_id"])
def downgrade() -> None:
op.drop_index("ix_human_attention_job_retrieval", table_name="sys_human_attention_job")
op.drop_index("ix_human_attention_job_session", table_name="sys_human_attention_job")
op.drop_index("ix_human_attention_job_user", table_name="sys_human_attention_job")
op.drop_index("ix_human_attention_job_status_next", table_name="sys_human_attention_job")
op.drop_table("sys_human_attention_job")

View File

@@ -0,0 +1,25 @@
"""add human attention time filter index
Revision ID: 0040_attention_filters
Revises: 0039_attention_config
"""
from alembic import op
revision = "0040_attention_filters"
down_revision = "0039_attention_config"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_index(
"ix_human_attention_created",
"sys_human_attention_record",
["created_at", "id"],
)
def downgrade() -> None:
op.drop_index("ix_human_attention_created", table_name="sys_human_attention_record")

View File

@@ -0,0 +1,87 @@
"""normalize legacy server timestamps to UTC
Revision ID: 0041_utc_time_contract
Revises: 0040_attention_filters
"""
from alembic import op
from sqlalchemy import text
revision = "0041_utc_time_contract"
down_revision = "0040_attention_filters"
branch_labels = None
depends_on = None
# Columns populated by MySQL CURRENT_TIMESTAMP. Fields that application
# services already wrote explicitly in UTC are intentionally excluded.
SERVER_TIMESTAMP_COLUMNS: dict[str, tuple[str, ...]] = {
"sys_admin": ("created_at", "updated_at"),
"sys_agent_batch_test": ("created_at", "updated_at"),
"sys_agent_batch_test_item": ("created_at", "updated_at"),
"sys_ai_request_log": ("created_at",),
"sys_chat_session": ("created_at", "updated_at"),
"sys_content_generation_config": ("updated_at",),
"sys_entitlement_plan": ("created_at", "updated_at"),
"sys_growth_profile_revision": ("created_at",),
"sys_human_attention_history": ("created_at",),
"sys_human_attention_job": ("created_at",),
"sys_human_attention_record": ("created_at", "updated_at"),
"sys_knowledge": ("created_at", "updated_at"),
"sys_knowledge_publish_log": ("created_at",),
"sys_knowledge_retrieval_log": ("created_at",),
"sys_knowledge_review_record": ("created_at",),
"sys_knowledge_sync_job": ("created_at",),
"sys_knowledge_version": ("created_at",),
"sys_log_retention_policy": ("updated_at",),
"sys_message_feedback": ("created_at",),
"sys_operation_log": ("created_at",),
"sys_periodic_report": ("created_at", "updated_at"),
"sys_prompt": ("updated_at",),
"sys_question_insight_cleaned_question": ("created_at", "updated_at"),
"sys_role": ("created_at", "updated_at"),
"sys_share_draft": ("created_at",),
"sys_sso_client": ("created_at", "updated_at"),
"sys_sso_login_audit": ("created_at",),
"sys_storage_snapshot": ("created_at",),
"sys_system_config": ("updated_at",),
"sys_teacher_help_card": ("created_at",),
"sys_topic_session": ("created_at", "updated_at"),
"sys_topic_summary": ("created_at", "updated_at"),
"sys_user": ("created_at", "updated_at"),
"sys_user_behavior_event": ("created_at",),
"sys_user_entitlement": ("created_at", "updated_at"),
"sys_user_external_identity": ("created_at", "updated_at"),
"sys_user_growth_profile": ("created_at", "updated_at"),
"sys_user_kb": ("created_at", "updated_at"),
}
def _server_offset_seconds() -> int:
connection = op.get_bind()
if connection.dialect.name != "mysql":
return 0
return int(connection.scalar(text("SELECT TIMESTAMPDIFF(SECOND, UTC_TIMESTAMP(), NOW())")) or 0)
def _shift_server_timestamps(offset_seconds: int) -> None:
if not offset_seconds:
return
connection = op.get_bind()
operation = "SUB" if offset_seconds > 0 else "ADD"
seconds = abs(offset_seconds)
for table_name, columns in SERVER_TIMESTAMP_COLUMNS.items():
assignments = ", ".join(
f"`{column}` = DATE_{operation}(`{column}`, INTERVAL {seconds} SECOND)"
for column in columns
)
connection.execute(text(f"UPDATE `{table_name}` SET {assignments}"))
def upgrade() -> None:
_shift_server_timestamps(_server_offset_seconds())
def downgrade() -> None:
_shift_server_timestamps(-_server_offset_seconds())

Some files were not shown because too many files have changed in this diff Show More