Compare commits
236 Commits
main
...
codex/h5-q
| Author | SHA1 | Date | |
|---|---|---|---|
| f4b2836c8a | |||
| 5740bd26e7 | |||
| 910e67bd89 | |||
| 6ffdd2c259 | |||
| efe835be81 | |||
| 833763c461 | |||
| 77399df060 | |||
| f952d6dc58 | |||
| 3faecab6ac | |||
| c4231f268b | |||
| 83b259af2d | |||
| ff4ba1148b | |||
| 71541f9a22 | |||
| 74213a363f | |||
| fd2da26d10 | |||
| 3b9215cc3d | |||
| c4de4e8c56 | |||
| cd3c875106 | |||
| 26109648af | |||
| 59c306f88f | |||
| 1954f461af | |||
| 8e6da99dcb | |||
| 8ee4d65b7b | |||
| 3f8ff30bbc | |||
| 8fb24f94ab | |||
| 861a0ae7d5 | |||
| b2b56f43bb | |||
| 20ed4875b8 | |||
| 7747334ea4 | |||
| d2e08ab36c | |||
| a3946013f5 | |||
| b6aff78aac | |||
| ab2c945f0b | |||
| 85a6da5949 | |||
| da313f88ed | |||
| 0008903e8d | |||
| a589a25bdc | |||
| 6c03faf10c | |||
| 99e23b33ec | |||
| 88ed92d78c | |||
| 763c78bd7e | |||
| a84650bb9e | |||
| ec9f8a015a | |||
| 4e872febd5 | |||
| 429636f3e8 | |||
| d8fde93b69 | |||
| 79094d0832 | |||
| 3e60a6da42 | |||
| 884dc765ad | |||
| ad2161497f | |||
| e426f12e22 | |||
| 1f82b89295 | |||
| 918ef5fa79 | |||
| 8e2dc1d781 | |||
| 162658384f | |||
| 77a899e24c | |||
| d1f573108e | |||
| c2655361b5 | |||
| 537978082a | |||
| cadf44e0b8 | |||
| 821cb26930 | |||
| d647a3d44b | |||
| 9173267e09 | |||
| c7bafda135 | |||
| a879dc2ff1 | |||
| bfaf2ebf67 | |||
| 13fed0467a | |||
| f7076569a7 | |||
| dde28aba83 | |||
| 6f07c70f1f | |||
| e3634eb26c | |||
| 9ba8fb6e0c | |||
| 921b56cfb9 | |||
| 1b5fcc4d52 | |||
| 6c02662d3b | |||
| 245b6b3eca | |||
| cbbdb41b80 | |||
| c3f24d0ccd | |||
| 56c9f2f359 | |||
| ba1840233c | |||
| e4a391aa08 | |||
| 128d444a76 | |||
| 653317ca7d | |||
| 971440ebff | |||
| bbd19cf9ed | |||
| dda2e040ca | |||
| a8bb8b068c | |||
| 44ab895149 | |||
| cb3b91b101 | |||
| 750a57a390 | |||
| 00d53c2569 | |||
| 0864f9305b | |||
| 2453816364 | |||
| 120fe185f9 | |||
| 82e0c02bac | |||
| b5047ca7e4 | |||
| 390f0a0cbf | |||
| 64a5052351 | |||
| e337897b7a | |||
| 8266d845c0 | |||
| c3639fe30d | |||
| 16fa9d06c2 | |||
| 872233adc6 | |||
| 6cadca8c8e | |||
| d2d43de6d3 | |||
| 73fdfed0e5 | |||
| 0199dd8242 | |||
| 9bce095583 | |||
| 70be6ab657 | |||
| 85208972b7 | |||
| 3393d1a809 | |||
| a81f16f298 | |||
| da795b10c3 | |||
| 10c829e62d | |||
| 603fb70715 | |||
| 4f75e11a8d | |||
| d0d25d9e17 | |||
| b3cb4a1ca9 | |||
| 5844741f24 | |||
| d9a8f42522 | |||
| dc4b1bfce9 | |||
| fc8a593393 | |||
| 2e274dea51 | |||
| 808ba74fc5 | |||
| 1ab834733e | |||
| 851c2a532a | |||
| b82a5a464f | |||
| 5ead280df0 | |||
| 83f67936c8 | |||
| 085deef9d3 | |||
| 75c54466d4 | |||
| 367127504f | |||
| 6d164a77fe | |||
| 0bc767933e | |||
| d7d9514348 | |||
| ebaf3defa2 | |||
| b759409580 | |||
| 076fcb9f02 | |||
| 7f835ec699 | |||
| fb39c9c543 | |||
| 9fb3dad5bd | |||
| aedecb3aab | |||
| 5aca872129 | |||
| 5819830e33 | |||
| f07335dab4 | |||
| ef24868105 | |||
| 55333445f4 | |||
| 53febbbf91 | |||
| 7587da6d18 | |||
| 664d1acbce | |||
| e661e53d0b | |||
| 5bd737b064 | |||
| 769bd2b7b4 | |||
| 0b0ff6b9dc | |||
| 34e740bbda | |||
| d9c7f9a309 | |||
| 0ea75dea1d | |||
| facb3a6a56 | |||
| 6c53566a4e | |||
| 532d51370e | |||
| cde7da572b | |||
| c56a4b49e1 | |||
| 34d6dd01c7 | |||
| 0e2706a4f4 | |||
| 2dcb43b717 | |||
| c86b517063 | |||
| d78d0ea75f | |||
| e1a130177c | |||
| 42569a1057 | |||
| f39e51a4d4 | |||
| d6b7ec1e52 | |||
| 1c73e8dee6 | |||
| 67048a14e8 | |||
| 76da6dd65a | |||
| 3598e34819 | |||
| f4196612d4 | |||
| 5dfff52b04 | |||
| 61db0aa9b8 | |||
| efd03ff9df | |||
| 6464c61396 | |||
| c68af6f806 | |||
| 70466a6a31 | |||
| 23d5f42972 | |||
| db1d83631b | |||
| 812ae80536 | |||
| 7a6068590e | |||
| 72bd2e1e02 | |||
| 1aab53cb37 | |||
| 211cb6207e | |||
| 465a481e7a | |||
| d476c54580 | |||
| 6f3bbd3803 | |||
| 10b6de0622 | |||
| 925ef0238d | |||
| 7811e3ed1f | |||
| 13185ab42b | |||
| 35011d2679 | |||
| fb0bbc346b | |||
| 7be5dde1db | |||
| aed6d8ffbf | |||
| 9c76be70fe | |||
| eda2c5af76 | |||
| ac72b6a94a | |||
| ca50fa437c | |||
| 6ecdc8011d | |||
| 4b3ea7baa4 | |||
| cd01e45fbd | |||
| d18d9fe9d8 | |||
| 29f25dd767 | |||
| 4f11d8632c | |||
| 84da551cbe | |||
| 03b1810a90 | |||
| 8d5d36c5eb | |||
| 7be9847d65 | |||
| da262f4596 | |||
| dada4aafcd | |||
| bcdf91c85d | |||
| 55b7c5b0ea | |||
| 08c301c136 | |||
| 08dd371480 | |||
| 62eeb3186c | |||
| 301661e7b4 | |||
| 86cbed2437 | |||
| 207340973c | |||
| 96742de07b | |||
| e9f3db9ab2 | |||
| 17cba7cf61 | |||
| 44af745828 | |||
| b7dfa13872 | |||
| dbd59a84df | |||
| 4eac7351a9 | |||
| 708e0a424e | |||
| fa2d82333c | |||
| a4b59add07 | |||
| cd73ed3001 | |||
| 34aeb22760 |
66
ai_knowledge_base_v2/.env.example
Normal file
66
ai_knowledge_base_v2/.env.example
Normal 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
2
ai_knowledge_base_v2/.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
.env
|
||||
outputs/
|
||||
51
ai_knowledge_base_v2/README.md
Normal file
51
ai_knowledge_base_v2/README.md
Normal 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`。
|
||||
5
ai_knowledge_base_v2/apps/admin-web/.dockerignore
Normal file
5
ai_knowledge_base_v2/apps/admin-web/.dockerignore
Normal file
@@ -0,0 +1,5 @@
|
||||
node_modules/
|
||||
dist/
|
||||
.env
|
||||
.env.local
|
||||
*.tsbuildinfo
|
||||
1
ai_knowledge_base_v2/apps/admin-web/.env.example
Normal file
1
ai_knowledge_base_v2/apps/admin-web/.env.example
Normal file
@@ -0,0 +1 @@
|
||||
VITE_API_BASE_URL=/api
|
||||
5
ai_knowledge_base_v2/apps/admin-web/.gitignore
vendored
Normal file
5
ai_knowledge_base_v2/apps/admin-web/.gitignore
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
node_modules/
|
||||
dist/
|
||||
*.tsbuildinfo
|
||||
.env
|
||||
.env.local
|
||||
12
ai_knowledge_base_v2/apps/admin-web/Dockerfile
Normal file
12
ai_knowledge_base_v2/apps/admin-web/Dockerfile
Normal 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"]
|
||||
12
ai_knowledge_base_v2/apps/admin-web/Dockerfile.prod
Normal file
12
ai_knowledge_base_v2/apps/admin-web/Dockerfile.prod
Normal 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
|
||||
33
ai_knowledge_base_v2/apps/admin-web/README.md
Normal file
33
ai_knowledge_base_v2/apps/admin-web/README.md
Normal 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
|
||||
```
|
||||
|
||||
管理后台登录页不会预填账号密码。生产环境必须通过环境变量配置安全的首个管理员账号密码,不能使用开发默认密码。
|
||||
12
ai_knowledge_base_v2/apps/admin-web/index.html
Normal file
12
ai_knowledge_base_v2/apps/admin-web/index.html
Normal 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>
|
||||
15
ai_knowledge_base_v2/apps/admin-web/nginx.prod.conf
Normal file
15
ai_knowledge_base_v2/apps/admin-web/nginx.prod.conf
Normal 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;
|
||||
}
|
||||
}
|
||||
1710
ai_knowledge_base_v2/apps/admin-web/package-lock.json
generated
Normal file
1710
ai_knowledge_base_v2/apps/admin-web/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
24
ai_knowledge_base_v2/apps/admin-web/package.json
Normal file
24
ai_knowledge_base_v2/apps/admin-web/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
1156
ai_knowledge_base_v2/apps/admin-web/src/App.vue
Normal file
1156
ai_knowledge_base_v2/apps/admin-web/src/App.vue
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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="取值 0–2。数值越小,回答越稳定、一致;数值越大,表达越多样,但也更容易发散。留空表示使用模型供应商默认值;做同题对比时建议设为 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="取值 0–1。模型只从累计概率达到该数值的候选词中选择。数值越小越专注,越大越多样。留空表示使用模型供应商默认值。"><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="取值 1–1000。每次生成只从概率最高的前 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 数,不等于字数。建议正式答疑使用 4096–8192;数值越大允许回答越长,也会增加耗时和费用,最终仍受模型自身上限约束。"><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="取值 -2–2,0 表示不调整。正数会降低已出现内容再次出现的概率,鼓励引入新主题;负数会更容易重复已出现内容。数值过高可能导致回答跳跃。"><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="取值 -2–2,0 表示不调整。正数会根据词语已出现的次数降低其再次出现的概率,用于减少反复表达;负数会增加重复倾向。数值过高可能影响语句连贯性。"><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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -0,0 +1,3 @@
|
||||
<template>
|
||||
<div class="table-row-actions"><slot /></div>
|
||||
</template>
|
||||
File diff suppressed because one or more lines are too long
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
382
ai_knowledge_base_v2/apps/admin-web/src/config/systemSettings.ts
Normal file
382
ai_knowledge_base_v2/apps/admin-web/src/config/systemSettings.ts
Normal 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);
|
||||
122
ai_knowledge_base_v2/apps/admin-web/src/main.ts
Normal file
122
ai_knowledge_base_v2/apps/admin-web/src/main.ts
Normal 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");
|
||||
438
ai_knowledge_base_v2/apps/admin-web/src/services/api.ts
Normal file
438
ai_knowledge_base_v2/apps/admin-web/src/services/api.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
2518
ai_knowledge_base_v2/apps/admin-web/src/styles.css
Normal file
2518
ai_knowledge_base_v2/apps/admin-web/src/styles.css
Normal file
File diff suppressed because it is too large
Load Diff
928
ai_knowledge_base_v2/apps/admin-web/src/types/api.ts
Normal file
928
ai_knowledge_base_v2/apps/admin-web/src/types/api.ts
Normal 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 }>;
|
||||
}
|
||||
81
ai_knowledge_base_v2/apps/admin-web/src/utils/dateTime.ts
Normal file
81
ai_knowledge_base_v2/apps/admin-web/src/utils/dateTime.ts
Normal 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();
|
||||
}
|
||||
1
ai_knowledge_base_v2/apps/admin-web/src/vite-env.d.ts
vendored
Normal file
1
ai_knowledge_base_v2/apps/admin-web/src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
17
ai_knowledge_base_v2/apps/admin-web/tsconfig.json
Normal file
17
ai_knowledge_base_v2/apps/admin-web/tsconfig.json
Normal 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"]
|
||||
}
|
||||
40
ai_knowledge_base_v2/apps/admin-web/vite.config.ts
Normal file
40
ai_knowledge_base_v2/apps/admin-web/vite.config.ts
Normal 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,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
7
ai_knowledge_base_v2/apps/backend/.dockerignore
Normal file
7
ai_knowledge_base_v2/apps/backend/.dockerignore
Normal file
@@ -0,0 +1,7 @@
|
||||
.env
|
||||
.venv/
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
.pytest_cache/
|
||||
.mypy_cache/
|
||||
.ruff_cache/
|
||||
72
ai_knowledge_base_v2/apps/backend/.env.example
Normal file
72
ai_knowledge_base_v2/apps/backend/.env.example
Normal 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
|
||||
7
ai_knowledge_base_v2/apps/backend/.gitignore
vendored
Normal file
7
ai_knowledge_base_v2/apps/backend/.gitignore
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
.env
|
||||
.venv/
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
.pytest_cache/
|
||||
.mypy_cache/
|
||||
.ruff_cache/
|
||||
19
ai_knowledge_base_v2/apps/backend/Dockerfile
Normal file
19
ai_knowledge_base_v2/apps/backend/Dockerfile
Normal 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"]
|
||||
52
ai_knowledge_base_v2/apps/backend/README.md
Normal file
52
ai_knowledge_base_v2/apps/backend/README.md
Normal 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 黑名单。
|
||||
37
ai_knowledge_base_v2/apps/backend/alembic.ini
Normal file
37
ai_knowledge_base_v2/apps/backend/alembic.ini
Normal 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
|
||||
51
ai_knowledge_base_v2/apps/backend/alembic/env.py
Normal file
51
ai_knowledge_base_v2/apps/backend/alembic/env.py
Normal 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()
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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)
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
@@ -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",
|
||||
)
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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,
|
||||
},
|
||||
],
|
||||
)
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
@@ -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)
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
@@ -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)
|
||||
)
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
@@ -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)
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
@@ -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
Reference in New Issue
Block a user