Compare commits

..

77 Commits

Author SHA1 Message Date
f4b2836c8a feat: improve chat feedback navigation 2026-08-31 11:41:56 +08:00
5740bd26e7 feat: improve audit exports and admin analytics 2026-08-25 18:49:56 +08:00
910e67bd89 feat: 完善人工关注与内容合规配置 2026-08-24 17:49:56 +08:00
6ffdd2c259 feat: 支持按权益名称导入用户 2026-08-19 15:11:49 +08:00
efe835be81 feat: 完善周期报告与用户行为分析
- 支持可配置周报月报模板与登录后异步补生成\n- 增加用户行为埋点和后台分析页面\n- 移除主题额度并保留实修回顾结算\n- 修复历史会话续聊上下文丢失
2026-08-19 11:53:34 +08:00
833763c461 feat: refine topic insights and learner experience 2026-08-17 12:42:16 +08:00
77399df060 feat: support configurable card variables and layouts 2026-08-14 13:36:53 +08:00
f952d6dc58 feat: 支持 Agent 并发批测与洞察导出 2026-08-13 17:25:22 +08:00
3faecab6ac feat: 增加 Agent Excel 批量测试 2026-08-12 17:00:50 +08:00
c4231f268b fix: 优化反馈日期筛选控件 2026-08-11 18:58:11 +08:00
83b259af2d feat: improve async feedback and export workflow 2026-08-11 18:45:39 +08:00
ff4ba1148b feat: reveal encrypted config secrets on demand 2026-08-11 18:20:22 +08:00
71541f9a22 fix: refresh voice config only on chat lifecycle 2026-08-11 17:49:00 +08:00
74213a363f fix: separate voice credentials and refresh feature toggle 2026-08-11 17:46:56 +08:00
fd2da26d10 feat: add admin permissions voice input analytics and feedback 2026-08-11 17:17:59 +08:00
3b9215cc3d feat: redesign system configuration workspace 2026-08-04 15:08:24 +08:00
c4de4e8c56 fix: optimize sso integration layout 2026-08-03 19:11:41 +08:00
cd3c875106 feat: isolate external application conversations 2026-08-03 18:57:19 +08:00
26109648af feat: 增加可信应用免登录接入 2026-08-03 18:28:40 +08:00
59c306f88f fix: 精简AI失败重试文案 2026-08-03 18:04:27 +08:00
1954f461af feat: 增加AI回复手动重试 2026-08-03 17:55:02 +08:00
8e6da99dcb feat: 提升并发容量默认配置 2026-08-03 17:42:17 +08:00
8ee4d65b7b feat: 增加内容生成配置管理 2026-08-03 17:23:42 +08:00
3f8ff30bbc fix: 完善个人中心卡片管理 2026-08-03 16:49:31 +08:00
8fb24f94ab fix: 优化个人中心卡片切换交互 2026-08-03 16:38:15 +08:00
861a0ae7d5 refactor: 拆分管理后台页面并按需加载 2026-08-03 16:22:03 +08:00
b2b56f43bb feat: 完成权益到期与续期闭环 2026-08-03 14:32:38 +08:00
20ed4875b8 feat: 新增用户端个人中心 2026-08-03 12:38:00 +08:00
7747334ea4 feat: 将成长档案调整为近期实修回顾 2026-08-03 12:25:51 +08:00
d2e08ab36c feat: 异步沉淀主题摘要和成长档案 2026-08-03 11:35:58 +08:00
a3946013f5 feat: 优化权益管理并完成一期验收 2026-07-31 18:49:48 +08:00
b6aff78aac feat: align agent preview with learner topics 2026-07-31 18:21:36 +08:00
ab2c945f0b feat: make chat model routing configurable 2026-07-31 17:57:26 +08:00
85a6da5949 feat: route fixed information chats by model 2026-07-31 17:42:25 +08:00
da313f88ed feat: route background ai workloads by model 2026-07-31 17:25:16 +08:00
0008903e8d feat: schedule periodic reports asynchronously 2026-07-31 17:11:21 +08:00
a589a25bdc feat: persist cleaned question insights 2026-07-31 16:50:11 +08:00
6c03faf10c feat: enrich question insights classification 2026-07-31 16:40:13 +08:00
99e23b33ec feat: add periodic practice reports 2026-07-31 16:37:25 +08:00
88ed92d78c feat: add admin user detail insights 2026-07-31 16:30:39 +08:00
763c78bd7e feat: expand user growth record view 2026-07-31 16:25:50 +08:00
a84650bb9e feat: add ai cost tracking foundation 2026-07-31 16:24:35 +08:00
ec9f8a015a feat: soften quota messaging 2026-07-31 16:10:35 +08:00
4e872febd5 feat: expose knowledge type priority traces 2026-07-31 16:09:27 +08:00
429636f3e8 feat: add class share drafts 2026-07-31 16:07:18 +08:00
d8fde93b69 feat: add teacher help cards 2026-07-31 15:53:47 +08:00
79094d0832 feat: align agent preview user context 2026-07-31 15:48:40 +08:00
3e60a6da42 feat: add topic summaries and growth profiles 2026-07-31 15:34:03 +08:00
884dc765ad feat: add entitlement plans and topic sessions 2026-07-31 15:22:11 +08:00
ad2161497f feat: add question insight analytics 2026-07-31 11:39:59 +08:00
e426f12e22 fix: highlight full knowledge search terms 2026-07-30 17:14:51 +08:00
1f82b89295 fix: improve chat detail page navigation 2026-07-30 15:32:56 +08:00
918ef5fa79 fix: speed up chat detail pagination 2026-07-30 15:21:26 +08:00
8e2dc1d781 fix: paginate chat detail messages 2026-07-30 15:09:05 +08:00
162658384f fix: deduplicate knowledge content search results 2026-07-30 14:45:20 +08:00
77a899e24c fix: align agent no-hit handling 2026-07-30 14:36:09 +08:00
d1f573108e feat: add knowledge content search 2026-07-30 11:02:04 +08:00
c2655361b5 feat: add agent response depth control 2026-07-20 11:36:18 +08:00
537978082a fix: preserve agent debug conversation context 2026-07-17 16:06:43 +08:00
cadf44e0b8 fix: allow direct knowledge deletion 2026-07-17 15:34:23 +08:00
821cb26930 feat: enforce knowledge type source hierarchy 2026-07-17 15:14:18 +08:00
d647a3d44b feat: apply knowledge type retrieval priority 2026-07-17 15:12:34 +08:00
9173267e09 fix(chat): refine markdown typography 2026-07-17 14:33:26 +08:00
c7bafda135 feat(agent): add debug output switches 2026-07-17 14:19:23 +08:00
a879dc2ff1 feat(agent): control reasoning visibility 2026-07-17 14:05:54 +08:00
bfaf2ebf67 feat(agent): stream admin debug preview 2026-07-17 13:47:05 +08:00
13fed0467a feat(chat): add configurable streaming output 2026-07-17 13:34:27 +08:00
f7076569a7 feat(knowledge): add batch synchronization 2026-07-17 13:20:41 +08:00
dde28aba83 fix(rag): route exercise titles to knowledge base 2026-07-17 12:31:56 +08:00
6f07c70f1f fix(rag): keep practice headings with details 2026-07-17 12:16:30 +08:00
e3634eb26c refactor(knowledge): keep latest version only 2026-07-16 18:24:49 +08:00
9ba8fb6e0c feat(knowledge): auto-publish successful syncs 2026-07-16 18:07:17 +08:00
921b56cfb9 fix(agent): keep response policy in managed prompt 2026-07-16 17:16:51 +08:00
1b5fcc4d52 fix(agent): enforce complete homework answers 2026-07-16 17:03:04 +08:00
6c02662d3b feat(agent): persist production generation settings 2026-07-16 16:07:11 +08:00
245b6b3eca fix(rag): recall complete course homework lists 2026-07-16 15:48:40 +08:00
cbbdb41b80 fix(admin): restore knowledge preview scrolling 2026-07-16 14:19:33 +08:00
243 changed files with 36745 additions and 1988 deletions

View File

@@ -21,6 +21,30 @@ 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
@@ -36,3 +60,7 @@ 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

View File

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

View File

@@ -9,11 +9,14 @@
"version": "0.1.0",
"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"
}
@@ -877,6 +880,13 @@
"integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==",
"license": "MIT"
},
"node_modules/@types/linkify-it": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/@types/linkify-it/-/linkify-it-5.0.0.tgz",
"integrity": "sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/lodash": {
"version": "4.17.24",
"resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.24.tgz",
@@ -892,6 +902,24 @@
"@types/lodash": "*"
}
},
"node_modules/@types/markdown-it": {
"version": "14.1.2",
"resolved": "https://registry.npmjs.org/@types/markdown-it/-/markdown-it-14.1.2.tgz",
"integrity": "sha512-promo4eFwuiW+TfGxhi+0x3czqTYJkG8qB17ZUJiVF10Xm7NLVRSLUsfRTU/6h1e24VvRnXCx+hG7li58lkzog==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/linkify-it": "^5",
"@types/mdurl": "^2"
}
},
"node_modules/@types/mdurl": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/@types/mdurl/-/mdurl-2.0.0.tgz",
"integrity": "sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/web-bluetooth": {
"version": "0.0.21",
"resolved": "https://registry.npmjs.org/@types/web-bluetooth/-/web-bluetooth-0.0.21.tgz",
@@ -1104,6 +1132,12 @@
"dev": true,
"license": "MIT"
},
"node_modules/argparse": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
"integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
"license": "Python-2.0"
},
"node_modules/async-validator": {
"version": "4.2.5",
"resolved": "https://registry.npmjs.org/async-validator/-/async-validator-4.2.5.tgz",
@@ -1122,6 +1156,16 @@
"integrity": "sha512-98IT+HOahAisibz/yjKbzuOBwYcjJ7BCLPzARyHiyEBmRz4fatF+KPJszEHXsGYjUG234aH/cOjW1wwTbKUZlA==",
"license": "MIT"
},
"node_modules/echarts": {
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/echarts/-/echarts-6.1.0.tgz",
"integrity": "sha512-q0yaFPggC9FUdsWH4blavRWFmxdrIodbkoKNAjJudAI6CA9gNPxHtV2RcZNEepZVlk4yvBYkOkbk6HIVpIyHZA==",
"license": "Apache-2.0",
"dependencies": {
"tslib": "2.3.0",
"zrender": "6.1.0"
}
},
"node_modules/element-plus": {
"version": "2.14.2",
"resolved": "https://registry.npmjs.org/element-plus/-/element-plus-2.14.2.tgz",
@@ -1238,6 +1282,25 @@
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/linkify-it": {
"version": "5.0.2",
"resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.2.tgz",
"integrity": "sha512-ONTm2jCMAVZjgQa/Fy1kScXsuOoF5NPTsoFBdE1KVIZ2vAh/r9+Bqo+0jINCBYnavTPQZz38QzFTme79ENoN3Q==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/puzrin"
},
{
"type": "github",
"url": "https://github.com/sponsors/markdown-it"
}
],
"license": "MIT",
"dependencies": {
"uc.micro": "^2.0.0"
}
},
"node_modules/lodash": {
"version": "4.18.1",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz",
@@ -1270,6 +1333,51 @@
"@jridgewell/sourcemap-codec": "^1.5.5"
}
},
"node_modules/markdown-it": {
"version": "14.3.0",
"resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.3.0.tgz",
"integrity": "sha512-RCEsPjR+sr0x+AuYp601tKTkgFG4YEPLCzHST3cQ/fhlJkqAkz1L2/Qbp1j9qw5SBwQHFBoW8+hoN5xssOF0Tw==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/puzrin"
},
{
"type": "github",
"url": "https://github.com/sponsors/markdown-it"
}
],
"license": "MIT",
"dependencies": {
"argparse": "^2.0.1",
"entities": "^4.5.0",
"linkify-it": "^5.0.2",
"mdurl": "^2.0.0",
"punycode.js": "^2.3.1",
"uc.micro": "^2.1.0"
},
"bin": {
"markdown-it": "bin/markdown-it.mjs"
}
},
"node_modules/markdown-it/node_modules/entities": {
"version": "4.5.0",
"resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",
"integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==",
"license": "BSD-2-Clause",
"engines": {
"node": ">=0.12"
},
"funding": {
"url": "https://github.com/fb55/entities?sponsor=1"
}
},
"node_modules/mdurl": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/mdurl/-/mdurl-2.0.0.tgz",
"integrity": "sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==",
"license": "MIT"
},
"node_modules/memoize-one": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-6.0.0.tgz",
@@ -1360,6 +1468,15 @@
"node": "^10 || ^12 || >=14"
}
},
"node_modules/punycode.js": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/punycode.js/-/punycode.js-2.3.1.tgz",
"integrity": "sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/rollup": {
"version": "4.62.2",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.2.tgz",
@@ -1429,6 +1546,12 @@
"url": "https://github.com/sponsors/SuperchupuDev"
}
},
"node_modules/tslib": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.0.tgz",
"integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==",
"license": "0BSD"
},
"node_modules/typescript": {
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
@@ -1443,6 +1566,12 @@
"node": ">=14.17"
}
},
"node_modules/uc.micro": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-2.1.0.tgz",
"integrity": "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==",
"license": "MIT"
},
"node_modules/vite": {
"version": "7.3.6",
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.6.tgz",
@@ -1567,6 +1696,15 @@
"peerDependencies": {
"typescript": ">=5.0.0"
}
},
"node_modules/zrender": {
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/zrender/-/zrender-6.1.0.tgz",
"integrity": "sha512-oEGMDB6pOP2S6OwRR4PdVv610zrjnA3Bh+JnSG12fYJlBKjtNAoEb5fSUoCOOINlH96I2fU38/A2UpRKs67xYQ==",
"license": "BSD-3-Clause",
"dependencies": {
"tslib": "2.3.0"
}
}
}
}

View File

@@ -10,11 +10,14 @@
},
"dependencies": {
"@vitejs/plugin-vue": "^6.0.1",
"echarts": "^6.1.0",
"element-plus": "^2.11.0",
"markdown-it": "^14.3.0",
"vite": "^7.1.0",
"vue": "^3.5.0"
},
"devDependencies": {
"@types/markdown-it": "^14.1.2",
"typescript": "^5.9.0",
"vue-tsc": "^3.1.0"
}

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

@@ -1,45 +1,82 @@
<script setup lang="ts">
import { ElMessage, ElMessageBox } from "element-plus";
import { computed, onMounted, reactive, ref, watch } from "vue";
import { computed, nextTick, onMounted, reactive, ref, watch } from "vue";
import { api } from "../services/api";
import type { AgentDebugResult, KnowledgeItem, ModelItem, PromptDetail, PromptHistoryItem } from "../types/api";
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 = defineProps<{ previewKnowledgeId?: number | null }>();
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 agentPreviewMessages = ref<{ role: "user" | "assistant" | "system"; content: string }[]>([
{ role: "assistant", content: "选择模型和知识库后,可以在这里调试 Agent 的真实问答效果。" },
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: 1024 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(() => {
@@ -47,12 +84,35 @@ const selectedModelName = computed(() => {
return model?.displayName || model?.modelName || "未选择模型";
});
const selectedKnowledgeSummary = computed(() => {
if (!agentForm.knowledgeIds.length) return "未选择知识库";
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);
@@ -63,17 +123,22 @@ watch(() => props.previewKnowledgeId, (knowledgeId) => {
async function load() {
loading.value = true;
try {
const [promptResult, modelRows, knowledgeRows] = await Promise.all([
api.prompt(), api.models(), api.knowledgeOptions(),
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.enabled === 1)?.id ?? modelRows[0]?.id;
agentForm.knowledgeIds = knowledgeRows
.filter((item) => item.status === 1 && item.lifecycleStatus === "active")
.map((item) => item.id);
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 配置加载失败"));
@@ -82,6 +147,126 @@ async function load() {
}
}
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];
@@ -188,54 +373,101 @@ async function restoreSelectedHistory() {
}
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 {
const result: AgentDebugResult = await api.debugAgent({
promptContent: promptContent.value,
modelId: agentForm.modelId,
knowledgeIds: agentForm.knowledgeIds,
question,
temperature: agentForm.temperature,
topP: agentForm.topP,
topK: agentForm.topK,
presencePenalty: agentForm.presencePenalty,
frequencyPenalty: agentForm.frequencyPenalty,
maxToken: agentForm.maxToken,
});
if (!result.ok) {
agentPreviewMessages.value.push({ role: "assistant", content: result.message });
return ElMessage.error(result.message);
}
agentPreviewMessages.value.push({ role: "assistant", content: result.answer });
agentDebugTrace.value = result.retrievalTrace || [];
ElMessage.success(result.message);
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) {
const message = errorMessage(error, "Agent 调试失败");
agentPreviewMessages.value.push({ role: "assistant", content: message });
ElMessage.error(message);
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 clearAgentPreview() {
agentPreviewMessages.value = [{ role: "assistant", content: "预览已清空,可以继续发起新的 Agent 调试。" }];
agentDebugTrace.value = [];
function stopDebugAgent() {
agentDebugAbortController.value?.abort();
}
function agentMessageParts(content: string) {
const matched = content.match(/<think>([\s\S]*?)<\/think>/i);
if (!matched) return { reasoning: "", answer: content };
return {
reasoning: matched[1].trim(),
answer: content.replace(matched[0], "").trim() || "模型未返回正式回答。",
};
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) {
@@ -248,11 +480,6 @@ function changeTypeTag(value?: string) {
return "info";
}
function formatDate(value?: string | null) {
if (!value) return "尚未保存";
return new Date(value).toLocaleString("zh-CN", { hour12: false });
}
function errorMessage(error: unknown, fallback: string) {
return error instanceof Error ? error.message : fallback;
}
@@ -262,15 +489,15 @@ function errorMessage(error: unknown, fallback: string) {
<div class="page-head agent-page-head">
<div>
<h2>Agent 管理</h2>
<p>维护主提示词及历史版本配置本次调试参数并验证真实检索与回答效果</p>
<p>维护主提示词用户端正式生成参数和历史版本并验证真实检索与回答效果</p>
</div>
<div class="agent-current-summary">
<el-tag type="success">当前版本 {{ prompt?.id ? `#${prompt.id}` : "系统默认" }}</el-tag>
<span>{{ prompt?.updatedByName || "系统默认" }} · {{ formatDate(prompt?.updatedAt) }}</span>
<span>{{ prompt?.updatedByName || "系统默认" }} · {{ formatDateTime(prompt?.updatedAt, "尚未保存") }}</span>
</div>
</div>
<section v-loading="loading" class="agent-workbench agent-workbench-v2">
<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">
@@ -286,51 +513,177 @@ function errorMessage(error: unknown, fallback: string) {
</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="选择已接入模型">
<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>
<div class="agent-config-grid">
<el-form-item>
<template #label><span class="agent-parameter-label">回答随机性<el-tooltip placement="top" :show-after="200" content="取值 02。数值越小回答越稳定、一致数值越大表达越多样但也更容易发散。答疑场景建议使用较低数值。"><button type="button" class="agent-parameter-help" aria-label="回答随机性说明">?</button></el-tooltip></span></template>
<el-input-number v-model="agentForm.temperature" :min="0" :max="2" :step="0.1" />
</el-form-item>
<el-form-item>
<template #label><span class="agent-parameter-label">核采样概率<el-tooltip placement="top" :show-after="200" content="取值 01。模型只从累计概率达到该数值的候选词中选择。数值越小越专注越大越多样。留空表示使用模型供应商默认值。"><button type="button" class="agent-parameter-help" aria-label="核采样概率说明">?</button></el-tooltip></span></template>
<el-input-number v-model="agentForm.topP" :min="0" :max="1" :step="0.05" />
</el-form-item>
<el-form-item>
<template #label><span class="agent-parameter-label">候选词数量<el-tooltip placement="top" :show-after="200" content="取值 11000。每次生成只从概率最高的前 K 个候选词中选择。数值越小越保守,越大选择越多。留空表示使用供应商默认值,部分模型可能不支持。"><button type="button" class="agent-parameter-help" aria-label="候选词数量说明">?</button></el-tooltip></span></template>
<el-input-number v-model="agentForm.topK" :min="1" :max="1000" />
</el-form-item>
<el-form-item>
<template #label><span class="agent-parameter-label">最大输出长度<el-tooltip placement="top" :show-after="200" content="限制模型本次最多生成的 Token 数,不等于字数,也不代表一定会生成到该长度。数值越大允许回答越长,同时可能增加耗时和费用,最终仍受模型上限约束。"><button type="button" class="agent-parameter-help" aria-label="最大输出长度说明">?</button></el-tooltip></span></template>
<el-input-number v-model="agentForm.maxToken" :min="1" :max="100000" />
</el-form-item>
<el-form-item>
<template #label><span class="agent-parameter-label">主题重复惩罚<el-tooltip placement="top" :show-after="200" content="取值 -220 表示不调整。正数会降低已出现内容再次出现的概率,鼓励引入新主题;负数会更容易重复已出现内容。数值过高可能导致回答跳跃。"><button type="button" class="agent-parameter-help" aria-label="主题重复惩罚说明">?</button></el-tooltip></span></template>
<el-input-number v-model="agentForm.presencePenalty" :min="-2" :max="2" :step="0.1" />
</el-form-item>
<el-form-item>
<template #label><span class="agent-parameter-label">高频重复惩罚<el-tooltip placement="top" :show-after="200" content="取值 -220 表示不调整。正数会根据词语已出现的次数降低其再次出现的概率,用于减少反复表达;负数会增加重复倾向。数值过高可能影响语句连贯性。"><button type="button" class="agent-parameter-help" aria-label="高频重复惩罚说明">?</button></el-tooltip></span></template>
<el-input-number v-model="agentForm.frequencyPenalty" :min="-2" :max="2" :step="0.1" />
</el-form-item>
</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">
@@ -340,7 +693,7 @@ function errorMessage(error: unknown, fallback: string) {
<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 }} · {{ formatDate(item.updatedAt) }}</span>
<span>{{ item.updatedByName }} · {{ formatDateTime(item.updatedAt, "尚未保存") }}</span>
</div>
<p>{{ item.preview || "空内容" }}</p>
<div class="prompt-history-foot">
@@ -356,26 +709,32 @@ function errorMessage(error: unknown, fallback: string) {
<aside class="agent-preview-panel">
<div class="agent-preview-head">
<div><h3>调试预览</h3><small>{{ selectedKnowledgeSummary }}</small></div>
<div class="agent-preview-head-actions"><span>{{ selectedModelName }}</span><el-button link @click="clearAgentPreview">清空</el-button></div>
<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 class="agent-preview-chat">
<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'">
<details v-if="agentMessageParts(message.content).reasoning" class="agent-preview-thinking"><summary>思考过程</summary><pre>{{ agentMessageParts(message.content).reasoning }}</pre></details>
<pre>{{ agentMessageParts(message.content).answer }}</pre>
<StreamingMarkdownMessage
:content="message.content"
:reasoning="message.reasoning"
:show-reasoning="message.showReasoning"
:streaming="message.streaming"
/>
</template>
<pre v-else>{{ message.content }}</pre>
</div>
</article>
<article v-if="agentDebugging" class="agent-preview-message-row assistant thinking"><div class="agent-preview-avatar">AI</div><div class="agent-preview-bubble"><strong>Agent</strong><pre>正在思考中...</pre></div></article>
</div>
<div class="agent-preview-composer">
<el-input v-model="agentForm.question" type="textarea" :rows="2" resize="none" placeholder="向 Agent 发送测试问题Enter 发送Shift+Enter 换行" @keydown.enter.exact.prevent="debugAgent" />
<el-button type="primary" :loading="agentDebugging" @click="debugAgent">发送</el-button>
<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>
@@ -396,7 +755,7 @@ function errorMessage(error: unknown, fallback: string) {
<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 }} · {{ formatDate(selectedHistory.updatedAt) }} · {{ selectedHistory.charCount }} 字符</span></div>
<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>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -2,15 +2,14 @@
import { computed, ref, watch } from "vue";
import { api } from "../services/api";
import type { KnowledgeVersion, KnowledgeVersionContent } from "../types/api";
import type { KnowledgeVersionContent } from "../types/api";
const props = defineProps<{
versions: KnowledgeVersion[];
versionId: number | null;
targetSectionId?: number | null;
highlightKeyword?: string;
}>();
const emit = defineEmits<{ "update:versionId": [versionId: number] }>();
const loading = ref(false);
const error = ref("");
const content = ref<KnowledgeVersionContent | null>(null);
@@ -28,6 +27,7 @@ const characterCount = computed(() => (
));
watch(() => props.versionId, loadVersion, { immediate: true });
watch(() => props.targetSectionId, selectTargetSection);
async function loadVersion(versionId: number | null) {
const sequence = ++requestSequence;
@@ -41,7 +41,9 @@ async function loadVersion(versionId: number | null) {
const result = await api.knowledgeVersionContent(versionId);
if (sequence !== requestSequence) return;
content.value = result;
activeSectionId.value = result.sections[0]?.id ?? null;
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 : "知识库内容加载失败";
@@ -50,20 +52,32 @@ async function loadVersion(versionId: number | null) {
}
}
function selectVersion(value: number) {
emit("update:versionId", value);
function selectTargetSection(sectionId?: number | null) {
if (!sectionId || !content.value?.sections.some((section) => section.id === sectionId)) return;
activeSectionId.value = sectionId;
}
function versionStatus(version: KnowledgeVersion) {
const labels: Record<string, string> = {
published: "正式版本",
pending_review: "待审核",
approved: "待发布",
rejected: "已拒绝",
superseded: "历史版本",
};
return labels[version.status] ?? version.status;
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>
@@ -71,22 +85,8 @@ function versionStatus(version: KnowledgeVersion) {
<header class="content-preview-toolbar">
<div>
<h4>知识库内容预览</h4>
<p>按同步后的章节结构查看实际进入检索链路的内容</p>
<p>查看当前最新版实际进入检索链路的章节内容</p>
</div>
<el-select
:model-value="versionId"
class="content-version-select"
placeholder="选择预览版本"
:disabled="versions.length === 0"
@update:model-value="selectVersion"
>
<el-option
v-for="version in versions"
:key="version.id"
:label="`V${version.versionNo} · ${versionStatus(version)}`"
:value="version.id"
/>
</el-select>
</header>
<el-alert v-if="error" :title="error" type="error" :closable="false" show-icon>
@@ -125,7 +125,7 @@ function versionStatus(version: KnowledgeVersion) {
<div><small>{{ activeSection.sectionKey }}</small><h3>{{ activeSection.title }}</h3></div>
<span>源内容位置 {{ activeSection.sourceStart }}{{ activeSection.sourceEnd }}</span>
</div>
<pre>{{ activeSection.content }}</pre>
<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>
@@ -136,7 +136,6 @@ function versionStatus(version: KnowledgeVersion) {
<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-column prop="reviewStatus" label="审核" width="110" />
</el-table>
</el-collapse-item>
</el-collapse>
@@ -149,32 +148,31 @@ function versionStatus(version: KnowledgeVersion) {
.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-version-select { width: 220px; flex: 0 0 220px; }
.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); min-height: 480px; max-height: 62vh; overflow: hidden; border: 1px solid #dfe8e5; border-radius: 12px; background: #fff; }
.content-outline { overflow-y: auto; padding: 14px 10px; border-right: 1px solid #e5ece9; background: #f7faf9; }
.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; overflow-y: auto; padding: 24px 28px; }
.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-version-select { width: 100%; flex-basis: auto; }
.content-preview-layout { grid-template-columns: 1fr; max-height: none; }
.content-outline { display: flex; gap: 8px; overflow-x: auto; border-right: 0; border-bottom: 1px solid #e5ece9; }
.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 { max-height: 58vh; padding: 20px; }
.content-article { height: auto; max-height: 58vh; padding: 20px; }
.content-article-head { flex-direction: column; gap: 8px; }
}
</style>

View File

@@ -3,14 +3,16 @@ 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,
KnowledgeVersion,
MigrationSummary,
} from "../types/api";
@@ -22,31 +24,44 @@ const loading = ref(false);
const operatingId = ref<number | null>(null);
const detailOpen = ref(false);
const detail = ref<KnowledgeDetail | null>(null);
const versions = ref<KnowledgeVersion[]>([]);
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: "", pending: "" });
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",
reviewMode: "manual",
status: 0,
remark: "",
});
const editForm = reactive({ id: 0, name: "", knowledgeType: "general", reviewMode: "manual" });
const batchForm = reactive({ knowledgeType: "", reviewMode: "" });
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);
@@ -55,7 +70,7 @@ async function load(page = pager.page, pageSize = pager.pageSize) {
try {
const result = await api.knowledge({
keyword: filter.keyword, knowledgeType: filter.type, openStatus: filter.open,
sourceStatus: filter.source, pending: filter.pending === "" ? undefined : filter.pending === "yes",
sourceStatus: filter.source,
page, pageSize,
});
rows.value = result.items;
@@ -74,7 +89,6 @@ function openEdit() {
id: detail.value.knowledge.id,
name: detail.value.knowledge.name,
knowledgeType: detail.value.knowledge.knowledgeType,
reviewMode: detail.value.knowledge.reviewMode,
});
editOpen.value = true;
}
@@ -87,7 +101,7 @@ async function saveMetadata() {
editSaving.value = true;
try {
await api.updateKnowledgeMetadata(editForm.id, {
name: editForm.name.trim(), knowledgeType: editForm.knowledgeType, reviewMode: editForm.reviewMode,
name: editForm.name.trim(), knowledgeType: editForm.knowledgeType,
});
ElMessage.success("知识库基本信息已更新");
editOpen.value = false;
@@ -104,13 +118,13 @@ function openBatchEdit() {
ElMessage.warning("请先勾选需要修改的知识库");
return;
}
Object.assign(batchForm, { knowledgeType: "", reviewMode: "" });
Object.assign(batchForm, { knowledgeType: "" });
batchOpen.value = true;
}
async function saveBatchMetadata() {
if (!batchForm.knowledgeType && !batchForm.reviewMode) {
ElMessage.warning("请至少选择一个修改项");
if (!batchForm.knowledgeType) {
ElMessage.warning("请选择知识库类型");
return;
}
if (!await confirmAction(
@@ -123,7 +137,6 @@ async function saveBatchMetadata() {
const result = await api.batchUpdateKnowledgeMetadata({
knowledgeIds: selectedRows.value.map((item) => item.id),
knowledgeType: batchForm.knowledgeType || undefined,
reviewMode: batchForm.reviewMode || undefined,
});
ElMessage[result.failed ? "warning" : "success"](`批量修改完成:成功 ${result.success} 个,失败 ${result.failed}`);
batchOpen.value = false;
@@ -135,15 +148,36 @@ async function saveBatchMetadata() {
}
}
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 === "versions") {
await openDetail(item);
if (detailOpen.value) activeTab.value = "versions";
return;
}
if (command === "archive") return await archive(item);
if (command === "restore") return await restore(item);
if (command === "delete") return await remove(item);
@@ -152,6 +186,36 @@ async function handleMore(command: string, item: KnowledgeItem) {
}
}
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",
@@ -168,9 +232,9 @@ async function createKnowledge() {
}
try {
await api.createKnowledge({ ...form });
Object.assign(form, { name: "", feishuSpaceId: "", feishuNodeId: "", knowledgeType: "general", reviewMode: "manual", status: 0, remark: "" });
Object.assign(form, { name: "", feishuSpaceId: "", feishuNodeId: "", knowledgeType: "general", status: 0, remark: "" });
nodeResolved.value = false; sourceTitle.value = "";
ElMessage.success("知识库已新增,请进行首次同步");
ElMessage.success("知识库已新增,首次同步成功后将自动生效");
await load();
} catch (error) {
ElMessage.error(errorMessage(error, "知识库新增失败"));
@@ -192,39 +256,50 @@ async function resolveNode() {
} finally { resolvingNode.value = false; }
}
async function openDetail(item: KnowledgeItem) {
async function openDetail(
item: KnowledgeItem | { id: number },
options: { sectionId?: number | null; keyword?: string; tab?: string } = {},
) {
detailOpen.value = true;
detail.value = null;
activeTab.value = "overview";
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 ?? versions.value[0]?.id ?? null;
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, nextVersions, nextJobs] = await Promise.all([
api.knowledgeDetail(id), api.knowledgeVersions(id), api.knowledgeSyncJobs(id),
const [nextDetail, nextJobs] = await Promise.all([
api.knowledgeDetail(id), api.knowledgeSyncJobs(id),
]);
detail.value = nextDetail;
versions.value = nextVersions;
jobs.value = nextJobs;
return nextDetail;
}
async function sync(item: KnowledgeItem, mode: "review" | "replace") {
async function sync(item: KnowledgeItem) {
operatingId.value = item.id;
try {
const result = await api.syncKnowledge(item.id, mode);
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);
@@ -237,7 +312,7 @@ async function sync(item: KnowledgeItem, mode: "review" | "replace") {
async function runMigration() {
if (!await confirmAction(
"将依次重新读取全部现有飞书文章并生成待审核版本。迁移审核完成前,新检索链路不会使用这些知识库。",
"将依次重新读取全部飞书文章。同步成功后仅保留最新版,旧版本和旧内容会被清理。",
"开始知识迁移",
{ confirmButtonText: "开始迁移", cancelButtonText: "取消", type: "warning" },
)) return;
@@ -253,75 +328,6 @@ async function runMigration() {
}
}
async function confirmManifest() {
if (!detail.value?.pendingVersion) return;
try {
await api.confirmKnowledgeManifest(detail.value.knowledge.id, detail.value.pendingVersion.id);
ElMessage.success("知识库说明卡已确认");
await refreshDetail(detail.value.knowledge.id);
} catch (error) {
ElMessage.error(errorMessage(error, "说明卡确认失败"));
}
}
async function review(action: "approve" | "reject") {
if (!detail.value?.pendingVersion) return;
let reason = "";
if (action === "reject") {
try {
const result = await ElMessageBox.prompt(
"请记录拒绝原因。知识内容不能在后台修改,请前往飞书源文章修改后重新同步。",
"拒绝版本",
{ confirmButtonText: "确认拒绝", cancelButtonText: "取消", inputValidator: (value) => Boolean(value?.trim()) || "请填写拒绝原因" },
);
reason = result.value;
} catch {
return;
}
}
try {
await api.reviewKnowledgeVersion(detail.value.knowledge.id, detail.value.pendingVersion.id, action, reason);
ElMessage.success(action === "approve" ? "版本已审核通过" : "版本已拒绝");
await refreshDetail(detail.value.knowledge.id);
} catch (error) {
ElMessage.error(errorMessage(error, "版本审核失败"));
}
}
async function publish(keepOpen: boolean) {
if (!detail.value?.pendingVersion) return;
if (!await confirmAction(
`将发布 V${detail.value.pendingVersion.versionNo},从用户下一条消息开始生效;发布后知识库将${keepOpen ? "保持开放" : "处于关闭状态"}`,
"发布知识版本",
{ confirmButtonText: "确认发布", cancelButtonText: "取消", type: "warning" },
)) return;
try {
await api.publishKnowledgeVersion(detail.value.knowledge.id, detail.value.pendingVersion.id, keepOpen);
ElMessage.success("正式版本已发布");
await load();
await refreshDetail(detail.value.knowledge.id);
} catch (error) {
ElMessage.error(errorMessage(error, "版本发布失败"));
}
}
async function rollback(version: KnowledgeVersion) {
if (!detail.value) return;
if (!await confirmAction(
`将从当前版本回滚到 V${version.versionNo},从用户下一条消息开始生效。`,
"回滚知识版本",
{ confirmButtonText: "确认回滚", cancelButtonText: "取消", type: "warning" },
)) return;
try {
await api.rollbackKnowledgeVersion(detail.value.knowledge.id, version.id, detail.value.knowledge.status === 1);
ElMessage.success("知识版本已回滚");
await load();
await refreshDetail(detail.value.knowledge.id);
} catch (error) {
ElMessage.error(errorMessage(error, "版本回滚失败"));
}
}
async function toggleOpen(item: KnowledgeItem): Promise<boolean> {
const next = item.status !== 1;
if (!await confirmAction(
@@ -344,7 +350,7 @@ async function toggleOpen(item: KnowledgeItem): Promise<boolean> {
}
async function archive(item: KnowledgeItem) {
if (!await confirmAction("归档后知识库会立即关闭,但历史正式版本和检索记录保留。", "归档知识库", {
if (!await confirmAction("归档后知识库会立即关闭,当前最新版和检索记录仍会保留。", "归档知识库", {
confirmButtonText: "确认归档", cancelButtonText: "取消", type: "warning",
})) return;
await api.setKnowledgeLifecycle(item.id, "archived");
@@ -354,20 +360,15 @@ async function archive(item: KnowledgeItem) {
async function remove(item: KnowledgeItem) {
if (!await confirmAction(
`物理删除“${item.name}及关联数据。该操作不可恢复,并可能影响历史检索记录定位`,
"物理删除知识库",
{ confirmButtonText: "确认删除", cancelButtonText: "取消", type: "error" },
`永久删除“${item.name}的知识正文、版本、章节、切片和同步记录,并立即停止 Agent 使用。审计日志仍会保留,该操作不可恢复`,
"永久删除知识库",
{ confirmButtonText: "永久删除", cancelButtonText: "取消", type: "error" },
)) return;
await api.deleteKnowledge(item.id);
ElMessage.success("知识库已删除");
await load();
}
async function showVersion(version: KnowledgeVersion) {
previewVersionId.value = version.id;
activeTab.value = "content";
}
function statusType(status: string) {
if (["published", "passed", "normal", "completed"].includes(status)) return "success";
if (["failed", "blocked", "error", "rejected"].includes(status)) return "danger";
@@ -376,7 +377,50 @@ function statusType(status: string) {
}
function typeLabel(value: string) {
return { course: "课程知识库", qa: "答疑知识库", general: "通用知识库" }[value] || value;
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) {
@@ -399,14 +443,13 @@ async function confirmAction(
<template>
<div class="page-head inline">
<div><h2>知识库管理</h2><p>一篇飞书文章对应一个知识库同步审核和发布相互独立</p></div>
<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.pending_review ?? 0 }}</strong></div>
<div><span>迁移完成</span><strong>{{ migration?.counts.completed ?? 0 }}</strong></div>
<div><span>失败</span><strong>{{ migration?.counts.failed ?? 0 }}</strong></div>
</section>
@@ -423,61 +466,113 @@ async function confirmAction(
<el-input :model-value="sourceTitle" placeholder="飞书原标题" disabled />
<el-input v-model="form.name" placeholder="后台显示名称" />
<el-select v-model="form.knowledgeType">
<el-option label="课程知识库" value="course" /><el-option label="答疑知识库" value="qa" /><el-option label="通用知识库" value="general" />
</el-select>
<el-select v-model="form.reviewMode">
<el-option label="必须人工审核" value="manual" /><el-option label="允许自动发布" value="automatic" />
<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>
<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 label="课程" value="course" /><el-option label="答疑" value="qa" /><el-option label="通用" value="general" /></el-select>
<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>
<el-select v-model="filter.pending" placeholder="待发布版本" clearable @change="load(1)"><el-option label="有待发布版本" value="yes" /><el-option label="无待发布版本" value="no" /></el-select>
</div>
<div class="knowledge-batch-bar">
<span>已选择 {{ selectedRows.length }} 个知识库</span>
<el-button :disabled="selectedRows.length === 0" @click="openBatchEdit">批量修改类型/审核方式</el-button>
<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="150"><template #default="{ row }">{{ typeLabel(row.knowledgeType) }}<small class="table-subline">{{ row.reviewMode === 'manual' ? '人工审核' : '允许自动发布' }}</small></template></el-table-column>
<el-table-column label="版本" width="130"><template #default="{ row }"><span>正式 {{ row.currentVersionId ? `#${row.currentVersionId}` : '' }}</span><small class="table-subline">待发布 {{ row.pendingVersionId ? `#${row.pendingVersionId}` : '无' }}</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 prop="lastSyncedAt" label="最近同步" width="180" />
<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, 'review')">同步</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 command="versions">查看版本</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" :disabled="row.lifecycleStatus !== 'archived'" divided>删除</el-dropdown-item></el-dropdown-menu></template></el-dropdown></TableRowActions></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) }} · {{ detail.knowledge.reviewMode === 'manual' ? '必须人工审核' : '允许自动发布' }}</p></div>
<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>{{ detail.knowledge.reviewMode === 'manual' ? '必须人工审核发布' : '允许自动处理后发布' }}</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><dt>待发布版本</dt><dd>{{ detail.pendingVersion ? `V${detail.pendingVersion.versionNo}` : '暂无' }}</dd></dl>
<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><el-button v-if="!detail.manifest.confirmed && detail.pendingVersion" type="primary" @click="confirmManifest">确认说明卡</el-button></section>
<div v-if="detail.pendingVersion?.status === 'pending_review'" class="version-actions"><el-button type="success" @click="review('approve')">审核通过</el-button><el-button type="danger" @click="review('reject')">审核拒绝</el-button></div>
<div v-else-if="detail.pendingVersion?.status === 'approved'" class="version-actions"><el-button type="primary" @click="publish(true)">发布并保持开放</el-button><el-button @click="publish(false)">发布后关闭</el-button></div>
<el-alert v-else-if="detail.pendingVersion?.status === 'rejected'" title="该版本已被拒绝,请在飞书修改源文章后重新同步。" type="warning" :closable="false" />
<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 v-model:version-id="previewVersionId" :versions="versions" />
<KnowledgeContentPreview
:version-id="previewVersionId"
:target-section-id="previewTargetSectionId"
:highlight-keyword="previewHighlightKeyword"
/>
</el-tab-pane>
<el-tab-pane label="版本记录" name="versions"><el-table :data="versions"><el-table-column label="版本" width="90"><template #default="{ row }">V{{ row.versionNo }}</template></el-table-column><el-table-column prop="status" label="状态" width="130" /><el-table-column prop="qualityStatus" label="质检" width="120" /><el-table-column prop="createdAt" label="生成时间" /><el-table-column prop="publishedAt" label="发布时间" /><el-table-column label="操作" width="180"><template #default="{ row }"><el-button link type="primary" @click="showVersion(row)">查看内容</el-button><el-button v-if="['published','superseded'].includes(row.status) && row.id !== detail?.knowledge.currentVersionId" link @click="rollback(row)">回滚</el-button></template></el-table-column></el-table></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="mode" label="模式" width="110" /><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-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>
@@ -485,8 +580,7 @@ async function confirmAction(
<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 label="课程知识库" value="course" /><el-option label="答疑知识库" value="qa" /><el-option label="通用知识库" value="general" /></el-select></el-form-item>
<el-form-item label="审核方式" required><el-select v-model="editForm.reviewMode"><el-option label="必须人工审核发布" value="manual" /><el-option label="允许自动处理后发布" value="automatic" /></el-select></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>
@@ -495,11 +589,32 @@ async function confirmAction(
<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 label="课程知识库" value="course" /><el-option label="答疑知识库" value="qa" /><el-option label="通用知识库" value="general" /></el-select></el-form-item>
<el-form-item label="审核方式"><el-select v-model="batchForm.reviewMode" clearable placeholder="不修改"><el-option label="必须人工审核发布" value="manual" /><el-option label="允许自动处理后发布" value="automatic" /></el-select></el-form-item>
<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>
@@ -517,6 +632,12 @@ async function confirmAction(
color: #66756f;
}
.knowledge-batch-actions {
display: flex;
align-items: center;
gap: 8px;
}
.knowledge-section-head h4 {
margin: 0;
}
@@ -536,4 +657,138 @@ async function confirmAction(
.batch-edit-form {
margin-top: 20px;
}
.batch-sync-result-table {
margin-top: 16px;
}
.knowledge-content-search-panel {
margin: 18px 0;
padding: 18px;
border: 1px solid #dfe8e5;
border-radius: 10px;
background: #fbfdfc;
}
.content-search-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 20px;
margin-bottom: 14px;
}
.content-search-head h3 {
margin: 0;
color: #203b32;
}
.content-search-head p {
margin: 6px 0 0;
color: #71817b;
font-size: 13px;
}
.content-search-row {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 12px;
}
.content-search-results {
display: grid;
gap: 10px;
margin-top: 14px;
}
.content-search-result-summary {
display: flex;
justify-content: space-between;
gap: 12px;
color: #53645e;
font-size: 13px;
}
.content-search-result-summary span {
color: #81918b;
}
.content-search-hit {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
align-items: center;
gap: 16px;
padding: 14px;
border: 1px solid #e1ebe7;
border-radius: 8px;
background: #ffffff;
}
.content-search-hit-main {
min-width: 0;
}
.content-search-hit-title {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
}
.content-search-hit-title strong {
overflow: hidden;
color: #203b32;
font-size: 14px;
text-overflow: ellipsis;
white-space: nowrap;
}
.content-search-hit-meta {
margin: 6px 0 0;
color: #708179;
font-size: 12px;
}
.content-search-hit-snippet {
margin: 8px 0 0;
color: #354840;
font-size: 13px;
line-height: 1.7;
}
.content-search-hit-snippet mark {
padding: 0 2px;
border-radius: 3px;
background: #fff1a8;
color: inherit;
}
.content-search-diagnostics {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-top: 8px;
}
.content-search-diagnostics span {
padding: 3px 7px;
border-radius: 999px;
background: #eef5f2;
color: #536b61;
font-size: 11px;
}
@media (max-width: 760px) {
.content-search-head,
.content-search-result-summary,
.content-search-hit {
align-items: stretch;
grid-template-columns: 1fr;
flex-direction: column;
}
.content-search-row {
grid-template-columns: 1fr;
}
}
</style>

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because one or more lines are too long

View File

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

View File

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

View File

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

View File

@@ -1,5 +1,10 @@
export type SystemSettingValue = string | number | boolean;
type SystemSettingType = "number" | "switch" | "text" | "password";
type SystemSettingType = "number" | "switch" | "text" | "password" | "select";
interface SystemSettingOption {
label: string;
value: string;
}
export interface SystemSettingDefinition {
key: string;
@@ -9,7 +14,9 @@ export interface SystemSettingDefinition {
min?: number;
max?: number;
placeholder?: string;
options?: SystemSettingOption[];
description: string;
valueHint?: (value: SystemSettingValue) => string;
}
export interface SystemSettingSection {
@@ -152,6 +159,15 @@ export const systemSettingSections: SystemSettingSection[] = [
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: "用户端展示引用来源",
@@ -159,32 +175,45 @@ export const systemSettingSections: SystemSettingSection[] = [
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: 2,
defaultValue: 30,
min: 1,
max: 1000,
description: "同一后端进程内同时进入飞书和模型生成链路的最大请求数。",
description: "用户正式问答的全局执行上限Redis 可用时由所有后端实例共享,超过后进入排队。",
valueHint: concurrentCapacityHint,
},
{
key: "chat_max_queue_size",
label: "问答最大排队数",
type: "number",
defaultValue: 20,
defaultValue: 90,
min: 0,
max: 10000,
description: "超过最大并发后允许等待的请求数量,超过后直接提示稍后再试。",
description: "超过最大并发后允许等待的请求数量。默认按并发数的 3 倍预留,避免课程结束后的集中提问被直接拒绝。",
},
{
key: "chat_queue_timeout_seconds",
label: "问答排队超时(秒)",
type: "number",
defaultValue: 60,
defaultValue: 90,
min: 1,
max: 3600,
description: "请求在排队中超过该时间后自动结束并提示用户稍后再试。",
description: "请求在排队中超过该时间后自动结束。默认 90 秒可覆盖 30 并发下约三轮请求释放。",
},
{
key: "chat_active_lease_seconds",
@@ -195,6 +224,54 @@ export const systemSettingSections: SystemSettingSection[] = [
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: "一句话识别服务地址,一般保持默认值即可。",
},
],
},
{
@@ -247,8 +324,24 @@ export const systemSettingSections: SystemSettingSection[] = [
},
{
title: "系统开关",
description: "控制系统级展示和运行策略,不包含密钥类配置。",
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: "展示操作日志详情",
@@ -274,4 +367,16 @@ export const systemSettingSections: SystemSettingSection[] = [
},
];
function concurrentCapacityHint(value: SystemSettingValue) {
const concurrency = Math.max(1, Number(value) || 1);
const lower = roundDownToTen((concurrency * 120) / 25 / 1.4);
const upper = roundDownToTen((concurrency * 120) / 25 / 1.2);
const capacity = lower === upper ? `${lower}` : `${lower}${upper}`;
return `容量估算:按单次问答约 25 秒并预留 20%40% 波动,当前 ${concurrency} 并发可承接 2 分钟内${capacity}集中提问。实际能力还受模型服务商额度和回答时长影响。`;
}
function roundDownToTen(value: number) {
return Math.max(1, Math.floor(value / 10) * 10);
}
export const systemSettingDefinitions = systemSettingSections.flatMap((section) => section.settings);

View File

@@ -1,6 +1,8 @@
import {
ElAlert,
ElButton,
ElCard,
ElCheckbox,
ElCollapse,
ElCollapseItem,
ElDatePicker,
@@ -18,7 +20,10 @@ import {
ElOption,
ElPagination,
ElProgress,
ElRadioButton,
ElRadioGroup,
ElSelect,
ElSlider,
ElSwitch,
ElTabPane,
ElTable,
@@ -26,11 +31,14 @@ import {
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";
@@ -48,14 +56,18 @@ 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";
@@ -68,6 +80,8 @@ app.config.globalProperties.$ELEMENT = { locale: zhCn };
[
ElAlert,
ElButton,
ElCard,
ElCheckbox,
ElCollapse,
ElCollapseItem,
ElDatePicker,
@@ -85,7 +99,10 @@ app.config.globalProperties.$ELEMENT = { locale: zhCn };
ElOption,
ElPagination,
ElProgress,
ElRadioButton,
ElRadioGroup,
ElSelect,
ElSlider,
ElSwitch,
ElTabPane,
ElTable,
@@ -93,9 +110,13 @@ app.config.globalProperties.$ELEMENT = { locale: zhCn };
ElTabs,
ElTag,
ElTooltip,
ElTree,
].forEach((component) => {
app.use(component);
});
// Element Plus 2.11 exposes radio group/button without an installer; register them explicitly.
app.component("ElRadioButton", ElRadioButton);
app.component("ElRadioGroup", ElRadioGroup);
app.directive("loading", vLoading);
app.mount("#app");

View File

@@ -1,28 +1,61 @@
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,
KnowledgeVersion,
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";
@@ -109,6 +142,12 @@ export const api = {
}),
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);
@@ -116,7 +155,28 @@ export const api = {
const qs = params.toString();
return request<DashboardStats>(`/admin/dashboard${qs ? "?" + qs : ""}`);
},
users: (query: { keyword?: string; page?: number; pageSize?: number } = {}) => request<PageResult<AdminUser>>(`/admin/user/list${queryString(query)}`),
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>[]) =>
@@ -130,6 +190,18 @@ export const api = {
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 }) }),
@@ -137,10 +209,15 @@ export const api = {
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; reviewMode: string }) =>
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; reviewMode?: string }) =>
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 }>(
@@ -148,19 +225,12 @@ export const api = {
{ method: "POST", body: JSON.stringify(body) },
),
knowledgeDetail: (id: number) => request<KnowledgeDetail>(`/admin/knowledge/${id}/detail`),
knowledgeVersions: (id: number) => request<KnowledgeVersion[]>(`/admin/knowledge/${id}/versions`),
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, mode: "review" | "replace") =>
request<KnowledgeSyncJob>(`/admin/knowledge/${id}/sync`, { method: "POST", body: JSON.stringify({ mode }) }),
confirmKnowledgeManifest: (id: number, versionId: number) =>
request<null>(`/admin/knowledge/${id}/manifest/${versionId}/confirm`, { method: "POST", body: "{}" }),
reviewKnowledgeVersion: (id: number, versionId: number, action: "approve" | "reject", reason?: string) =>
request<null>(`/admin/knowledge/${id}/version/${versionId}/review`, { method: "POST", body: JSON.stringify({ action, reason }) }),
publishKnowledgeVersion: (id: number, versionId: number, keepOpen: boolean) =>
request<KnowledgeItem>(`/admin/knowledge/${id}/version/${versionId}/publish`, { method: "POST", body: JSON.stringify({ keepOpen }) }),
rollbackKnowledgeVersion: (id: number, versionId: number, keepOpen: boolean) =>
request<KnowledgeItem>(`/admin/knowledge/${id}/version/${versionId}/rollback`, { method: "POST", body: JSON.stringify({ keepOpen }) }),
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") =>
@@ -176,15 +246,64 @@ export const api = {
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) }),
enableModel: (modelId: number) =>
request<null>("/admin/model/enable", { method: "POST", body: JSON.stringify({ modelId }) }),
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) =>
@@ -193,15 +312,53 @@ export const api = {
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) => request<ChatDetail>(`/admin/chat/${sessionId}`),
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 }) }),
@@ -209,10 +366,73 @@ export const api = {
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: { page?: number; pageSize?: number } = {}) => request<PageResult<AttentionItem>>(`/admin/attention/list${queryString(query)}`),
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);
}
}
}

View File

@@ -525,12 +525,16 @@ textarea {
gap: 14px;
}
.storage-panel { margin-top: 18px; padding: 18px; border: 1px solid #dfe8e5; border-radius: 8px; background: #fff; }
.cost-breakdown-panel { margin-top: 18px; padding: 18px; border: 1px solid #dfe8e5; border-radius: 8px; background: #fff; }
.storage-panel-head { display: flex; justify-content: space-between; align-items: start; gap: 16px; }
.storage-panel-head h3, .storage-panel-head p { margin: 0 0 6px; }
.storage-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 12px; margin: 14px 0; }
.storage-grid > div { padding: 14px; background: #f6f8f7; border-radius: 8px; }
.storage-grid span, .storage-grid strong { display: block; }
.storage-grid strong { margin-top: 8px; font-size: 20px; }
.model-capability-tags { display: flex; flex-wrap: wrap; gap: 6px; }
.model-default-tag { margin-left: 6px; }
.model-routing-help { display: block; margin-top: 7px; color: #71827d; line-height: 1.55; }
.cleanup-controls { display: flex; align-items: center; gap: 8px; white-space: nowrap; }
.retention-panel { display: grid; grid-template-columns: minmax(260px, 1fr) 180px auto auto; align-items: center; gap: 12px; padding: 14px 16px; margin-bottom: 14px; border: 1px solid #dfe8e5; border-radius: 8px; background: #fff; }
.retention-panel div span { display: block; margin-top: 4px; color: #667a73; font-size: 12px; }
@@ -635,6 +639,61 @@ textarea {
flex: 0 0 auto;
}
.user-entitlement-cell {
display: grid;
gap: 6px;
min-width: 0;
}
.user-entitlement-cell .el-select {
width: 100%;
}
.user-entitlement-cell small {
overflow: hidden;
color: #71817b;
font-size: 12px;
line-height: 1.4;
text-overflow: ellipsis;
white-space: nowrap;
}
.user-list-toolbar {
display: grid;
grid-template-columns: minmax(220px, 1fr) 220px 190px auto auto auto;
gap: 10px;
align-items: center;
margin-bottom: 14px;
padding: 14px;
border: 1px solid #dfe8e5;
border-radius: 8px;
background: #ffffff;
}
.user-list-toolbar .el-input,
.user-list-toolbar .el-select {
width: 100%;
}
.batch-renew-button {
margin-left: auto;
}
.entitlement-meta-row {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
}
.entitlement-meta-row span {
overflow: hidden;
color: #71817b;
font-size: 12px;
text-overflow: ellipsis;
white-space: nowrap;
}
.migration-overview {
display: grid;
grid-template-columns: repeat(5, minmax(0, 1fr));
@@ -916,6 +975,10 @@ textarea {
align-items: start;
}
.agent-workbench.agent-batch-mode { grid-template-columns: minmax(0, 1fr); }
.agent-workbench.agent-batch-mode > .agent-preview-panel,
.agent-workbench.agent-batch-mode > .agent-trace-panel { display: none; }
.agent-page-head {
display: flex;
align-items: flex-start;
@@ -990,6 +1053,41 @@ textarea {
margin-top: 16px;
}
.agent-batch-panel { display: grid; gap: 16px; }
.agent-batch-create-card { display: grid; gap: 16px; padding: 16px; border: 1px solid #dfe8e5; border-radius: 10px; background: #f8fbfa; }
.agent-batch-config-summary { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 10px; }
.agent-batch-config-summary span { min-width: 0; padding: 11px 12px; border: 1px solid #e1e9e6; border-radius: 8px; background: #fff; }
.agent-batch-config-summary small, .agent-batch-config-summary strong { display: block; }
.agent-batch-config-summary small { margin-bottom: 4px; color: #7a8c85; font-size: 11px; }
.agent-batch-config-summary strong { overflow: hidden; color: #2b4038; font-size: 13px; text-overflow: ellipsis; white-space: nowrap; }
.agent-batch-upload-row { display: flex; align-items: flex-end; gap: 10px; flex-wrap: wrap; }
.agent-batch-mode-setting { display: flex; align-items: center; gap: 14px; padding: 12px; border: 1px solid #dfe8e5; border-radius: 9px; background: #fff; }
.agent-batch-mode-copy { display: grid; min-width: 240px; flex: 1; gap: 3px; }
.agent-batch-mode-copy strong { color: #2b4038; font-size: 13px; }
.agent-batch-mode-copy span { color: #71827c; font-size: 11px; line-height: 1.5; }
.agent-batch-mode-options { flex: none; }
.agent-batch-concurrency { display: flex; align-items: center; gap: 8px; color: #536860; font-size: 12px; }
.agent-batch-concurrency > span { white-space: nowrap; }
.agent-batch-concurrency .el-input-number { width: 112px; }
.agent-batch-name { display: grid; gap: 6px; width: 220px; color: #536860; font-size: 12px; }
.agent-batch-name input { height: 40px; padding: 0 12px; border: 1px solid #d8e2df; border-radius: 6px; outline: none; background: #fff; color: #263832; }
.agent-batch-name input:focus { border-color: #2f9479; box-shadow: 0 0 0 2px rgba(47, 148, 121, .12); }
.agent-batch-file { position: relative; display: grid; min-width: 260px; flex: 1; gap: 3px; min-height: 40px; padding: 7px 12px; border: 1px dashed #b9cac4; border-radius: 7px; background: #fff; cursor: pointer; }
.agent-batch-file.selected { border-style: solid; border-color: #62aa95; background: #f3faf7; }
.agent-batch-file input { position: absolute; inset: 0; width: 100%; height: 100%; opacity: 0; cursor: pointer; }
.agent-batch-file span { overflow: hidden; color: #315148; font-size: 13px; font-weight: 600; text-overflow: ellipsis; white-space: nowrap; }
.agent-batch-file small { color: #82918c; font-size: 11px; }
.agent-batch-list-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
.agent-batch-list-head h4, .agent-batch-list-head p { margin: 0; }
.agent-batch-list-head h4 { margin-bottom: 4px; color: #263832; font-size: 15px; }
.agent-batch-list-head p { color: #71827c; font-size: 12px; }
.agent-batch-cell-note { display: block; margin-top: 4px; overflow: hidden; color: #7b8c86; font-size: 11px; text-overflow: ellipsis; white-space: nowrap; }
.agent-batch-detail { min-height: 260px; }
.agent-batch-detail-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 14px; margin-bottom: 14px; padding: 13px 14px; border-radius: 8px; background: #f5f8f7; }
.agent-batch-detail-head div { display: grid; gap: 4px; min-width: 0; }
.agent-batch-detail-head span { overflow: hidden; color: #71827c; font-size: 12px; text-overflow: ellipsis; white-space: nowrap; }
.agent-batch-answer { max-height: 104px; overflow: auto; white-space: pre-wrap; word-break: break-word; line-height: 1.6; }
.agent-advanced-settings {
margin-top: 16px;
border: 1px solid #e2eae7;
@@ -1017,6 +1115,80 @@ textarea {
font-size: 12px;
}
.agent-stream-setting {
margin: 16px 0 0;
padding: 14px 16px;
border: 1px solid #dce7e3;
border-radius: 8px;
background: #ffffff;
}
.agent-stream-setting > .el-form-item__content {
display: flex;
align-items: center;
justify-content: space-between;
gap: 20px;
}
.agent-stream-setting-copy {
display: grid;
gap: 4px;
min-width: 0;
}
.agent-stream-setting-copy strong {
color: #263832;
font-size: 14px;
}
.agent-stream-setting-copy span {
color: #667a73;
font-size: 12px;
line-height: 1.6;
}
.agent-response-depth {
margin: 16px 0 0;
padding: 14px 16px 18px;
border: 1px solid #dce7e3;
border-radius: 8px;
background: #ffffff;
}
.agent-response-depth > .el-form-item__content {
display: grid;
gap: 14px;
}
.agent-response-depth-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
width: 100%;
}
.agent-response-depth .el-slider {
--el-slider-main-bg-color: #0f735d;
--el-slider-runway-bg-color: #dce7e3;
width: calc(100% - 12px);
margin: 2px 6px 0;
}
@media (max-width: 680px) {
.agent-stream-setting > .el-form-item__content {
align-items: flex-start;
flex-direction: column;
gap: 12px;
}
.agent-response-depth-head {
align-items: flex-start;
flex-direction: column;
gap: 10px;
}
}
.agent-parameter-label {
display: inline-flex;
align-items: center;
@@ -1203,6 +1375,23 @@ textarea {
width: 100%;
}
.agent-form-help {
margin-top: 6px;
color: #7b8d86;
font-size: 12px;
line-height: 1.6;
}
.agent-debug-context-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 14px;
}
.agent-debug-context-grid .el-form-item {
min-width: 0;
}
.debug-knowledge-select { width: 100%; min-width: 0; }
.debug-knowledge-select .el-select__wrapper { height: 40px; min-height: 40px; overflow: hidden; }
.debug-knowledge-select .el-select__selection { flex-wrap: nowrap; overflow: hidden; }
@@ -1340,6 +1529,157 @@ textarea {
line-height: 1.75;
}
.agent-preview-markdown {
overflow-wrap: anywhere;
color: #263832;
font-size: 14px;
line-height: 1.68;
}
.agent-preview-markdown > :first-child { margin-top: 0; }
.agent-preview-markdown > :last-child { margin-bottom: 0; }
.agent-preview-markdown p { margin: 0 0 8px; }
.agent-preview-markdown h1,
.agent-preview-markdown h2,
.agent-preview-markdown h3,
.agent-preview-markdown h4,
.agent-preview-markdown h5,
.agent-preview-markdown h6 {
margin: 13px 0 5px;
color: #1f352e;
line-height: 1.42;
font-weight: 700;
}
.agent-preview-markdown h1 { font-size: 18px; }
.agent-preview-markdown h2 { font-size: 17px; }
.agent-preview-markdown h3 { font-size: 16px; }
.agent-preview-markdown h4 { font-size: 15px; }
.agent-preview-markdown h5,
.agent-preview-markdown h6 { font-size: 14px; }
.agent-preview-markdown ul,
.agent-preview-markdown ol {
margin: 6px 0 8px;
padding-left: 21px;
}
.agent-preview-markdown li + li { margin-top: 3px; }
.agent-preview-markdown strong { color: #1f352e; font-weight: 700; }
.agent-preview-markdown blockquote {
margin: 8px 0;
padding: 7px 10px;
border-left: 3px solid #8cc7b6;
background: #eef7f4;
color: #526b63;
}
.agent-preview-markdown code {
padding: 2px 5px;
border-radius: 5px;
background: #edf5f2;
color: #0f735d;
font-family: "SFMono-Regular", Consolas, monospace;
font-size: 0.92em;
}
.agent-preview-markdown pre {
max-width: 100%;
overflow-x: auto;
margin: 10px 0;
padding: 10px;
border-radius: 8px;
background: #13231e;
color: #f4fffb;
}
.agent-preview-markdown pre code {
padding: 0;
background: transparent;
color: inherit;
}
.agent-preview-markdown table {
display: block;
max-width: 100%;
overflow-x: auto;
border-collapse: collapse;
margin: 10px 0;
}
.agent-preview-markdown th,
.agent-preview-markdown td {
padding: 6px 8px;
border: 1px solid #dbe7e3;
white-space: nowrap;
}
.agent-preview-markdown a { color: #0f735d; font-weight: 700; }
.agent-preview-markdown hr {
margin: 12px 0;
border: 0;
border-top: 1px solid #dbe7e3;
}
.agent-preview-generation-state {
display: flex;
align-items: center;
gap: 4px;
min-height: 28px;
color: #667a73;
font-size: 13px;
}
.agent-preview-generation-state span {
width: 5px;
height: 5px;
border-radius: 50%;
background: #0f735d;
animation: agent-generation-pulse 1.2s ease-in-out infinite;
}
.agent-preview-generation-state span:nth-child(2) { animation-delay: 0.14s; }
.agent-preview-generation-state span:nth-child(3) { margin-right: 4px; animation-delay: 0.28s; }
.agent-reasoning-panel {
margin: 6px 0 10px;
overflow: hidden;
border: 1px solid #dbe7e3;
border-radius: 10px;
background: #f2f7f5;
color: #496259;
}
.agent-reasoning-panel summary {
min-height: 34px;
display: flex;
align-items: center;
padding: 0 10px;
color: #315d52;
font-size: 12px;
font-weight: 700;
cursor: pointer;
}
.agent-reasoning-content {
max-height: 220px;
overflow-y: auto;
padding: 0 10px 10px;
color: #5d7169;
font-size: 12px;
line-height: 1.65;
white-space: pre-wrap;
word-break: break-word;
}
@keyframes agent-generation-pulse {
0%, 70%, 100% { opacity: 0.25; transform: translateY(0); }
35% { opacity: 1; transform: translateY(-2px); }
}
.agent-preview-thinking {
margin-bottom: 10px;
padding: 9px 10px;
@@ -1373,60 +1713,24 @@ textarea {
height: 54px;
}
.actions {
margin-top: 12px;
}
.settings-grid {
.agent-preview-input {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 14px;
margin-bottom: 16px;
min-width: 0;
gap: 6px;
}
.settings-section {
padding: 18px;
border: 1px solid #dfe8e5;
border-radius: 8px;
background: #ffffff;
}
.settings-section-head h3 {
margin: 0;
font-size: 17px;
}
.settings-section-head p {
margin: 6px 0 0;
color: #667a73;
font-size: 13px;
line-height: 1.6;
}
.settings-fields {
display: grid;
gap: 14px;
margin-top: 16px;
}
.setting-field {
display: grid;
gap: 7px;
}
.setting-field span {
color: #223631;
font-weight: 600;
}
.setting-field small {
color: #667a73;
.agent-preview-input small {
color: #7d8d87;
font-size: 11px;
line-height: 1.5;
}
.setting-field .el-input-number,
.setting-field .el-input {
width: 100%;
.agent-debug-randomness-alert {
margin-bottom: 14px;
}
.actions {
margin-top: 12px;
}
.el-drawer .el-date-editor.el-input {
@@ -1513,6 +1817,281 @@ textarea {
align-items: center;
}
.question-insight-filter {
align-items: center;
}
.insight-number-field {
height: 40px;
display: flex;
align-items: center;
gap: 8px;
padding-left: 11px;
border: 1px solid #dcdfe6;
border-radius: 4px;
background: #ffffff;
}
.insight-number-field > span {
color: #909399;
font-size: 13px;
white-space: nowrap;
}
.insight-number-field .el-input-number {
width: 132px;
}
.insight-number-field .el-input__wrapper {
border-radius: 0 4px 4px 0;
box-shadow: none;
}
.question-insight-help {
margin: -2px 0 14px;
color: #70837c;
font-size: 13px;
}
.question-insight-summary {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 12px;
margin-bottom: 14px;
}
.question-insight-summary div {
padding: 14px 16px;
border: 1px solid #dfe8e5;
border-radius: 12px;
background: #f8fbfa;
}
.question-insight-summary span {
display: block;
color: #70837c;
font-size: 13px;
}
.question-insight-summary strong {
display: block;
margin-top: 4px;
color: #0f735d;
font-size: 24px;
}
.question-insight-list {
display: grid;
gap: 12px;
min-height: 160px;
}
.question-insight-card {
padding: 16px 18px;
border: 1px solid #dfe8e5;
border-radius: 14px;
background: #ffffff;
}
.question-insight-card header {
display: flex;
justify-content: space-between;
gap: 16px;
align-items: flex-start;
}
.question-insight-card small {
color: #0f735d;
font-weight: 700;
}
.question-insight-card h3 {
margin: 4px 0 0;
color: #183b33;
font-size: 18px;
line-height: 1.45;
}
.question-insight-metrics {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
gap: 8px;
min-width: 220px;
}
.question-insight-metrics span,
.question-variants span {
padding: 4px 9px;
border-radius: 999px;
background: #eef6f3;
color: #49675f;
font-size: 12px;
white-space: nowrap;
}
.question-insight-tags {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin: 12px 0;
}
.question-insight-action {
margin: 0 0 12px;
padding: 10px 12px;
border-radius: 10px;
background: #f6faf8;
color: #50655e;
font-size: 13px;
line-height: 1.6;
}
.question-variants {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 12px;
}
.question-samples {
display: grid;
gap: 10px;
}
.question-samples article {
padding: 12px;
border: 1px solid #e5ece9;
border-radius: 10px;
background: #f8fbfa;
}
.question-samples article > div {
display: flex;
flex-wrap: wrap;
gap: 10px;
align-items: center;
color: #70837c;
font-size: 12px;
}
.question-samples strong {
color: #183b33;
}
.question-samples p {
margin: 8px 0;
color: #1f2d2a;
}
.question-samples pre {
max-height: 150px;
margin: 0;
padding: 10px;
overflow: auto;
border-radius: 8px;
background: #ffffff;
color: #52665f;
white-space: pre-wrap;
word-break: break-word;
line-height: 1.6;
}
.chat-detail-body {
min-height: 320px;
max-height: calc(100vh - 96px);
overflow-y: auto;
padding-right: 4px;
}
.user-detail-body {
min-height: 320px;
max-height: calc(100vh - 96px);
overflow-y: auto;
padding-right: 4px;
}
.user-detail-head {
display: flex;
justify-content: space-between;
gap: 16px;
align-items: flex-start;
margin-bottom: 16px;
padding: 16px;
border: 1px solid #dfe8e5;
border-radius: 14px;
background: linear-gradient(135deg, #f8fbfa 0%, #eef7f3 100%);
}
.user-detail-head h3 {
margin: 0 0 6px;
color: #183b33;
font-size: 20px;
}
.user-detail-head p {
margin: 0;
color: #637973;
}
.user-detail-tags {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
gap: 8px;
}
.user-metric-grid {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
.user-report-head {
display: flex;
justify-content: space-between;
gap: 16px;
align-items: flex-end;
margin-top: 18px;
}
.user-report-head .detail-title {
margin-bottom: 4px;
}
.user-report-head p {
margin: 0 0 10px;
color: #71827c;
font-size: 13px;
}
.user-report-actions {
display: flex;
flex-wrap: nowrap;
gap: 8px;
margin-bottom: 10px;
}
.user-report-actions .el-button {
margin-left: 0;
white-space: nowrap;
}
.report-error {
margin: 0;
padding: 10px 12px;
border-radius: 10px;
background: #fff2f0;
color: #b42318;
}
.report-pending {
margin: 0;
padding: 12px 14px;
border-radius: 10px;
background: #f2f8f6;
color: #557068;
line-height: 1.6;
}
.chat-summary {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
@@ -1549,11 +2128,77 @@ textarea {
font-size: 16px;
}
.topic-summary-collapse {
margin-bottom: 14px;
}
.topic-summary-card {
display: grid;
gap: 12px;
}
.topic-summary-meta {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 8px;
color: #667a73;
font-size: 12px;
}
.topic-summary-meta span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.topic-summary-card pre {
margin: 0;
padding: 12px;
border: 1px solid #dfe8e5;
border-radius: 10px;
background: #f8fbfa;
color: #34453f;
font: inherit;
font-size: 13px;
line-height: 1.75;
white-space: pre-wrap;
}
.topic-summary-fields {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
}
.topic-summary-fields p {
margin: 0;
padding: 10px 12px;
border-radius: 10px;
background: #f6f8f7;
color: #53645e;
font-size: 13px;
line-height: 1.65;
}
.topic-summary-fields strong {
display: block;
margin-bottom: 4px;
color: #203b32;
}
.conversation-list {
display: grid;
gap: 12px;
}
.chat-detail-pagination {
margin: 14px 0;
}
.chat-detail-pagination.top {
margin-top: 0;
}
.conversation-message {
padding: 12px;
border: 1px solid #dfe8e5;
@@ -1691,11 +2336,16 @@ textarea {
@media (max-width: 1120px) {
.student-tools,
.agent-workbench,
.settings-grid {
.user-list-toolbar,
.agent-workbench {
grid-template-columns: 1fr;
}
.batch-renew-button {
width: 100%;
margin-left: 0;
}
.audit-detail-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
@@ -1716,6 +2366,19 @@ textarea {
.agent-page-head {
flex-direction: column;
}
.agent-debug-context-grid {
grid-template-columns: 1fr;
}
.agent-batch-config-summary { grid-template-columns: 1fr; }
.agent-batch-name, .agent-batch-file { width: 100%; min-width: 0; }
.agent-batch-mode-setting { align-items: stretch; flex-direction: column; }
.agent-batch-mode-copy { min-width: 0; }
.agent-batch-mode-options { display: flex; }
.agent-batch-mode-options .el-radio-button { flex: 1; }
.agent-batch-mode-options .el-radio-button__inner { width: 100%; }
.agent-batch-upload-row > .el-button { flex: 1; }
}
@media (max-width: 860px) {

View File

@@ -16,6 +16,71 @@ export interface AdminProfile {
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 {
@@ -27,6 +92,81 @@ export interface DashboardStats {
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 {
@@ -50,17 +190,133 @@ export interface PromptHistoryItem {
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 {
@@ -92,14 +348,11 @@ export interface KnowledgeItem {
status: number;
remark?: string | null;
sourceTitle?: string | null;
knowledgeType: "course" | "qa" | "general";
reviewMode: "manual" | "automatic";
knowledgeType: "course" | "qa" | "general" | "fixed";
lifecycleStatus: "active" | "archived";
sourceStatus: "normal" | "error";
sourceError?: string | null;
manifestConfirmed: boolean;
currentVersionId?: number | null;
pendingVersionId?: number | null;
lastSyncedAt?: string | null;
lastPublishedAt?: string | null;
migrationStatus: string;
@@ -131,6 +384,33 @@ export interface ModelItem {
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 {
@@ -143,6 +423,59 @@ export interface AgentDebugResult {
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 {
@@ -176,7 +509,6 @@ export interface KnowledgeManifest {
export interface KnowledgeDetail {
knowledge: KnowledgeItem;
currentVersion?: KnowledgeVersion | null;
pendingVersion?: KnowledgeVersion | null;
manifest?: KnowledgeManifest | null;
}
@@ -187,6 +519,31 @@ export interface KnowledgeVersionContent {
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;
@@ -212,6 +569,20 @@ export interface KnowledgeBatchUpdateResult {
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;
@@ -237,14 +608,66 @@ export interface AttentionItem {
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;
@@ -254,6 +677,7 @@ export interface ChatRecord {
export interface ChatMessageRecord {
id: number;
sessionId: number;
topicSessionId?: number | null;
userId: number;
role: "user" | "assistant";
content: string;
@@ -278,6 +702,11 @@ export interface AiLogRecord {
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;
@@ -296,15 +725,204 @@ export interface RetrievedKnowledgeChunk {
export interface ChatDetail {
session: ChatRecord;
messages: ChatMessageRecord[];
messagesPage?: PageResult<ChatMessageRecord>;
focusedMessageId?: number | null;
aiLogs: AiLogRecord[];
topics?: TopicSessionRecord[];
helpCards?: TeacherHelpCardRecord[];
shareDrafts?: ShareDraftRecord[];
}
export interface TeacherHelpCardRecord {
id: number;
userId: number;
topicSessionId: number;
summaryId?: number | null;
content: string;
source: string;
copied: boolean;
copiedAt?: string | null;
createdAt: string;
}
export interface ShareDraftRecord {
id: number;
userId: number;
topicSessionId: number;
summaryId?: number | null;
content: string;
source: string;
copied: boolean;
copiedAt?: string | null;
createdAt: string;
}
export interface TopicSummaryRecord {
id: number;
topicSessionId: number;
userId: number;
schemaVersion: number;
summary: string;
currentFocus?: string | null;
nextObservation?: string | null;
modelName?: string | null;
status: string;
errorMessage?: string | null;
attemptCount: number;
maxAttempts: number;
nextRunAt?: string | null;
lastStartedAt?: string | null;
finishedAt?: string | null;
generatedAt: string;
}
export interface UserGrowthProfileRecord {
id: number;
userId: number;
schemaVersion: number;
reviewText: string;
currentFocus?: string | null;
sourceSummaryIds: number[];
lastTopicSummaryId?: number | null;
updatedBy: string;
createdAt: string;
updatedAt: string;
}
export interface PeriodicReportRecord {
id: number;
userId: number;
schemaVersion: number;
reportType: "weekly" | "monthly" | "stage" | string;
reportTypeLabel: string;
periodStart: string;
periodEnd: string;
title: string;
content: string;
sourceSummaryIds: number[];
sourceTopicIds: number[];
sourceMessageIds: number[];
sourceReportIds: number[];
modelName?: string | null;
status: "success" | "failed" | "empty" | string;
errorMessage?: string | null;
generatedBy: string;
attemptCount: number;
maxAttempts: number;
nextRunAt?: string | null;
lastStartedAt?: string | null;
finishedAt?: string | null;
generatedAt: string;
createdAt: string;
updatedAt: string;
}
export interface TopicSessionRecord {
id: number;
userId: number;
chatSessionId: number;
title: string;
coreQuestion: string;
status: string;
messageCount: number;
tokenInput: number;
tokenOutput: number;
startedAt: string;
endedAt?: string | null;
createdAt?: string | null;
updatedAt?: string | null;
summary?: TopicSummaryRecord | null;
summaryAvailable?: boolean;
}
export interface QuestionInsightSummary {
range: {
dateFrom?: string | null;
dateTo?: string | null;
maxMessages: number;
};
summary: {
scannedMessages: number;
cleanedQuestions: number;
filteredMessages: number;
clusterCount: number;
visibleClusterCount: number;
minCount: number;
cleanerVersion: string;
};
items: QuestionInsightCluster[];
total: number;
page: number;
pageSize: number;
}
export interface QuestionInsightRefreshResult {
processedMessages: number;
acceptedQuestions: number;
filteredMessages: number;
concurrentSkips: number;
hasMore: boolean;
cleanerVersion: string;
}
export interface QuestionInsightCluster {
rank: number;
title: string;
normalized: string;
category: string;
categoryLabel: string;
count: number;
userCount: number;
sessionCount: number;
aiRequestCount: number;
noHitCount: number;
failedCount: number;
needsKnowledgeFollowUp: boolean;
suggestedAction: string;
firstSeenAt: string;
lastSeenAt: string;
topTerms: string[];
variants: Array<{ text: string; count: number }>;
samples: QuestionInsightSample[];
}
export interface QuestionInsightSample {
messageId: number;
sessionId: number;
userId: number;
userName: string;
userPhone: string;
raw: string;
cleaned: string;
createdAt: string;
}
export interface ChatRecordQuery {
keyword?: string;
userId?: number | null;
status?: string;
sourceType?: string;
sourceClientId?: number | null;
dateFrom?: string;
dateTo?: string;
page?: number;
pageSize?: number;
}
export interface FeedbackItem {
id: number;
userId: number;
userName: string;
userPhone: string;
messageId: number;
messageContent: string;
content: string;
isRead: boolean;
createdAt: string;
}
export interface FeedbackDetail extends FeedbackItem {
sessionTitle: string;
messages: Array<{ id: number; role: "user" | "assistant"; content: string; createdAt: string; isTarget: boolean }>;
messagesPage: PageResult<{ id: number; role: "user" | "assistant"; content: string; createdAt: string; isTarget: boolean }>;
}

View File

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

View File

@@ -41,7 +41,32 @@ CHAT_MAX_QUEUE_SIZE=20
CHAT_QUEUE_TIMEOUT_SECONDS=60
CHAT_ACTIVE_LEASE_SECONDS=900
# 周报在每周一 02:00、月报在每月 1 日 03:00 生成,时间按 PERIODIC_REPORT_TIMEZONE 解释。
PERIODIC_REPORT_WORKER_ENABLED=true
PERIODIC_REPORT_POLL_SECONDS=5
PERIODIC_REPORT_STALE_MINUTES=30
PERIODIC_REPORT_MAX_ATTEMPTS=3
PERIODIC_REPORT_WEEKLY_ENABLED=true
PERIODIC_REPORT_MONTHLY_ENABLED=true
PERIODIC_REPORT_TIMEZONE=Asia/Shanghai
TOPIC_SETTLEMENT_WORKER_ENABLED=true
TOPIC_SETTLEMENT_POLL_SECONDS=2
TOPIC_SETTLEMENT_STALE_MINUTES=30
TOPIC_SETTLEMENT_MAX_ATTEMPTS=3
AGENT_BATCH_WORKER_ENABLED=true
AGENT_BATCH_POLL_SECONDS=2
AGENT_BATCH_STALE_MINUTES=30
AGENT_BATCH_WORKER_CONCURRENCY=10
HUMAN_ATTENTION_WORKER_ENABLED=true
HUMAN_ATTENTION_WORKER_POLL_SECONDS=2
HUMAN_ATTENTION_WORKER_STALE_MINUTES=30
HUMAN_ATTENTION_WORKER_MAX_ATTEMPTS=3
# 本地开发可以使用开发密码;生产环境必须改成高强度密码,且 APP_ENV=production 时不能使用 admin123456
BOOTSTRAP_ADMIN_USERNAME=admin
BOOTSTRAP_ADMIN_PASSWORD=admin123456
BOOTSTRAP_ADMIN_NAME=系统管理员
ALIYUN_NLS_APP_KEY=
ALIYUN_NLS_ACCESS_KEY_ID=
ALIYUN_NLS_ACCESS_KEY_SECRET=
ALIYUN_NLS_ENDPOINT=https://nls-gateway-cn-shanghai.aliyuncs.com/stream/v1/asr

View File

@@ -1,6 +1,6 @@
from __future__ import annotations
from alembic import op
from alembic import context, op
import sqlalchemy as sa
revision = "0008_unique_kb_node"
@@ -10,7 +10,9 @@ depends_on = None
def upgrade() -> None:
constraints = {item["name"] for item in sa.inspect(op.get_bind()).get_unique_constraints("sys_knowledge")}
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",
@@ -20,6 +22,8 @@ def upgrade() -> None:
def downgrade() -> None:
constraints = {item["name"] for item in sa.inspect(op.get_bind()).get_unique_constraints("sys_knowledge")}
constraints = {"uq_knowledge_feishu_node"} if context.is_offline_mode() else {
item["name"] for item in sa.inspect(op.get_bind()).get_unique_constraints("sys_knowledge")
}
if "uq_knowledge_feishu_node" in constraints:
op.drop_constraint("uq_knowledge_feishu_node", "sys_knowledge", type_="unique")

View File

@@ -3,7 +3,7 @@
Revision ID: 0009_logs_storage
Revises: 0008_unique_kb_node
"""
from alembic import op
from alembic import context, op
import sqlalchemy as sa
revision = "0009_logs_storage"
@@ -13,8 +13,8 @@ depends_on = None
def upgrade() -> None:
inspector = sa.inspect(op.get_bind())
tables = set(inspector.get_table_names())
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",
@@ -45,7 +45,10 @@ def upgrade() -> None:
def _create_index_if_missing(table: str, name: str, columns: list[str]) -> None:
if name not in {item["name"] for item in sa.inspect(op.get_bind()).get_indexes(table)}:
indexes = set() if context.is_offline_mode() else {
item["name"] for item in sa.inspect(op.get_bind()).get_indexes(table)
}
if name not in indexes:
op.create_index(name, table, columns)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,48 @@
"""add granular record export permission
Revision ID: 0042_record_export_permission
Revises: 0041_utc_time_contract
"""
from __future__ import annotations
import json
from alembic import op
from sqlalchemy import text
revision = "0042_record_export_permission"
down_revision = "0041_utc_time_contract"
branch_labels = None
depends_on = None
def _rewrite_permissions(*, add_export: bool) -> None:
connection = op.get_bind()
rows = connection.execute(text("SELECT id, permissions FROM sys_role")).mappings().all()
for row in rows:
try:
permissions = list(json.loads(row["permissions"] or "[]"))
except (TypeError, ValueError, json.JSONDecodeError):
continue
changed = False
if add_export and "records.view" in permissions and "records.export" not in permissions:
permissions.append("records.export")
changed = True
if not add_export and "records.export" in permissions:
permissions = [item for item in permissions if item != "records.export"]
changed = True
if changed:
connection.execute(
text("UPDATE sys_role SET permissions = :permissions WHERE id = :role_id"),
{"permissions": json.dumps(sorted(set(permissions))), "role_id": row["id"]},
)
def upgrade() -> None:
_rewrite_permissions(add_export=True)
def downgrade() -> None:
_rewrite_permissions(add_export=False)

View File

@@ -0,0 +1,25 @@
"""add chat message navigation index
Revision ID: 0043_chat_msg_nav_index
Revises: 0042_record_export_permission
"""
from alembic import op
revision = "0043_chat_msg_nav_index"
down_revision = "0042_record_export_permission"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_index(
"ix_chat_message_session_created_id",
"sys_chat_message",
["session_id", "created_at", "id"],
)
def downgrade() -> None:
op.drop_index("ix_chat_message_session_created_id", table_name="sys_chat_message")

View File

@@ -0,0 +1,217 @@
from __future__ import annotations
import json
from datetime import UTC, datetime
from typing import Annotated
from urllib.parse import quote
from fastapi import APIRouter, Depends, File, Form, HTTPException, Query, UploadFile, status
from fastapi.responses import StreamingResponse
from sqlalchemy import func, select, update
from sqlalchemy.orm import Session
from app.api.pagination import page_result
from app.core.database import get_db
from app.core.dependencies import get_current_admin
from app.core.responses import api_success
from app.models.admin import Admin
from app.models.agent_batch import AgentBatchTest, AgentBatchTestItem
from app.models.ai_config import ModelConfig
from app.models.knowledge import Knowledge
from app.services.admin_service import OperationLogService
from app.services.agent_batch_test_service import AgentBatchTestService, item_dict, job_dict
router = APIRouter()
@router.get("/agent/model-options")
def agent_model_options(
db: Session = Depends(get_db),
current_admin: Admin = Depends(get_current_admin),
) -> dict:
"""Safe Agent-only model choices without provider credentials or endpoints."""
items = list(db.scalars(select(ModelConfig).order_by(ModelConfig.is_default.desc(), ModelConfig.id.desc())))
return api_success([
{
"id": item.id,
"displayName": item.display_name,
"modelName": item.model_name,
"temperature": float(item.temperature) if item.temperature is not None else None,
"topP": float(item.top_p) if item.top_p is not None else None,
"topK": item.top_k,
"presencePenalty": float(item.presence_penalty) if item.presence_penalty is not None else None,
"frequencyPenalty": float(item.frequency_penalty) if item.frequency_penalty is not None else None,
"maxToken": item.max_token,
"streamEnabled": item.stream_enabled,
"enabled": item.enabled,
"isDefault": item.is_default,
}
for item in items
])
@router.get("/agent/knowledge-options")
def agent_knowledge_options(
db: Session = Depends(get_db),
current_admin: Admin = Depends(get_current_admin),
) -> dict:
"""Safe Agent-only knowledge choices; does not grant Knowledge Management access."""
items = list(
db.scalars(
select(Knowledge)
.where(Knowledge.lifecycle_status == "active")
.order_by(Knowledge.id.desc())
)
)
return api_success([
{
"id": item.id,
"name": item.name,
"status": item.status,
"knowledgeType": item.knowledge_type,
"lifecycleStatus": item.lifecycle_status,
"sourceStatus": item.source_status,
}
for item in items
])
@router.get("/agent/batch/template")
def download_batch_template(
current_admin: Admin = Depends(get_current_admin),
) -> StreamingResponse:
return StreamingResponse(
AgentBatchTestService.template_workbook(),
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
headers={"Content-Disposition": "attachment; filename*=UTF-8''Agent_batch_test_template.xlsx"},
)
@router.post("/agent/batch/jobs")
def create_batch_job(
file: Annotated[UploadFile, File(...)],
configJson: Annotated[str, Form(...)],
db: Session = Depends(get_db),
current_admin: Admin = Depends(get_current_admin),
) -> dict:
try:
config = json.loads(configJson)
except json.JSONDecodeError as exc:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="批量测试配置格式不正确") from exc
if not isinstance(config, dict):
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="批量测试配置格式不正确")
rows = AgentBatchTestService.parse_questions(file)
job = AgentBatchTestService.create_job(
db,
admin=current_admin,
filename=file.filename or "Agent批量测试.xlsx",
rows=rows,
config=config,
)
OperationLogService.write(db, admin_id=current_admin.id, module="agent", action="batch_create", target_id=job.id)
db.commit()
return api_success(job_dict(job))
@router.get("/agent/batch/jobs")
def list_batch_jobs(
page: int = Query(default=1, ge=1),
pageSize: int = Query(default=10, ge=10, le=100),
db: Session = Depends(get_db),
current_admin: Admin = Depends(get_current_admin),
) -> dict:
owner_filter = _owner_filter(current_admin)
total = db.scalar(select(func.count(AgentBatchTest.id)).where(*owner_filter)) or 0
rows = list(
db.scalars(
select(AgentBatchTest)
.where(*owner_filter)
.order_by(AgentBatchTest.created_at.desc(), AgentBatchTest.id.desc())
.offset((page - 1) * pageSize)
.limit(pageSize)
)
)
return api_success(page_result([job_dict(row) for row in rows], total=total, page=page, page_size=pageSize))
@router.get("/agent/batch/jobs/{job_id}")
def get_batch_job(
job_id: int,
page: int = Query(default=1, ge=1),
pageSize: int = Query(default=20, ge=10, le=100),
db: Session = Depends(get_db),
current_admin: Admin = Depends(get_current_admin),
) -> dict:
job = _job_or_404(db, job_id, current_admin)
total = db.scalar(select(func.count(AgentBatchTestItem.id)).where(AgentBatchTestItem.batch_id == job.id)) or 0
items = list(
db.scalars(
select(AgentBatchTestItem)
.where(AgentBatchTestItem.batch_id == job.id)
.order_by(AgentBatchTestItem.row_number.asc(), AgentBatchTestItem.id.asc())
.offset((page - 1) * pageSize)
.limit(pageSize)
)
)
return api_success({**job_dict(job), "items": page_result([item_dict(item) for item in items], total=total, page=page, page_size=pageSize)})
@router.get("/agent/batch/jobs/{job_id}/export")
def export_batch_job(
job_id: int,
db: Session = Depends(get_db),
current_admin: Admin = Depends(get_current_admin),
) -> StreamingResponse:
job = _job_or_404(db, job_id, current_admin)
items = list(
db.scalars(
select(AgentBatchTestItem)
.where(AgentBatchTestItem.batch_id == job.id)
.order_by(AgentBatchTestItem.row_number.asc(), AgentBatchTestItem.id.asc())
)
)
filename = quote(f"Agent批量测试结果_{job.id}.xlsx")
OperationLogService.write(db, admin_id=current_admin.id, module="agent", action="batch_export", target_id=job.id)
db.commit()
return StreamingResponse(
AgentBatchTestService.export_workbook(job, items),
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
headers={"Content-Disposition": f"attachment; filename*=UTF-8''{filename}"},
)
@router.post("/agent/batch/jobs/{job_id}/cancel")
def cancel_batch_job(
job_id: int,
db: Session = Depends(get_db),
current_admin: Admin = Depends(get_current_admin),
) -> dict:
job = _job_or_404(db, job_id, current_admin)
if job.status not in {"pending", "running"}:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="当前任务已结束,不能取消")
now = datetime.now(UTC).replace(tzinfo=None)
job.status = "cancelled"
job.cancelled_at = now
job.finished_at = now
db.add(job)
db.execute(
update(AgentBatchTestItem)
.where(AgentBatchTestItem.batch_id == job.id, AgentBatchTestItem.status == "pending")
.values(status="cancelled", finished_at=now, next_run_at=None)
)
OperationLogService.write(db, admin_id=current_admin.id, module="agent", action="batch_cancel", target_id=job.id)
db.commit()
db.refresh(job)
return api_success(job_dict(job))
def _owner_filter(admin: Admin) -> tuple:
return () if admin.is_super_admin else (AgentBatchTest.created_by == admin.id,)
def _job_or_404(db: Session, job_id: int, admin: Admin) -> AgentBatchTest:
job = db.get(AgentBatchTest, job_id)
if job is None or (not admin.is_super_admin and job.created_by != admin.id):
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="批量测试任务不存在")
return job

View File

@@ -10,10 +10,13 @@ from sqlalchemy.orm import Session
from app.core.database import get_db
from app.core.dependencies import get_current_admin
from app.core.responses import api_success
from app.core.time_utils import as_utc_naive, business_day_boundary
from app.models.admin import Admin
from app.models.chat import ChatMessage
from app.models.knowledge import (
HumanAttentionHistory,
HumanAttentionRecord,
Knowledge,
KnowledgeRetrievalCandidate,
KnowledgeRetrievalLog,
)
@@ -94,7 +97,9 @@ def _cleanup_before(payload: dict) -> datetime:
if not raw:
raise HTTPException(status_code=400, detail="必须指定清理日期")
try:
return datetime.fromisoformat(raw.replace("Z", "+00:00")).replace(tzinfo=None)
if len(raw) == 10:
return business_day_boundary(datetime.fromisoformat(raw).date())
return as_utc_naive(datetime.fromisoformat(raw.replace("Z", "+00:00")))
except ValueError as exc:
raise HTTPException(status_code=400, detail="清理日期格式错误") from exc
@@ -113,11 +118,19 @@ def retrieval_detail(
.where(KnowledgeRetrievalCandidate.retrieval_log_id == log.id)
.order_by(KnowledgeRetrievalCandidate.selected.desc(), KnowledgeRetrievalCandidate.id)
).all()
knowledge_ids = {item.knowledge_id for item in candidates}
knowledge_map = {
item.id: item
for item in db.scalars(select(Knowledge).where(Knowledge.id.in_(knowledge_ids))).all()
} if knowledge_ids else {}
data = _retrieval_dict(log, detail=True)
data["candidates"] = [
{
"id": item.id,
"knowledgeId": item.knowledge_id,
"knowledgeName": knowledge_map[item.knowledge_id].name if item.knowledge_id in knowledge_map else "",
"knowledgeType": knowledge_map[item.knowledge_id].knowledge_type if item.knowledge_id in knowledge_map else "general",
"knowledgeTypeLabel": _knowledge_type_label(knowledge_map[item.knowledge_id].knowledge_type if item.knowledge_id in knowledge_map else "general"),
"versionId": item.version_id,
"chunkId": item.chunk_id,
"sectionId": item.section_id,
@@ -131,23 +144,57 @@ def retrieval_detail(
return api_success(data)
def _knowledge_type_label(value: str) -> str:
return {
"fixed": "固定信息类·最高优先级",
"course": "课程知识库",
"qa": "答疑知识库",
"general": "通用知识库",
}.get(value, value or "通用知识库")
@router.get("/attention/list")
def attention_list(
priority: str = Query(default=""),
statusValue: str = Query(default="", alias="status"),
priority: str = Query(default="", pattern="^(|urgent|important|normal)$"),
statusValue: str = Query(default="", alias="status", pattern="^(|pending|processing|resolved|ignored)$"),
dateFrom: datetime | None = Query(default=None),
dateTo: datetime | None = Query(default=None),
page: int = Query(default=1, ge=1),
pageSize: int = Query(default=20, ge=10, le=100),
db: Session = Depends(get_db),
current_admin: Admin = Depends(get_current_admin),
) -> dict:
dateFrom = as_utc_naive(dateFrom) if dateFrom is not None else None
dateTo = as_utc_naive(dateTo) if dateTo is not None else None
if dateFrom is not None and dateTo is not None and dateFrom > dateTo:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="开始时间不能晚于结束时间")
query = select(HumanAttentionRecord)
if priority:
query = query.where(HumanAttentionRecord.priority == priority)
if statusValue:
query = query.where(HumanAttentionRecord.status == statusValue)
if dateFrom is not None:
query = query.where(HumanAttentionRecord.created_at >= dateFrom)
if dateTo is not None:
query = query.where(HumanAttentionRecord.created_at <= dateTo)
total = db.scalar(select(func.count()).select_from(query.subquery())) or 0
rows = db.scalars(query.order_by(HumanAttentionRecord.id.desc()).offset((page - 1) * pageSize).limit(pageSize)).all()
return api_success(page_result([_attention_dict(item) for item in rows], total=total, page=page, page_size=pageSize))
message_keys = {
(message_id, session_id)
for message_id, session_id in db.execute(
select(ChatMessage.id, ChatMessage.session_id).where(
ChatMessage.id.in_([item.message_id for item in rows])
)
).all()
} if rows else set()
items = [
_attention_dict(
item,
target_available=(item.message_id, item.session_id) in message_keys,
)
for item in rows
]
return api_success(page_result(items, total=total, page=page, page_size=pageSize))
@router.put("/attention/{attention_id}")
@@ -233,8 +280,8 @@ def _retrieval_dict(item: KnowledgeRetrievalLog, *, detail: bool) -> dict:
return data
def _attention_dict(item: HumanAttentionRecord) -> dict:
return {
def _attention_dict(item: HumanAttentionRecord, *, target_available: bool | None = None) -> dict:
data = {
"id": item.id,
"sessionId": item.session_id,
"messageId": item.message_id,
@@ -249,6 +296,9 @@ def _attention_dict(item: HumanAttentionRecord) -> dict:
"createdAt": item.created_at,
"updatedAt": item.updated_at,
}
if target_available is not None:
data["targetAvailable"] = target_available
return data
def _json(raw: str | None):

View File

@@ -0,0 +1,174 @@
from __future__ import annotations
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy import func, or_, select
from sqlalchemy.orm import Session
from app.api.pagination import page_result
from app.core.database import get_db
from app.core.dependencies import get_current_admin
from app.core.responses import api_success
from app.models.admin import Admin
from app.models.chat import ChatMessage, ChatSession
from app.models.knowledge import KnowledgeRetrievalLog
from app.models.user import User
from app.schemas.knowledge import AttentionConfigRequest, AttentionPreviewRequest
from app.services.admin_service import OperationLogService
from app.services.human_attention_service import HumanAttentionService
router = APIRouter()
@router.get("/attention/config")
def get_attention_config(
db: Session = Depends(get_db),
current_admin: Admin = Depends(get_current_admin),
) -> dict:
return api_success(HumanAttentionService.config_dict(HumanAttentionService.get_config(db)))
@router.put("/attention/config")
def save_attention_config(
payload: AttentionConfigRequest,
db: Session = Depends(get_db),
current_admin: Admin = Depends(get_current_admin),
) -> dict:
config = HumanAttentionService.save_config(db, payload.model_dump(), current_admin.id)
OperationLogService.write(db, admin_id=current_admin.id, module="attention", action="config_update")
db.commit()
return api_success(HumanAttentionService.config_dict(config))
@router.get("/attention/preview/messages")
def attention_preview_messages(
keyword: str = Query(default="", max_length=100),
page: int = Query(default=1, ge=1),
pageSize: int = Query(default=10, ge=5, le=50),
db: Session = Depends(get_db),
current_admin: Admin = Depends(get_current_admin),
) -> dict:
query = (
select(ChatMessage, ChatSession, User)
.join(ChatSession, ChatSession.id == ChatMessage.session_id)
.join(User, User.id == ChatMessage.user_id)
.where(ChatMessage.role == "user", ChatMessage.message_status == "FINISHED")
)
normalized_keyword = keyword.strip()
if normalized_keyword:
pattern = f"%{normalized_keyword}%"
query = query.where(or_(ChatMessage.content.like(pattern), User.name.like(pattern), User.phone.like(pattern)))
total = db.scalar(select(func.count()).select_from(query.order_by(None).subquery())) or 0
rows = db.execute(
query.order_by(ChatMessage.id.desc()).offset((page - 1) * pageSize).limit(pageSize)
).all()
items = [_preview_message_dict(db, message, session, user) for message, session, user in rows]
return api_success(page_result(items, total=total, page=page, page_size=pageSize))
@router.get("/attention/preview/config")
def get_attention_preview_config(
db: Session = Depends(get_db),
current_admin: Admin = Depends(get_current_admin),
) -> dict:
return api_success(HumanAttentionService.config_dict(HumanAttentionService.get_config(db)))
@router.post("/attention/preview")
def preview_attention_filter(
payload: AttentionPreviewRequest,
db: Session = Depends(get_db),
current_admin: Admin = Depends(get_current_admin),
) -> dict:
messages = db.scalars(
select(ChatMessage).where(
ChatMessage.id.in_(payload.messageIds),
ChatMessage.role == "user",
ChatMessage.message_status == "FINISHED",
)
).all()
by_id = {message.id: message for message in messages}
missing_ids = [message_id for message_id in payload.messageIds if message_id not in by_id]
if missing_ids:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"历史消息不存在:{missing_ids[0]}")
results = []
for message_id in payload.messageIds:
message = by_id[message_id]
answer, knowledge_missing = _answer_and_knowledge_state(db, message)
decision = HumanAttentionService.preview(
db,
question=message.content,
answer=answer,
knowledge_missing=knowledge_missing,
config_payload=payload.config.model_dump(),
user_id=message.user_id,
)
results.append(
{
"messageId": message.id,
"sessionId": message.session_id,
"question": message.content,
"answer": answer,
"knowledgeMissing": knowledge_missing,
"needsAttention": decision.needs_attention,
"priority": decision.priority,
"reason": decision.reason,
"summary": decision.summary,
"source": decision.source,
"rawOutput": decision.raw_output,
"renderedPrompt": decision.rendered_prompt,
"matchedItem": decision.matched_item,
}
)
OperationLogService.write(
db,
admin_id=current_admin.id,
module="attention",
action=f"preview:{len(results)}",
)
db.commit()
return api_success(results)
def _preview_message_dict(db: Session, message: ChatMessage, session: ChatSession, user: User) -> dict:
answer, knowledge_missing = _answer_and_knowledge_state(db, message)
return {
"messageId": message.id,
"sessionId": message.session_id,
"userId": message.user_id,
"userName": user.name,
"userPhone": user.phone,
"sessionTitle": session.title,
"question": message.content,
"answer": answer,
"knowledgeMissing": knowledge_missing,
"createdAt": message.created_at,
}
def _answer_and_knowledge_state(db: Session, user_message: ChatMessage) -> tuple[str, bool]:
answer = db.scalar(
select(ChatMessage)
.where(
ChatMessage.session_id == user_message.session_id,
ChatMessage.user_id == user_message.user_id,
ChatMessage.role == "assistant",
ChatMessage.id > user_message.id,
)
.order_by(ChatMessage.id.asc())
.limit(1)
)
if answer is None:
return "", False
retrieval_log = db.scalar(
select(KnowledgeRetrievalLog)
.where(KnowledgeRetrievalLog.message_id == answer.id)
.order_by(KnowledgeRetrievalLog.id.desc())
.limit(1)
)
knowledge_missing = bool(
retrieval_log
and retrieval_log.knowledge_called == 1
and not (retrieval_log.final_section_ids or "").strip()
)
return answer.content, knowledge_missing

View File

@@ -7,7 +7,8 @@ from app.core.database import get_db
from app.core.dependencies import get_current_admin
from app.core.responses import api_success
from app.models.admin import Admin
from app.schemas.admin import AdminLoginRequest, AdminLoginResponse, AdminRead
from app.schemas.admin import AdminLoginRequest, AdminLoginResponse
from app.services.admin_permission_service import permissions_for
from app.services.admin_service import AdminAuthService
from app.services.auth_service import AuthService
from app.services.security_state_service import client_ip
@@ -24,7 +25,12 @@ def login(payload: AdminLoginRequest, request: Request, db: Session = Depends(ge
@router.get("/profile")
def profile(current_admin: Admin = Depends(get_current_admin)) -> dict:
return api_success(AdminRead.model_validate(current_admin).model_dump())
return api_success({
"id": current_admin.id, "username": current_admin.username, "name": current_admin.name,
"status": current_admin.status, "isSuperAdmin": bool(current_admin.is_super_admin),
"mustChangePassword": bool(current_admin.must_change_password),
"permissions": sorted(permissions_for(current_admin)),
})
@router.post("/logout")

View File

@@ -0,0 +1,234 @@
from __future__ import annotations
from typing import Literal
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy import func, select
from sqlalchemy.orm import Session
from app.api.pagination import page_result
from app.core.database import get_db
from app.core.dependencies import get_current_admin
from app.core.responses import api_success
from app.models.admin import Admin
from app.models.ai_config import ContentGenerationConfig
from app.schemas.admin import (
ContentGenerationConfigSaveRequest,
ContentGenerationPreviewRequest,
ContentGenerationTestRequest,
)
from app.services.admin_service import OperationLogService
from app.services.content_generation_config_service import (
ContentGenerationConfigService,
ContentGenerationType,
config_detail,
)
router = APIRouter()
@router.get("/content-generation/config/{config_type}")
def get_content_generation_config(
config_type: Literal["help_card", "share_draft", "weekly_report", "monthly_report"],
db: Session = Depends(get_db),
current_admin: Admin = Depends(get_current_admin),
) -> dict:
row = _config_with_admin(db, config_type)
return api_success(config_detail(config_type, row[0], row[1].name if row and row[1] else None) if row else config_detail(config_type, None))
@router.put("/content-generation/config/{config_type}")
def save_content_generation_config(
config_type: Literal["help_card", "share_draft", "weekly_report", "monthly_report"],
payload: ContentGenerationConfigSaveRequest,
db: Session = Depends(get_db),
current_admin: Admin = Depends(get_current_admin),
) -> dict:
config = ContentGenerationConfigService.save(
db,
config_type=config_type,
template_content=payload.templateContent,
instruction_content=payload.instructionContent,
variables=[item.model_dump() for item in payload.variables],
updated_by=current_admin.id,
)
OperationLogService.write(
db,
admin_id=current_admin.id,
module="content_generation",
action=f"save_{config_type}",
target_id=config.id,
)
db.commit()
db.refresh(config)
return api_success(config_detail(config_type, config, current_admin.name))
@router.post("/content-generation/config/{config_type}/reset")
def reset_content_generation_config(
config_type: Literal["help_card", "share_draft", "weekly_report", "monthly_report"],
db: Session = Depends(get_db),
current_admin: Admin = Depends(get_current_admin),
) -> dict:
config = ContentGenerationConfigService.reset(
db,
config_type=config_type,
updated_by=current_admin.id,
)
OperationLogService.write(
db,
admin_id=current_admin.id,
module="content_generation",
action=f"reset_{config_type}",
target_id=config.id,
)
db.commit()
db.refresh(config)
return api_success(config_detail(config_type, config, current_admin.name))
@router.get("/content-generation/config/{config_type}/history")
def content_generation_history(
config_type: Literal["help_card", "share_draft", "weekly_report", "monthly_report"],
page: int = Query(default=1, ge=1),
pageSize: int = Query(default=10, ge=5, le=100),
db: Session = Depends(get_db),
current_admin: Admin = Depends(get_current_admin),
) -> dict:
total = db.scalar(
select(func.count(ContentGenerationConfig.id)).where(ContentGenerationConfig.config_type == config_type)
) or 0
current_id = db.scalar(
select(ContentGenerationConfig.id)
.where(ContentGenerationConfig.config_type == config_type)
.order_by(ContentGenerationConfig.updated_at.desc(), ContentGenerationConfig.id.desc())
.limit(1)
)
rows = db.execute(
select(ContentGenerationConfig, Admin)
.outerjoin(Admin, Admin.id == ContentGenerationConfig.updated_by)
.where(ContentGenerationConfig.config_type == config_type)
.order_by(ContentGenerationConfig.updated_at.desc(), ContentGenerationConfig.id.desc())
.offset((page - 1) * pageSize)
.limit(pageSize)
).all()
items = [_history_item(config, admin, current_id=current_id) for config, admin in rows]
return api_success(page_result(items, total=total, page=page, page_size=pageSize))
@router.get("/content-generation/config/{config_type}/history/{config_id}")
def content_generation_history_detail(
config_type: Literal["help_card", "share_draft", "weekly_report", "monthly_report"],
config_id: int,
db: Session = Depends(get_db),
current_admin: Admin = Depends(get_current_admin),
) -> dict:
row = db.execute(
select(ContentGenerationConfig, Admin)
.outerjoin(Admin, Admin.id == ContentGenerationConfig.updated_by)
.where(
ContentGenerationConfig.id == config_id,
ContentGenerationConfig.config_type == config_type,
)
).first()
if row is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="内容生成配置版本不存在")
return api_success(config_detail(config_type, row[0], row[1].name if row[1] else None))
@router.post("/content-generation/config/{config_type}/history/{config_id}/restore")
def restore_content_generation_config(
config_type: Literal["help_card", "share_draft", "weekly_report", "monthly_report"],
config_id: int,
db: Session = Depends(get_db),
current_admin: Admin = Depends(get_current_admin),
) -> dict:
restored = ContentGenerationConfigService.restore(
db,
config_type=config_type,
source_config_id=config_id,
updated_by=current_admin.id,
)
OperationLogService.write(
db,
admin_id=current_admin.id,
module="content_generation",
action=f"restore_{config_type}",
target_id=restored.id,
)
db.commit()
db.refresh(restored)
return api_success(config_detail(config_type, restored, current_admin.name))
@router.post("/content-generation/preview")
def preview_content_generation(
payload: ContentGenerationPreviewRequest,
current_admin: Admin = Depends(get_current_admin),
) -> dict:
return api_success(
{
"content": ContentGenerationConfigService.preview(
payload.configType,
payload.templateContent,
[item.model_dump() for item in payload.variables],
)
}
)
@router.post("/content-generation/test")
def test_content_generation(
payload: ContentGenerationTestRequest,
db: Session = Depends(get_db),
current_admin: Admin = Depends(get_current_admin),
) -> dict:
variables = [item.model_dump() for item in payload.variables]
values = ContentGenerationConfigService.build_test_values(
payload.configType,
variables,
payload.sampleText,
)
generated, used_fallback = ContentGenerationConfigService.generate_values(
db,
config_type=payload.configType,
instruction_content=payload.instructionContent,
values=values,
variables=variables,
user_id=None,
)
content = ContentGenerationConfigService.render(payload.configType, payload.templateContent, generated, variables)
OperationLogService.write(
db,
admin_id=current_admin.id,
module="content_generation",
action=f"test_{payload.configType}",
)
db.commit()
return api_success({"content": content, "usedFallback": used_fallback})
def _config_with_admin(db: Session, config_type: ContentGenerationType):
return db.execute(
select(ContentGenerationConfig, Admin)
.outerjoin(Admin, Admin.id == ContentGenerationConfig.updated_by)
.where(ContentGenerationConfig.config_type == config_type)
.order_by(ContentGenerationConfig.updated_at.desc(), ContentGenerationConfig.id.desc())
.limit(1)
).first()
def _history_item(config: ContentGenerationConfig, admin: Admin | None, *, current_id: int | None) -> dict:
compact = " ".join(config.template_content.split())
return {
"id": config.id,
"configType": config.config_type,
"preview": compact[:140],
"templateCharCount": len(config.template_content),
"instructionCharCount": len(config.instruction_content),
"changeType": config.change_type,
"sourceConfigId": config.source_config_id,
"updatedByName": admin.name if admin else "未知管理员",
"updatedAt": config.updated_at,
"isCurrent": config.id == current_id,
}

View File

@@ -5,6 +5,7 @@ from datetime import datetime, timedelta
from fastapi import APIRouter, Depends, Query
import json
from pathlib import Path
from typing import Literal
from sqlalchemy import select, text
from sqlalchemy.orm import Session
@@ -16,6 +17,8 @@ from app.schemas.admin import DashboardStats
from app.services.admin_service import AdminDashboardService
from app.models.logs import StorageSnapshot
from app.services.redis_client import get_sync_redis_client
from app.services.request_traffic_service import RequestTrafficService
from app.core.time_utils import business_day_boundary
router = APIRouter()
@@ -27,16 +30,20 @@ def dashboard(
db: Session = Depends(get_db),
current_admin: Admin = Depends(get_current_admin),
) -> dict:
start_dt = datetime.strptime(start, "%Y-%m-%d") if start else None
if end:
end_dt = datetime.strptime(end, "%Y-%m-%d")
end_dt = end_dt.replace(hour=23, minute=59, second=59)
else:
end_dt = None
start_dt = business_day_boundary(datetime.strptime(start, "%Y-%m-%d").date()) if start else None
end_dt = business_day_boundary(datetime.strptime(end, "%Y-%m-%d").date(), end_exclusive=True) if end else None
stats = AdminDashboardService.stats(db, start_dt, end_dt)
return api_success(DashboardStats.model_validate(stats).model_dump())
@router.get("/dashboard/traffic")
async def peak_traffic(
grain: Literal["minute", "hour", "day", "week"] = Query(default="hour"),
current_admin: Admin = Depends(get_current_admin),
) -> dict:
return api_success(await RequestTrafficService.peak_traffic(grain))
@router.get("/dashboard/storage")
def storage_stats(
refresh: bool = Query(default=False),

View File

@@ -0,0 +1,179 @@
from __future__ import annotations
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy.orm import Session
from app.core.database import get_db
from app.core.dependencies import get_current_admin
from app.core.responses import api_success
from app.models.admin import Admin
from app.models.entitlement import EntitlementPlan
from app.models.user import User
from app.schemas.admin import (
EntitlementPlanSaveRequest,
UserEntitlementAssignRequest,
UserEntitlementBatchRenewRequest,
UserEntitlementRenewRequest,
)
from app.services.admin_service import OperationLogService
from app.services.entitlement_service import EntitlementService, entitlement_dict, plan_dict
router = APIRouter()
@router.get("/entitlement/plan/list")
def list_entitlement_plans(
includeDisabled: bool = Query(default=True),
db: Session = Depends(get_db),
current_admin: Admin = Depends(get_current_admin),
) -> dict:
plans = EntitlementService.list_plans(db, include_disabled=includeDisabled)
return api_success([plan_dict(plan) for plan in plans])
@router.post("/entitlement/plan")
def create_entitlement_plan(
payload: EntitlementPlanSaveRequest,
db: Session = Depends(get_db),
current_admin: Admin = Depends(get_current_admin),
) -> dict:
plan = EntitlementPlan()
_apply_plan_payload(plan, payload)
db.add(plan)
db.flush()
OperationLogService.write(db, admin_id=current_admin.id, module="entitlement", action="create_plan", target_id=plan.id)
db.commit()
db.refresh(plan)
return api_success(plan_dict(plan))
@router.put("/entitlement/plan/{plan_id}")
def update_entitlement_plan(
plan_id: int,
payload: EntitlementPlanSaveRequest,
db: Session = Depends(get_db),
current_admin: Admin = Depends(get_current_admin),
) -> dict:
plan = db.get(EntitlementPlan, plan_id)
if plan is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="权益版本不存在")
_apply_plan_payload(plan, payload)
db.add(plan)
OperationLogService.write(db, admin_id=current_admin.id, module="entitlement", action="update_plan", target_id=plan.id)
db.commit()
db.refresh(plan)
return api_success(plan_dict(plan))
@router.post("/user/{user_id}/entitlement")
def assign_user_entitlement(
user_id: int,
payload: UserEntitlementAssignRequest,
db: Session = Depends(get_db),
current_admin: Admin = Depends(get_current_admin),
) -> dict:
user = db.get(User, user_id)
if user is None or user.is_deleted:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="用户不存在")
entitlement = EntitlementService.assign_user_plan(
db,
user=user,
plan_id=payload.planId,
operated_by=current_admin.id,
effective_at=payload.effectiveAt,
expired_at=payload.expiredAt,
remark=payload.remark,
)
OperationLogService.write(
db,
admin_id=current_admin.id,
module="entitlement",
action="assign_user_plan",
target_id=user.id,
)
db.commit()
db.refresh(entitlement)
view = EntitlementService.active_entitlement(db, user)
return api_success(entitlement_dict(view))
@router.post("/user/{user_id}/entitlement/renew")
def renew_user_entitlement(
user_id: int,
payload: UserEntitlementRenewRequest,
db: Session = Depends(get_db),
current_admin: Admin = Depends(get_current_admin),
) -> dict:
user = db.get(User, user_id)
if user is None or user.is_deleted:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="用户不存在")
EntitlementService.renew_user_plan(
db,
user=user,
extension_days=payload.extensionDays,
request_key=f"{payload.idempotencyKey}:{user.id}",
operated_by=current_admin.id,
remark=payload.remark,
)
OperationLogService.write(
db,
admin_id=current_admin.id,
module="entitlement",
action="renew_user_plan",
target_id=user.id,
)
db.commit()
view = EntitlementService.active_entitlement(db, user)
return api_success(entitlement_dict(view))
@router.post("/user/entitlement/batch-renew")
def batch_renew_user_entitlement(
payload: UserEntitlementBatchRenewRequest,
db: Session = Depends(get_db),
current_admin: Admin = Depends(get_current_admin),
) -> dict:
succeeded: list[dict] = []
failed: list[dict] = []
for user_id in dict.fromkeys(payload.userIds):
user = db.get(User, user_id)
if user is None or user.is_deleted:
failed.append({"userId": user_id, "reason": "用户不存在"})
continue
try:
EntitlementService.renew_user_plan(
db,
user=user,
extension_days=payload.extensionDays,
request_key=f"{payload.idempotencyKey}:{user.id}",
operated_by=current_admin.id,
remark=payload.remark,
)
succeeded.append({"userId": user.id, "name": user.name})
except HTTPException as exc:
failed.append({"userId": user.id, "name": user.name, "reason": str(exc.detail)})
OperationLogService.write(
db,
admin_id=current_admin.id,
module="entitlement",
action="batch_renew_user_plan",
target_id=None,
result="SUCCESS" if not failed else "PARTIAL",
)
db.commit()
return api_success(
{"succeeded": succeeded, "failed": failed, "successCount": len(succeeded), "failureCount": len(failed)}
)
def _apply_plan_payload(plan: EntitlementPlan, payload: EntitlementPlanSaveRequest) -> None:
plan.name = payload.name.strip()
plan.plan_type = payload.planType
plan.description = payload.description.strip() if payload.description else None
plan.validity_days = payload.validityDays
plan.enable_growth_profile = payload.enableGrowthProfile
plan.enable_periodic_reports = payload.enablePeriodicReports
plan.allow_help_card = payload.allowHelpCard
plan.allow_share_draft = payload.allowShareDraft
plan.status = payload.status
plan.sort_order = payload.sortOrder

View File

@@ -36,7 +36,6 @@ def list_knowledge(
knowledgeType: str = Query(default=""),
openStatus: int | None = Query(default=None),
sourceStatus: str = Query(default=""),
pending: bool | None = Query(default=None),
page: int = Query(default=1, ge=1),
pageSize: int = Query(default=20, ge=10, le=100),
db: Session = Depends(get_db),
@@ -51,8 +50,6 @@ def list_knowledge(
query = query.where(Knowledge.status == openStatus)
if sourceStatus:
query = query.where(Knowledge.source_status == sourceStatus)
if pending is not None:
query = query.where(Knowledge.pending_version_id.is_not(None) if pending else Knowledge.pending_version_id.is_(None))
total = db.scalar(select(func.count()).select_from(query.subquery())) or 0
items = db.scalars(query.order_by(Knowledge.id.desc()).offset((page - 1) * pageSize).limit(pageSize)).all()
return api_success(page_result([_knowledge_dict(item) for item in items], total=total, page=page, page_size=pageSize))
@@ -119,7 +116,7 @@ def import_knowledge_from_space(
feishu_node_id=node_token,
status=0,
knowledge_type="general",
review_mode="manual",
review_mode="automatic",
remark=f"obj_type={info.get('obj_type', '')}",
)
db.add(knowledge)
@@ -153,7 +150,7 @@ def create_knowledge(
status=0,
remark=payload.remark,
knowledge_type=payload.knowledgeType,
review_mode=payload.reviewMode,
review_mode="automatic",
)
db.add(knowledge)
db.flush()
@@ -179,7 +176,7 @@ def update_knowledge(
knowledge.name = payload.name
knowledge.remark = payload.remark
knowledge.knowledge_type = payload.knowledgeType
knowledge.review_mode = payload.reviewMode
knowledge.review_mode = "automatic"
db.add(knowledge)
OperationLogService.write(db, admin_id=current_admin.id, module="knowledge", action="update", target_id=knowledge.id)
db.commit()
@@ -195,12 +192,13 @@ def delete_knowledge(
) -> dict:
_require_super_admin(current_admin)
knowledge = _get_knowledge(db, knowledge_id)
if knowledge.lifecycle_status != "archived" or knowledge.status != 0:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="物理删除前必须先关闭并归档知识库",
)
# 清理早期按用户授权口径留下的历史关联,避免外键阻止知识库删除。
# 删除操作本身会立即停止开放并清理正式内容,不再要求管理员先执行“关闭、归档”两步。
knowledge.status = 0
knowledge.lifecycle_status = "archived"
db.add(knowledge)
db.flush()
# 清理按用户授权口径留下的历史关联,避免外键阻止知识库删除。
permissions = db.scalars(
select(UserKnowledgePermission).where(UserKnowledgePermission.knowledge_id == knowledge.id)
).all()
@@ -215,9 +213,16 @@ def delete_knowledge(
db.flush()
for row in db.scalars(select(KnowledgeSourceSnapshot).where(KnowledgeSourceSnapshot.knowledge_id == knowledge.id)).all():
db.delete(row)
db.delete(knowledge)
OperationLogService.write(db, admin_id=current_admin.id, module="knowledge", action="delete", target_id=knowledge.id)
db.commit()
db.delete(knowledge)
try:
db.commit()
except IntegrityError as exc:
db.rollback()
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="知识库仍有关联数据,删除未执行,请刷新后重试",
) from exc
return api_success()
@@ -243,13 +248,10 @@ def _knowledge_dict(knowledge: Knowledge) -> dict:
"remark": knowledge.remark,
"sourceTitle": knowledge.source_title,
"knowledgeType": knowledge.knowledge_type,
"reviewMode": knowledge.review_mode,
"lifecycleStatus": knowledge.lifecycle_status,
"sourceStatus": knowledge.source_status,
"sourceError": knowledge.source_error,
"manifestConfirmed": knowledge.manifest_confirmed == 1,
"currentVersionId": knowledge.current_version_id,
"pendingVersionId": knowledge.pending_version_id,
"lastSyncedAt": knowledge.last_synced_at,
"lastPublishedAt": knowledge.last_published_at,
"migrationStatus": knowledge.migration_status,

View File

@@ -3,12 +3,13 @@ from __future__ import annotations
import json
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy import func, select
from sqlalchemy import and_, func, literal, or_, select, union_all
from sqlalchemy.orm import Session
from app.core.database import get_db
from app.core.dependencies import get_current_admin
from app.core.responses import api_success
from app.api.pagination import page_result
from app.models.admin import Admin
from app.models.knowledge import (
Knowledge,
@@ -20,13 +21,11 @@ from app.models.knowledge import (
KnowledgeVersion,
)
from app.schemas.knowledge import (
KnowledgeLifecycleRequest,
KnowledgeBatchMetadataUpdateRequest,
KnowledgeBatchSyncRequest,
KnowledgeLifecycleRequest,
KnowledgeMetadataUpdateRequest,
KnowledgePublishRequest,
KnowledgeReviewRequest,
KnowledgeStatusRequest,
KnowledgeSyncRequest,
)
from app.services.admin_service import OperationLogService
from app.services.knowledge_pipeline_service import KnowledgePipelineService
@@ -41,8 +40,8 @@ def batch_update_metadata(
current_admin: Admin = Depends(get_current_admin),
) -> dict:
_require_super_admin(current_admin)
if payload.knowledgeType is None and payload.reviewMode is None:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="至少选择一个待修改字段")
if payload.knowledgeType is None:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="选择待修改的知识库类型")
knowledge_ids = list(dict.fromkeys(payload.knowledgeIds))
rows = {
item.id: item
@@ -56,8 +55,6 @@ def batch_update_metadata(
continue
if payload.knowledgeType is not None:
item.knowledge_type = payload.knowledgeType
if payload.reviewMode is not None:
item.review_mode = payload.reviewMode
db.add(item)
OperationLogService.write(
db,
@@ -85,7 +82,7 @@ def update_metadata(
knowledge = _knowledge(db, knowledge_id)
knowledge.name = payload.name.strip()
knowledge.knowledge_type = payload.knowledgeType
knowledge.review_mode = payload.reviewMode
knowledge.review_mode = "automatic"
db.add(knowledge)
OperationLogService.write(
db, admin_id=current_admin.id, module="knowledge", action="update_metadata", target_id=knowledge.id
@@ -104,37 +101,19 @@ def detail(
_require_super_admin(current_admin)
knowledge = _knowledge(db, knowledge_id)
current = db.get(KnowledgeVersion, knowledge.current_version_id) if knowledge.current_version_id else None
pending = db.get(KnowledgeVersion, knowledge.pending_version_id) if knowledge.pending_version_id else None
manifest = None
manifest_version_id = knowledge.pending_version_id or knowledge.current_version_id
manifest_version_id = knowledge.current_version_id
if manifest_version_id:
manifest = db.scalar(select(KnowledgeManifest).where(KnowledgeManifest.version_id == manifest_version_id))
return api_success(
{
"knowledge": _knowledge_detail(knowledge),
"currentVersion": _version_dict(current),
"pendingVersion": _version_dict(pending),
"manifest": _manifest_dict(manifest),
}
)
@router.get("/knowledge/{knowledge_id}/versions")
def versions(
knowledge_id: int,
db: Session = Depends(get_db),
current_admin: Admin = Depends(get_current_admin),
) -> dict:
_require_super_admin(current_admin)
_knowledge(db, knowledge_id)
rows = db.scalars(
select(KnowledgeVersion)
.where(KnowledgeVersion.knowledge_id == knowledge_id)
.order_by(KnowledgeVersion.version_no.desc())
).all()
return api_success([_version_dict(item) for item in rows])
@router.get("/knowledge/version/{version_id}/content")
def version_content(
version_id: int,
@@ -187,6 +166,185 @@ def version_content(
)
@router.get("/knowledge/content-search")
def content_search(
keyword: str = Query(default="", min_length=1, max_length=100),
includeClosed: bool = Query(default=True),
page: int = Query(default=1, ge=1),
pageSize: int = Query(default=10, ge=5, le=50),
db: Session = Depends(get_db),
current_admin: Admin = Depends(get_current_admin),
) -> dict:
_require_super_admin(current_admin)
keyword = keyword.strip()
if not keyword:
return api_success({"items": [], "total": 0, "page": page, "pageSize": pageSize})
pattern = f"%{_escape_like(keyword)}%"
base_filters = [
Knowledge.current_version_id.is_not(None),
Knowledge.current_version_id == KnowledgeVersion.id,
Knowledge.lifecycle_status == "active",
]
if not includeClosed:
base_filters.append(Knowledge.status == 1)
section_query = (
select(
Knowledge.id.label("knowledge_id"),
Knowledge.name.label("knowledge_name"),
Knowledge.source_title.label("source_title"),
Knowledge.knowledge_type.label("knowledge_type"),
Knowledge.status.label("knowledge_status"),
Knowledge.lifecycle_status.label("lifecycle_status"),
Knowledge.source_status.label("source_status"),
KnowledgeVersion.id.label("version_id"),
KnowledgeVersion.version_no.label("version_no"),
KnowledgeVersion.published_at.label("published_at"),
literal("section").label("item_type"),
literal(1).label("item_rank"),
KnowledgeSection.id.label("item_id"),
KnowledgeSection.id.label("section_id"),
KnowledgeSection.section_key.label("section_key"),
KnowledgeSection.title.label("title"),
KnowledgeSection.content.label("content"),
KnowledgeSection.sort_order.label("sort_order"),
)
.select_from(KnowledgeSection)
.join(Knowledge, Knowledge.id == KnowledgeSection.knowledge_id)
.join(KnowledgeVersion, KnowledgeVersion.id == KnowledgeSection.version_id)
.where(*base_filters)
.where(or_(KnowledgeSection.title.like(pattern, escape="\\"), KnowledgeSection.content.like(pattern, escape="\\")))
)
card_query = (
select(
Knowledge.id.label("knowledge_id"),
Knowledge.name.label("knowledge_name"),
Knowledge.source_title.label("source_title"),
Knowledge.knowledge_type.label("knowledge_type"),
Knowledge.status.label("knowledge_status"),
Knowledge.lifecycle_status.label("lifecycle_status"),
Knowledge.source_status.label("source_status"),
KnowledgeVersion.id.label("version_id"),
KnowledgeVersion.version_no.label("version_no"),
KnowledgeVersion.published_at.label("published_at"),
literal("card").label("item_type"),
literal(2).label("item_rank"),
KnowledgeCard.id.label("item_id"),
KnowledgeCard.section_id.label("section_id"),
KnowledgeSection.section_key.label("section_key"),
KnowledgeCard.title.label("title"),
KnowledgeCard.summary.label("content"),
KnowledgeSection.sort_order.label("sort_order"),
)
.select_from(KnowledgeCard)
.join(Knowledge, Knowledge.id == KnowledgeCard.knowledge_id)
.join(KnowledgeVersion, KnowledgeVersion.id == KnowledgeCard.version_id)
.join(KnowledgeSection, KnowledgeSection.id == KnowledgeCard.section_id)
.where(*base_filters)
.where(
or_(
KnowledgeCard.title.like(pattern, escape="\\"),
KnowledgeCard.summary.like(pattern, escape="\\"),
KnowledgeCard.core_conclusion.like(pattern, escape="\\"),
KnowledgeCard.applicable_questions.like(pattern, escape="\\"),
KnowledgeCard.keywords.like(pattern, escape="\\"),
KnowledgeCard.synonyms.like(pattern, escape="\\"),
)
)
)
chunk_query = (
select(
Knowledge.id.label("knowledge_id"),
Knowledge.name.label("knowledge_name"),
Knowledge.source_title.label("source_title"),
Knowledge.knowledge_type.label("knowledge_type"),
Knowledge.status.label("knowledge_status"),
Knowledge.lifecycle_status.label("lifecycle_status"),
Knowledge.source_status.label("source_status"),
KnowledgeVersion.id.label("version_id"),
KnowledgeVersion.version_no.label("version_no"),
KnowledgeVersion.published_at.label("published_at"),
literal("chunk").label("item_type"),
literal(3).label("item_rank"),
KnowledgeChunk.id.label("item_id"),
KnowledgeChunk.section_id.label("section_id"),
KnowledgeSection.section_key.label("section_key"),
KnowledgeChunk.title.label("title"),
KnowledgeChunk.content.label("content"),
KnowledgeSection.sort_order.label("sort_order"),
)
.select_from(KnowledgeChunk)
.join(Knowledge, Knowledge.id == KnowledgeChunk.knowledge_id)
.join(KnowledgeVersion, KnowledgeVersion.id == KnowledgeChunk.version_id)
.join(KnowledgeSection, KnowledgeSection.id == KnowledgeChunk.section_id)
.where(*base_filters)
.where(
or_(
KnowledgeChunk.title.like(pattern, escape="\\"),
KnowledgeChunk.content.like(pattern, escape="\\"),
KnowledgeChunk.normalized_text.like(pattern, escape="\\"),
KnowledgeChunk.keywords.like(pattern, escape="\\"),
KnowledgeChunk.synonyms.like(pattern, escape="\\"),
)
)
)
search = union_all(section_query, card_query, chunk_query).subquery()
grouped = (
select(
search.c.knowledge_id,
search.c.version_id,
search.c.section_id,
func.max(search.c.knowledge_status).label("knowledge_status"),
func.min(search.c.sort_order).label("sort_order"),
func.min(search.c.item_rank).label("item_rank"),
func.min(search.c.item_id).label("item_id"),
)
.group_by(search.c.knowledge_id, search.c.version_id, search.c.section_id)
.subquery()
)
total = db.scalar(select(func.count()).select_from(grouped)) or 0
page_groups = (
select(grouped.c.knowledge_id, grouped.c.version_id, grouped.c.section_id)
.order_by(
grouped.c.knowledge_status.desc(),
grouped.c.knowledge_id.desc(),
grouped.c.sort_order.asc(),
grouped.c.item_rank.asc(),
grouped.c.item_id.asc(),
)
.offset((page - 1) * pageSize)
.limit(pageSize)
.subquery()
)
rows = db.execute(
select(search)
.join(
page_groups,
and_(
page_groups.c.knowledge_id == search.c.knowledge_id,
page_groups.c.version_id == search.c.version_id,
page_groups.c.section_id == search.c.section_id,
),
)
.order_by(
search.c.knowledge_status.desc(),
search.c.knowledge_id.desc(),
search.c.sort_order.asc(),
search.c.item_rank.asc(),
search.c.item_id.asc(),
)
).mappings().all()
section_ids = [int(row["section_id"]) for row in rows if row["section_id"]]
chunk_section_ids = set()
if section_ids:
chunk_section_ids = set(
db.scalars(select(KnowledgeChunk.section_id).where(KnowledgeChunk.section_id.in_(section_ids))).all()
)
merged = _merge_content_search_rows(rows, keyword, chunk_section_ids)
return api_success(page_result(merged, total=total, page=page, page_size=pageSize))
@router.get("/knowledge/{knowledge_id}/sync-jobs")
def sync_jobs(
knowledge_id: int,
@@ -203,10 +361,82 @@ def sync_jobs(
return api_success([_job_dict(item) for item in rows])
@router.post("/knowledge/batch/sync")
async def batch_sync(
payload: KnowledgeBatchSyncRequest,
db: Session = Depends(get_db),
current_admin: Admin = Depends(get_current_admin),
) -> dict:
_require_super_admin(current_admin)
knowledge_ids = list(dict.fromkeys(payload.knowledgeIds))
rows = {
item.id: item
for item in db.scalars(select(Knowledge).where(Knowledge.id.in_(knowledge_ids))).all()
}
results: list[dict] = []
for knowledge_id in knowledge_ids:
knowledge = rows.get(knowledge_id)
if knowledge is None:
results.append({"knowledgeId": knowledge_id, "ok": False, "error": "知识库不存在"})
continue
if knowledge.lifecycle_status == "archived":
results.append(
{
"knowledgeId": knowledge_id,
"name": knowledge.name,
"ok": False,
"error": "归档知识库不能同步",
}
)
continue
try:
job = await KnowledgePipelineService.synchronize(
db, knowledge, admin_id=current_admin.id
)
OperationLogService.write(
db,
admin_id=current_admin.id,
module="knowledge",
action="batch_sync",
target_id=knowledge.id,
)
db.commit()
results.append(
{
"knowledgeId": knowledge.id,
"name": knowledge.name,
"ok": True,
"status": job.status,
"job": _job_dict(job),
}
)
except Exception as exc:
OperationLogService.write(
db,
admin_id=current_admin.id,
module="knowledge",
action="batch_sync",
target_id=knowledge.id,
result="FAILED",
)
db.commit()
results.append(
{
"knowledgeId": knowledge.id,
"name": knowledge.name,
"ok": False,
"error": str(exc),
}
)
success = sum(1 for item in results if item["ok"])
return api_success(
{"total": len(results), "success": success, "failed": len(results) - success, "results": results}
)
@router.post("/knowledge/{knowledge_id}/sync")
async def synchronize(
knowledge_id: int,
payload: KnowledgeSyncRequest,
db: Session = Depends(get_db),
current_admin: Admin = Depends(get_current_admin),
) -> dict:
@@ -216,7 +446,7 @@ async def synchronize(
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="归档知识库不能同步")
try:
job = await KnowledgePipelineService.synchronize(
db, knowledge, admin_id=current_admin.id, mode=payload.mode
db, knowledge, admin_id=current_admin.id
)
except Exception as exc:
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=f"知识同步失败:{exc}") from exc
@@ -225,90 +455,6 @@ async def synchronize(
return api_success(_job_dict(job))
@router.post("/knowledge/{knowledge_id}/manifest/{version_id}/confirm")
def confirm_manifest(
knowledge_id: int,
version_id: int,
db: Session = Depends(get_db),
current_admin: Admin = Depends(get_current_admin),
) -> dict:
_require_super_admin(current_admin)
knowledge, version = _knowledge_version(db, knowledge_id, version_id)
if knowledge.pending_version_id != version.id:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="只能确认当前待发布版本的说明卡")
try:
KnowledgePipelineService.confirm_manifest(db, knowledge, version, current_admin.id)
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(exc)) from exc
OperationLogService.write(db, admin_id=current_admin.id, module="knowledge", action="confirm_manifest", target_id=knowledge.id)
db.commit()
return api_success()
@router.post("/knowledge/{knowledge_id}/version/{version_id}/review")
def review(
knowledge_id: int,
version_id: int,
payload: KnowledgeReviewRequest,
db: Session = Depends(get_db),
current_admin: Admin = Depends(get_current_admin),
) -> dict:
_require_super_admin(current_admin)
knowledge, version = _knowledge_version(db, knowledge_id, version_id)
if knowledge.pending_version_id != version.id or version.status != "pending_review":
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="只能审核当前待审核版本")
if version.quality_status == "blocked" and payload.action == "approve":
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="自动质检存在阻断项,不能审核通过")
KnowledgePipelineService.review_version(db, knowledge, version, current_admin.id, payload.action, payload.reason)
OperationLogService.write(db, admin_id=current_admin.id, module="knowledge", action=f"review_{payload.action}", target_id=knowledge.id)
db.commit()
return api_success()
@router.post("/knowledge/{knowledge_id}/version/{version_id}/publish")
def publish(
knowledge_id: int,
version_id: int,
payload: KnowledgePublishRequest,
db: Session = Depends(get_db),
current_admin: Admin = Depends(get_current_admin),
) -> dict:
_require_super_admin(current_admin)
knowledge, version = _knowledge_version(db, knowledge_id, version_id)
if knowledge.pending_version_id != version.id:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="只能发布当前待发布版本")
try:
KnowledgePipelineService.publish_version(db, knowledge, version, current_admin.id, payload.keepOpen)
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(exc)) from exc
OperationLogService.write(db, admin_id=current_admin.id, module="knowledge", action="publish", target_id=knowledge.id)
db.commit()
return api_success(_knowledge_detail(knowledge))
@router.post("/knowledge/{knowledge_id}/version/{version_id}/rollback")
def rollback(
knowledge_id: int,
version_id: int,
payload: KnowledgePublishRequest,
db: Session = Depends(get_db),
current_admin: Admin = Depends(get_current_admin),
) -> dict:
_require_super_admin(current_admin)
knowledge, version = _knowledge_version(db, knowledge_id, version_id)
if knowledge.current_version_id == version.id:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="目标版本已是当前正式版本")
if payload.keepOpen and knowledge.lifecycle_status != "active":
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="归档知识库不能在回滚后开放")
try:
KnowledgePipelineService.rollback(db, knowledge, version, current_admin.id, payload.keepOpen)
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(exc)) from exc
OperationLogService.write(db, admin_id=current_admin.id, module="knowledge", action="rollback", target_id=knowledge.id)
db.commit()
return api_success(_knowledge_detail(knowledge))
@router.put("/knowledge/{knowledge_id}/open-status")
def open_status(
knowledge_id: int,
@@ -321,8 +467,6 @@ def open_status(
if payload.open:
if not knowledge.current_version_id:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="知识库没有正式发布版本")
if knowledge.manifest_confirmed != 1:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="首次知识库说明卡尚未确认")
if knowledge.source_status != "normal":
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="飞书源文章异常,不能开放")
if knowledge.lifecycle_status != "active":
@@ -381,7 +525,7 @@ async def run_migration(
for knowledge in rows:
try:
job = await KnowledgePipelineService.synchronize(
db, knowledge, admin_id=current_admin.id, mode="review"
db, knowledge, admin_id=current_admin.id
)
results.append({"knowledgeId": knowledge.id, "ok": True, "job": _job_dict(job)})
except Exception as exc:
@@ -410,14 +554,6 @@ def _version(db: Session, version_id: int) -> KnowledgeVersion:
return item
def _knowledge_version(db: Session, knowledge_id: int, version_id: int) -> tuple[Knowledge, KnowledgeVersion]:
knowledge = _knowledge(db, knowledge_id)
version = _version(db, version_id)
if version.knowledge_id != knowledge.id:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="版本不属于该知识库")
return knowledge, version
def _knowledge_detail(item: Knowledge) -> dict:
return {
"id": item.id,
@@ -428,14 +564,11 @@ def _knowledge_detail(item: Knowledge) -> dict:
"feishuSpaceId": item.feishu_space_id,
"feishuNodeId": item.feishu_node_id,
"knowledgeType": item.knowledge_type,
"reviewMode": item.review_mode,
"open": item.status == 1,
"lifecycleStatus": item.lifecycle_status,
"sourceStatus": item.source_status,
"sourceError": item.source_error,
"manifestConfirmed": item.manifest_confirmed == 1,
"currentVersionId": item.current_version_id,
"pendingVersionId": item.pending_version_id,
"lastSyncedAt": item.last_synced_at,
"lastPublishedAt": item.last_published_at,
"migrationStatus": item.migration_status,
@@ -503,3 +636,117 @@ def _json_value(raw: str | None):
return json.loads(raw)
except json.JSONDecodeError:
return raw
def _escape_like(value: str) -> str:
return value.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
def _merge_content_search_rows(rows, keyword: str, chunk_section_ids: set[int]) -> list[dict]:
grouped: dict[tuple[int, int, int], dict] = {}
for row in rows:
key = (int(row["knowledge_id"]), int(row["version_id"]), int(row["section_id"]))
item_type = str(row["item_type"])
current = grouped.get(key)
if current is None:
current = _content_search_item(row, keyword, int(row["section_id"]) in chunk_section_ids)
current["itemTypes"] = []
grouped[key] = current
if item_type not in current["itemTypes"]:
current["itemTypes"].append(item_type)
for matched in _matched_in(str(row["title"] or ""), str(row["content"] or ""), keyword):
if matched not in current["matchedIn"]:
current["matchedIn"].append(matched)
for term in _highlight_terms(str(row["title"] or ""), str(row["content"] or ""), keyword):
if term not in current["highlightTerms"]:
current["highlightTerms"].append(term)
current["itemType"] = _primary_item_type(current["itemTypes"])
return list(grouped.values())
def _content_search_item(row, keyword: str, has_chunks: bool) -> dict:
title = str(row["title"] or "")
content = str(row["content"] or "")
is_active_current = (
int(row["knowledge_status"] or 0) == 1
and row["lifecycle_status"] == "active"
and row["source_status"] == "normal"
and bool(row["version_id"])
)
return {
"knowledgeId": row["knowledge_id"],
"knowledgeName": row["knowledge_name"],
"sourceTitle": row["source_title"],
"knowledgeType": row["knowledge_type"],
"knowledgeStatus": row["knowledge_status"],
"lifecycleStatus": row["lifecycle_status"],
"sourceStatus": row["source_status"],
"versionId": row["version_id"],
"versionNo": row["version_no"],
"publishedAt": row["published_at"],
"itemType": row["item_type"],
"itemId": row["item_id"],
"sectionId": row["section_id"],
"sectionKey": row["section_key"],
"title": title,
"snippet": _snippet(f"{title}\n{content}", keyword),
"matchedIn": _matched_in(title, content, keyword),
"highlightTerms": _highlight_terms(title, content, keyword),
"itemTypes": [row["item_type"]],
"hasChunks": has_chunks,
"isCurrentVersion": True,
"canAgentUse": is_active_current and has_chunks,
}
def _primary_item_type(item_types: list[str]) -> str:
for item_type in ("section", "card", "chunk"):
if item_type in item_types:
return item_type
return item_types[0] if item_types else "section"
def _matched_in(title: str, content: str, keyword: str) -> list[str]:
result: list[str] = []
lowered = keyword.lower()
if lowered in title.lower():
result.append("title")
if lowered in content.lower():
result.append("content")
return result or ["content"]
def _highlight_terms(title: str, content: str, keyword: str) -> list[str]:
keyword = keyword.strip()
if not keyword:
return []
text = f"{title}\n{content}".lower()
terms = [keyword]
if keyword.lower() not in text:
terms.extend(_keyword_parts(keyword))
result: list[str] = []
for term in sorted({item.strip() for item in terms if item.strip()}, key=len, reverse=True):
if term.lower() in text and term not in result:
result.append(term)
return result or [keyword]
def _keyword_parts(keyword: str) -> list[str]:
parts = [part for part in keyword.replace("", " ").replace(",", " ").split() if part]
if parts:
return parts
return list(keyword) if len(keyword) <= 8 else []
def _snippet(text: str, keyword: str, radius: int = 90) -> str:
compact = " ".join(text.split())
if not compact:
return ""
index = compact.lower().find(keyword.lower())
if index < 0:
return compact[: radius * 2] + ("..." if len(compact) > radius * 2 else "")
start = max(0, index - radius)
end = min(len(compact), index + len(keyword) + radius)
prefix = "..." if start > 0 else ""
suffix = "..." if end < len(compact) else ""
return f"{prefix}{compact[start:end]}{suffix}"

View File

@@ -0,0 +1,120 @@
from __future__ import annotations
import json
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy import select
from sqlalchemy.orm import Session, selectinload
from app.core.database import get_db
from app.core.dependencies import get_current_admin
from app.core.responses import api_success
from app.core.security import hash_password, verify_password
from app.models.admin import Admin, Role
from app.schemas.admin import AdminPasswordChangeRequest, ManagedAdminCreateRequest, ManagedAdminUpdateRequest
from app.services.admin_permission_service import ALL_PERMISSION_CODES, PERMISSION_TREE, permissions_for, require_permission
router = APIRouter()
def _serialize(admin: Admin) -> dict:
return {
"id": admin.id, "username": admin.username, "name": admin.name, "status": admin.status,
"isSuperAdmin": bool(admin.is_super_admin), "mustChangePassword": bool(admin.must_change_password),
"permissions": sorted(permissions_for(admin)), "lastLoginAt": admin.last_login_at,
"createdAt": admin.created_at,
}
def _validated_permissions(values: list[str]) -> list[str]:
unknown = set(values) - ALL_PERMISSION_CODES
if unknown:
raise HTTPException(status_code=422, detail=f"包含未知权限:{', '.join(sorted(unknown))}")
return sorted(set(values))
def _require_super(admin: Admin) -> None:
if not admin.is_super_admin:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="仅超级管理员可管理管理员账号")
@router.get("/permission-tree")
def permission_tree(current: Admin = Depends(get_current_admin)) -> dict:
_require_super(current)
return api_success(PERMISSION_TREE)
@router.get("/administrator/list")
def list_administrators(db: Session = Depends(get_db), current: Admin = Depends(get_current_admin)) -> dict:
_require_super(current)
rows = db.scalars(select(Admin).options(selectinload(Admin.role)).order_by(Admin.is_super_admin.desc(), Admin.id)).all()
return api_success([_serialize(item) for item in rows])
@router.post("/administrator")
def create_administrator(payload: ManagedAdminCreateRequest, db: Session = Depends(get_db), current: Admin = Depends(get_current_admin)) -> dict:
_require_super(current)
if db.scalar(select(Admin.id).where(Admin.username == payload.username.strip())):
raise HTTPException(status_code=409, detail="管理员账号已存在")
permissions = _validated_permissions(payload.permissions)
role = Role(code=f"admin_{payload.username.strip().lower()}", name=f"{payload.name.strip()}的权限", permissions=json.dumps(permissions))
db.add(role)
db.flush()
item = Admin(username=payload.username.strip(), name=payload.name.strip(), password=hash_password(payload.initialPassword), role_id=role.id, status=payload.status, must_change_password=1, is_super_admin=0)
db.add(item)
db.commit()
db.refresh(item)
item.role = role
return api_success(_serialize(item))
@router.put("/administrator/{admin_id}")
def update_administrator(admin_id: int, payload: ManagedAdminUpdateRequest, db: Session = Depends(get_db), current: Admin = Depends(get_current_admin)) -> dict:
_require_super(current)
item = db.scalar(select(Admin).options(selectinload(Admin.role)).where(Admin.id == admin_id))
if item is None:
raise HTTPException(status_code=404, detail="管理员不存在")
if item.is_super_admin:
raise HTTPException(status_code=403, detail="超级管理员账号由环境变量管理,不可修改")
item.name, item.status = payload.name.strip(), payload.status
if item.role is None:
item.role = Role(code=f"admin_{item.username.lower()}", name=f"{item.name}的权限")
item.role.name = f"{item.name}的权限"
item.role.permissions = json.dumps(_validated_permissions(payload.permissions))
if payload.resetPassword:
item.password = hash_password(payload.resetPassword)
item.must_change_password = 1
db.commit()
db.refresh(item)
return api_success(_serialize(item))
@router.delete("/administrator/{admin_id}")
def delete_administrator(admin_id: int, db: Session = Depends(get_db), current: Admin = Depends(get_current_admin)) -> dict:
_require_super(current)
item = db.scalar(select(Admin).options(selectinload(Admin.role)).where(Admin.id == admin_id))
if item is None:
raise HTTPException(status_code=404, detail="管理员不存在")
if item.is_super_admin:
raise HTTPException(status_code=403, detail="超级管理员不可删除")
role = item.role
db.delete(item)
db.flush()
if role is not None:
db.delete(role)
db.commit()
return api_success()
@router.post("/password")
def change_password(payload: AdminPasswordChangeRequest, db: Session = Depends(get_db), current: Admin = Depends(get_current_admin)) -> dict:
if current.is_super_admin:
raise HTTPException(status_code=403, detail="超级管理员密码由环境变量管理,不可在后台修改")
if not verify_password(payload.currentPassword, current.password):
raise HTTPException(status_code=400, detail="当前密码不正确")
if payload.currentPassword == payload.newPassword:
raise HTTPException(status_code=400, detail="新密码不能与当前密码相同")
current.password = hash_password(payload.newPassword)
current.must_change_password = 0
db.commit()
return api_success()

View File

@@ -7,18 +7,29 @@ from io import StringIO
from typing import Any
from fastapi import APIRouter, Depends, HTTPException, Query, status
from fastapi.responses import Response
from fastapi.responses import Response, StreamingResponse
from sqlalchemy import exists, func, or_, select
from sqlalchemy.orm import Session, load_only
from app.core.database import get_db
from app.core.dependencies import get_current_admin
from app.core.responses import api_success
from app.core.time_utils import as_utc_naive, to_business_naive
from app.models.admin import Admin
from app.models.chat import ChatMessage, ChatSession
from app.models.chat import ChatMessage, ChatSession, TopicSession
from app.models.growth import ShareDraft, TeacherHelpCard, TopicSummary
from app.models.logs import AiRequestLog, OperationLog
from app.models.sso import SsoClient
from app.models.user import User
from app.api.pagination import page_result
from app.services.admin_service import OperationLogService
from app.services.question_insight_service import QuestionInsightService
from app.services.question_insight_export_service import QuestionInsightExportService
from app.services.growth_profile_service import topic_dict, topic_summary_dict
from app.services.help_card_service import help_card_dict
from app.services.share_draft_service import share_draft_dict
from app.services.chat_export_service import ChatExportService
from app.services.chat_message_navigation_service import ChatMessageNavigationService
router = APIRouter()
@@ -28,6 +39,8 @@ def chat_list(
keyword: str = Query(default=""),
userId: int | None = Query(default=None),
status: str = Query(default=""),
sourceType: str = Query(default="", pattern="^(|direct|sso)$"),
sourceClientId: int | None = Query(default=None, gt=0),
dateFrom: datetime | None = Query(default=None),
dateTo: datetime | None = Query(default=None),
page: int = Query(default=1, ge=1),
@@ -35,10 +48,21 @@ def chat_list(
db: Session = Depends(get_db),
current_admin: Admin = Depends(get_current_admin),
) -> dict:
query = _chat_query(keyword=keyword, user_id=userId, status=status, date_from=dateFrom, date_to=dateTo)
dateFrom = as_utc_naive(dateFrom) if dateFrom is not None else None
dateTo = as_utc_naive(dateTo) if dateTo is not None else None
query = _chat_query(
keyword=keyword,
user_id=userId,
status=status,
source_type=sourceType,
source_client_id=sourceClientId,
date_from=dateFrom,
date_to=dateTo,
)
total = db.scalar(select(func.count()).select_from(query.order_by(None).subquery())) or 0
rows = db.execute(query.order_by(ChatSession.updated_at.desc()).offset((page - 1) * pageSize).limit(pageSize)).all()
return api_success(page_result([_chat_row_dict(session, user) for session, user in rows], total=total, page=page, page_size=pageSize))
items = [_chat_row_dict(session, user, source_client) for session, user, source_client in rows]
return api_success(page_result(items, total=total, page=page, page_size=pageSize))
@router.get("/chat/export")
@@ -46,30 +70,43 @@ def export_chats(
keyword: str = Query(default=""),
userId: int | None = Query(default=None),
status: str = Query(default=""),
sourceType: str = Query(default="", pattern="^(|direct|sso)$"),
sourceClientId: int | None = Query(default=None, gt=0),
dateFrom: datetime | None = Query(default=None),
dateTo: datetime | None = Query(default=None),
db: Session = Depends(get_db),
current_admin: Admin = Depends(get_current_admin),
) -> Response:
dateFrom = as_utc_naive(dateFrom) if dateFrom is not None else None
dateTo = as_utc_naive(dateTo) if dateTo is not None else None
rows = db.execute(
_chat_query(keyword=keyword, user_id=userId, status=status, date_from=dateFrom, date_to=dateTo)
_chat_query(
keyword=keyword,
user_id=userId,
status=status,
source_type=sourceType,
source_client_id=sourceClientId,
date_from=dateFrom,
date_to=dateTo,
)
.order_by(ChatSession.updated_at.desc())
.limit(1000)
).all()
output = StringIO()
writer = csv.writer(output)
writer.writerow(["会话ID", "用户ID", "手机号", "姓名", "标题", "消息数", "最后消息时间", "更新时间"])
for session, user in rows:
writer.writerow(["会话ID", "用户ID", "手机号", "姓名", "来源", "标题", "消息数", "最后消息时间", "更新时间"])
for session, user, source_client in rows:
writer.writerow(
[
session.id,
session.user_id,
user.phone if user else "",
user.name if user else "",
source_client.name if source_client else "千问千答直接访问",
session.title,
session.message_count,
session.last_message_at,
session.updated_at,
to_business_naive(session.last_message_at),
to_business_naive(session.updated_at),
]
)
content = "\ufeff" + output.getvalue()
@@ -80,15 +117,86 @@ def export_chats(
)
@router.get("/chat/{session_id}/messages")
def chat_messages(
session_id: int,
keyword: str = Query(default="", max_length=100),
page: int = Query(default=1, ge=1),
pageSize: int = Query(default=20, ge=10, le=100),
db: Session = Depends(get_db),
current_admin: Admin = Depends(get_current_admin),
) -> dict:
exists_session = db.scalar(select(ChatSession.id).where(ChatSession.id == session_id))
if exists_session is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="会话不存在")
normalized_keyword = keyword.strip()
message_query = select(ChatMessage).where(ChatMessage.session_id == session_id)
if normalized_keyword:
message_query = message_query.where(ChatMessage.content.contains(normalized_keyword, autoescape=True))
total = db.scalar(select(func.count()).select_from(message_query.subquery())) or 0
messages = db.scalars(
message_query
.order_by(ChatMessage.created_at.asc(), ChatMessage.id.asc())
.offset((page - 1) * pageSize)
.limit(pageSize)
).all()
return api_success(page_result([_message_dict(item) for item in messages], total=total, page=page, page_size=pageSize))
@router.get("/chat/{session_id}/export")
def export_chat_detail(
session_id: int,
db: Session = Depends(get_db),
current_admin: Admin = Depends(get_current_admin),
) -> StreamingResponse:
session_row = db.execute(
select(ChatSession, User, SsoClient)
.join(User, User.id == ChatSession.user_id, isouter=True)
.join(SsoClient, SsoClient.id == ChatSession.source_client_id, isouter=True)
.where(ChatSession.id == session_id)
).first()
if session_row is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="会话不存在")
session, user, source_client = session_row
messages = db.scalars(
select(ChatMessage)
.where(ChatMessage.session_id == session_id)
.order_by(ChatMessage.created_at.asc(), ChatMessage.id.asc())
).all()
stream = ChatExportService.build(
session=session,
user=user,
source_name=source_client.name if source_client else "千问千答直接访问",
messages=messages,
)
OperationLogService.write(
db,
admin_id=current_admin.id,
module="records",
action="export_chat_detail",
target_id=session.id,
)
db.commit()
return StreamingResponse(
stream,
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
headers={"Content-Disposition": f'attachment; filename="chat_session_{session.id}.xlsx"'},
)
@router.get("/chat/{session_id}")
def chat_detail(
session_id: int,
messagePage: int = Query(default=1, ge=1),
messagePageSize: int = Query(default=20, ge=10, le=100),
focusMessageId: int | None = Query(default=None, gt=0),
db: Session = Depends(get_db),
current_admin: Admin = Depends(get_current_admin),
) -> dict:
session_row = db.execute(
select(ChatSession, User)
select(ChatSession, User, SsoClient)
.join(User, User.id == ChatSession.user_id, isouter=True)
.join(SsoClient, SsoClient.id == ChatSession.source_client_id, isouter=True)
.where(ChatSession.id == session_id)
).first()
if session_row is None:
@@ -96,22 +204,65 @@ def chat_detail(
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="会话不存在")
session, user = session_row
session, user, source_client = session_row
message_query = select(ChatMessage).where(ChatMessage.session_id == session_id)
message_total = db.scalar(select(func.count()).select_from(message_query.subquery())) or 0
if focusMessageId is not None:
focused_message = db.scalar(
select(ChatMessage).where(
ChatMessage.id == focusMessageId,
ChatMessage.session_id == session_id,
)
)
if focused_message is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="触发消息不属于该会话或已不存在")
messagePage = ChatMessageNavigationService.page_for_message(
db,
session_id=session_id,
message=focused_message,
page_size=messagePageSize,
)
messages = db.scalars(
select(ChatMessage)
.where(ChatMessage.session_id == session_id)
message_query
.order_by(ChatMessage.created_at.asc(), ChatMessage.id.asc())
.offset((messagePage - 1) * messagePageSize)
.limit(messagePageSize)
).all()
ai_logs = db.scalars(
select(AiRequestLog)
.where(AiRequestLog.session_id == session_id)
.order_by(AiRequestLog.created_at.asc(), AiRequestLog.id.asc())
).all()
topics = _topic_rows(db, session_id)
topic_ids = [item["id"] for item in topics]
help_cards = []
share_drafts = []
if topic_ids:
help_cards = list(
db.scalars(
select(TeacherHelpCard)
.where(TeacherHelpCard.topic_session_id.in_(topic_ids))
.order_by(TeacherHelpCard.created_at.desc(), TeacherHelpCard.id.desc())
)
)
share_drafts = list(
db.scalars(
select(ShareDraft)
.where(ShareDraft.topic_session_id.in_(topic_ids))
.order_by(ShareDraft.created_at.desc(), ShareDraft.id.desc())
)
)
return api_success(
{
"session": _chat_row_dict(session, user),
"session": _chat_row_dict(session, user, source_client),
"messages": [_message_dict(item) for item in messages],
"aiLogs": [_ai_log_dict(item, include_prompt=True) for item in ai_logs],
"messagesPage": page_result(
[_message_dict(item) for item in messages],
total=message_total,
page=messagePage,
page_size=messagePageSize,
),
# Large prompts and retrieval chunks are loaded asynchronously by the drawer.
"aiLogs": [],
"topics": topics,
"helpCards": [help_card_dict(item) for item in help_cards],
"shareDrafts": [share_draft_dict(item) for item in share_drafts],
"focusedMessageId": focusMessageId,
}
)
@@ -171,7 +322,9 @@ def ai_logs(
AiRequestLog.id, AiRequestLog.session_id, AiRequestLog.message_id, AiRequestLog.user_id,
AiRequestLog.model_name, AiRequestLog.knowledge_ids, AiRequestLog.retrieve_count,
AiRequestLog.input_token, AiRequestLog.output_token, AiRequestLog.total_token,
AiRequestLog.cost_ms, AiRequestLog.status, AiRequestLog.error_message, AiRequestLog.created_at,
AiRequestLog.cost_ms, AiRequestLog.estimated_cost, AiRequestLog.currency,
AiRequestLog.route_reason, AiRequestLog.question_type, AiRequestLog.knowledge_hit,
AiRequestLog.status, AiRequestLog.error_message, AiRequestLog.created_at,
)).order_by(AiRequestLog.created_at.desc()).offset((page - 1) * pageSize).limit(pageSize)
).all()
return api_success(page_result([_ai_log_dict(item, include_chunks=False) for item in logs], total=total, page=page, page_size=pageSize))
@@ -189,25 +342,125 @@ def ai_log_detail(
return api_success(_ai_log_dict(log, include_prompt=True))
@router.get("/question-insights/summary")
def question_insights(
dateFrom: datetime | None = Query(default=None),
dateTo: datetime | None = Query(default=None),
minCount: int = Query(default=2, ge=1, le=50),
maxMessages: int = Query(default=5000, ge=100, le=20000),
page: int = Query(default=1, ge=1),
pageSize: int = Query(default=20, ge=10, le=50),
db: Session = Depends(get_db),
current_admin: Admin = Depends(get_current_admin),
) -> dict:
dateFrom = as_utc_naive(dateFrom) if dateFrom is not None else None
dateTo = as_utc_naive(dateTo) if dateTo is not None else None
return api_success(
QuestionInsightService.summarize(
db,
date_from=dateFrom,
date_to=dateTo,
min_count=minCount,
max_messages=maxMessages,
page=page,
page_size=pageSize,
)
)
@router.post("/question-insights/refresh")
def refresh_question_insights(
dateFrom: datetime | None = Query(default=None),
dateTo: datetime | None = Query(default=None),
maxMessages: int = Query(default=5000, ge=100, le=20000),
db: Session = Depends(get_db),
current_admin: Admin = Depends(get_current_admin),
) -> dict:
dateFrom = as_utc_naive(dateFrom) if dateFrom is not None else None
dateTo = as_utc_naive(dateTo) if dateTo is not None else None
result = QuestionInsightService.refresh(
db,
date_from=dateFrom,
date_to=dateTo,
max_messages=maxMessages,
)
OperationLogService.write(
db,
admin_id=current_admin.id,
module="question_insight",
action=f"refresh:{result['processedMessages']}",
)
db.commit()
return api_success(result)
@router.get("/question-insights/export")
def export_question_insights(
dateFrom: datetime | None = Query(default=None),
dateTo: datetime | None = Query(default=None),
minCount: int = Query(default=2, ge=1, le=50),
maxMessages: int = Query(default=5000, ge=100, le=20000),
db: Session = Depends(get_db),
current_admin: Admin = Depends(get_current_admin),
) -> StreamingResponse:
dateFrom = as_utc_naive(dateFrom) if dateFrom is not None else None
dateTo = as_utc_naive(dateTo) if dateTo is not None else None
if dateFrom is not None and dateTo is not None and dateFrom > dateTo:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="开始时间不能晚于结束时间")
result = QuestionInsightService.summarize(
db,
date_from=dateFrom,
date_to=dateTo,
min_count=minCount,
max_messages=maxMessages,
page=1,
page_size=maxMessages,
)
OperationLogService.write(
db,
admin_id=current_admin.id,
module="question_insight",
action=f"export:{result['total']}",
)
db.commit()
filename = f"question_insights_{_export_date_label(dateFrom)}_{_export_date_label(dateTo)}.xlsx"
return StreamingResponse(
QuestionInsightExportService.build(result),
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
)
def _export_date_label(value: datetime | None) -> str:
return value.strftime("%Y%m%d") if value is not None else "all"
def _chat_query(
*,
keyword: str,
user_id: int | None,
status: str,
source_type: str,
source_client_id: int | None,
date_from: datetime | None,
date_to: datetime | None,
):
query = (
select(ChatSession, User)
select(ChatSession, User, SsoClient)
.join(User, User.id == ChatSession.user_id, isouter=True)
.join(SsoClient, SsoClient.id == ChatSession.source_client_id, isouter=True)
.where(ChatSession.is_deleted == 0)
)
if user_id is not None:
query = query.where(ChatSession.user_id == user_id)
if source_type:
query = query.where(ChatSession.source_type == source_type)
if source_client_id is not None:
query = query.where(ChatSession.source_type == "sso", ChatSession.source_client_id == source_client_id)
if date_from is not None:
query = query.where(ChatSession.updated_at >= date_from.replace(tzinfo=None))
query = query.where(ChatSession.updated_at >= date_from)
if date_to is not None:
query = query.where(ChatSession.updated_at <= date_to.replace(tzinfo=None))
query = query.where(ChatSession.updated_at <= date_to)
if status:
query = query.where(
exists()
@@ -229,12 +482,15 @@ def _chat_query(
return query
def _chat_row_dict(session: ChatSession, user: User | None) -> dict:
def _chat_row_dict(session: ChatSession, user: User | None, source_client: SsoClient | None = None) -> dict:
return {
"id": session.id,
"userId": session.user_id,
"userPhone": user.phone if user else "",
"userName": user.name if user else "",
"sourceType": session.source_type,
"sourceClientId": session.source_client_id,
"sourceName": source_client.name if source_client else "千问千答直接访问",
"title": session.title,
"messageCount": session.message_count,
"lastMessageAt": session.last_message_at,
@@ -246,6 +502,7 @@ def _message_dict(message: ChatMessage) -> dict:
return {
"id": message.id,
"sessionId": message.session_id,
"topicSessionId": message.topic_session_id,
"userId": message.user_id,
"role": message.role,
"content": message.content,
@@ -258,6 +515,22 @@ def _message_dict(message: ChatMessage) -> dict:
}
def _topic_rows(db: Session, session_id: int) -> list[dict]:
rows = db.execute(
select(TopicSession, TopicSummary)
.join(TopicSummary, TopicSummary.topic_session_id == TopicSession.id, isouter=True)
.where(TopicSession.chat_session_id == session_id)
.order_by(TopicSession.started_at.asc(), TopicSession.id.asc())
).all()
return [
{
**topic_dict(topic),
"summary": topic_summary_dict(summary) if summary else None,
}
for topic, summary in rows
]
def _ai_log_dict(log: AiRequestLog, *, include_prompt: bool = False, include_chunks: bool = True) -> dict:
data = {
"id": log.id,
@@ -272,6 +545,11 @@ def _ai_log_dict(log: AiRequestLog, *, include_prompt: bool = False, include_chu
"outputToken": log.output_token,
"totalToken": log.total_token,
"costMs": log.cost_ms,
"estimatedCost": float(log.estimated_cost) if log.estimated_cost is not None else None,
"currency": log.currency,
"routeReason": log.route_reason,
"questionType": log.question_type,
"knowledgeHit": bool(log.knowledge_hit),
"status": log.status,
"errorMessage": log.error_message,
"createdAt": log.created_at,

View File

@@ -1,6 +1,11 @@
from __future__ import annotations
import json
from collections.abc import AsyncIterator
from fastapi import APIRouter, Depends, HTTPException, Query, status
from fastapi.encoders import jsonable_encoder
from fastapi.responses import StreamingResponse
from sqlalchemy import func, select
from sqlalchemy.orm import Session
@@ -13,17 +18,22 @@ from app.models.ai_config import ModelConfig, Prompt, SystemConfig
from app.models.knowledge import Knowledge
from app.schemas.admin import (
AgentDebugRequest,
AgentRuntimeConfigSaveRequest,
DefaultModelRequest,
EnableModelRequest,
ModelSaveRequest,
PromptSaveRequest,
SystemConfigSaveRequest,
)
from app.services.admin_service import OperationLogService
from app.services.agent_debug_service import AgentDebugService
from app.services.feishu_service import FeishuKnowledgeService
from app.services.knowledge_agent_service import KnowledgeAgentService
from app.services.knowledge_service import KnowledgeScope
from app.services.model_service import ModelClientService
from app.services.rag_service import PromptService, RagResult
from app.services.public_site_config_service import PublicSiteConfigService
from app.services.admin_permission_service import require_permission
from app.services.reasoning_policy_service import ReasoningPolicyService
from app.services.response_style_service import ResponseStyleService
from app.services.secret_service import MASKED_SECRET, SENSITIVE_CONFIG_KEYS, SecretService
router = APIRouter()
@@ -145,37 +155,11 @@ async def debug_agent(
if model is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="模型不存在")
rag_result = await KnowledgeAgentService.build_result(
db,
question=payload.question,
version_overrides=payload.knowledgeVersions,
preview_knowledge_ids=payload.knowledgeIds,
)
debug_messages = [
{"role": "system", "content": payload.promptContent.strip()},
*(rag_result.messages or []),
]
rag_result = RagResult(
question=rag_result.question,
knowledge_scopes=rag_result.knowledge_scopes,
chunks=rag_result.chunks,
prompt=PromptService.render_messages(debug_messages),
allow_general_knowledge=rag_result.allow_general_knowledge,
retrieval_log_id=rag_result.retrieval_log_id,
tool_trace=rag_result.tool_trace,
messages=debug_messages,
)
rag_result = await AgentDebugService.build_result(db, payload)
result = await ModelClientService.debug_model_async(
model,
rag_result,
{
"temperature": payload.temperature,
"top_p": payload.topP,
"top_k": payload.topK,
"presence_penalty": payload.presencePenalty,
"frequency_penalty": payload.frequencyPenalty,
"max_token": payload.maxToken,
},
AgentDebugService.overrides(payload),
)
result["retrievalTrace"] = rag_result.tool_trace or []
result["retrievalLogId"] = rag_result.retrieval_log_id
@@ -184,9 +168,94 @@ async def debug_agent(
return api_success(result)
@router.post("/agent/debug/stream")
def debug_agent_stream(
payload: AgentDebugRequest,
db: Session = Depends(get_db),
current_admin: Admin = Depends(get_current_admin),
) -> StreamingResponse:
return StreamingResponse(
_debug_agent_stream(payload, db, current_admin),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache, no-transform",
"Connection": "keep-alive",
"X-Accel-Buffering": "no",
},
)
async def _debug_agent_stream(
payload: AgentDebugRequest,
db: Session,
current_admin: Admin,
) -> AsyncIterator[str]:
async for event in AgentDebugService.stream(payload, db, current_admin):
yield _admin_sse_event(event)
yield _admin_sse_done()
def _admin_sse_event(event: dict) -> str:
event = jsonable_encoder(event)
return f"data: {json.dumps(event, ensure_ascii=False)}\n\n"
def _admin_sse_done() -> str:
return "data: [DONE]\n\n"
@router.get("/agent/runtime-config")
def get_agent_runtime_config(
db: Session = Depends(get_db),
current_admin: Admin = Depends(get_current_admin),
) -> dict:
model = _enabled_model(db)
return api_success(_agent_runtime_config_dict(model, ReasoningPolicyService.is_visible(db), ResponseStyleService.get_depth(db)))
@router.put("/agent/runtime-config")
def save_agent_runtime_config(
payload: AgentRuntimeConfigSaveRequest,
db: Session = Depends(get_db),
current_admin: Admin = Depends(get_current_admin),
) -> dict:
model = _enabled_model(db)
if model is None:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="当前没有已启用模型,请先在模型管理中启用一个模型",
)
model.temperature = payload.temperature
model.top_p = payload.topP
model.top_k = payload.topK
model.presence_penalty = payload.presencePenalty
model.frequency_penalty = payload.frequencyPenalty
model.max_token = payload.maxToken
model.stream_enabled = payload.streamEnabled
db.add(model)
ReasoningPolicyService.set_visible(db, payload.reasoningVisible == 1, current_admin.id)
ResponseStyleService.set_depth(db, payload.responseDepth, current_admin.id)
OperationLogService.write(
db,
admin_id=current_admin.id,
module="agent",
action="save_runtime_config",
target_id=model.id,
)
db.commit()
db.refresh(model)
return api_success(_agent_runtime_config_dict(model, ReasoningPolicyService.is_visible(db), ResponseStyleService.get_depth(db)))
@router.get("/model/list")
def list_models(db: Session = Depends(get_db), current_admin: Admin = Depends(get_current_admin)) -> dict:
models = db.scalars(select(ModelConfig).order_by(ModelConfig.id.desc())).all()
models = db.scalars(
select(ModelConfig).order_by(
ModelConfig.is_default.desc(),
ModelConfig.enabled.desc(),
ModelConfig.id.desc(),
)
).all()
return api_success([_model_dict(model) for model in models])
@@ -220,7 +289,17 @@ def create_model(
extra_params=payload.extraParams,
remark=payload.remark,
timeout_second=payload.timeoutSecond,
input_price_per_1k=payload.inputPricePer1k,
output_price_per_1k=payload.outputPricePer1k,
currency=payload.currency or "CNY",
usage_scenarios=payload.usageScenarios,
allow_summary=payload.allowSummary,
allow_report=payload.allowReport,
allow_fixed_info=payload.allowFixedInfo,
allow_simple_knowledge=payload.allowSimpleKnowledge,
allow_deep_chat=payload.allowDeepChat,
enabled=0,
is_default=0,
)
db.add(model)
db.flush()
@@ -263,6 +342,15 @@ def update_model(
model.extra_params = payload.extraParams
model.remark = payload.remark
model.timeout_second = payload.timeoutSecond
model.input_price_per_1k = payload.inputPricePer1k
model.output_price_per_1k = payload.outputPricePer1k
model.currency = payload.currency or "CNY"
model.usage_scenarios = payload.usageScenarios
model.allow_summary = payload.allowSummary
model.allow_report = payload.allowReport
model.allow_fixed_info = payload.allowFixedInfo
model.allow_simple_knowledge = payload.allowSimpleKnowledge
model.allow_deep_chat = payload.allowDeepChat
db.add(model)
OperationLogService.write(db, admin_id=current_admin.id, module="model", action="update", target_id=model.id)
db.commit()
@@ -275,14 +363,46 @@ def enable_model(
payload: EnableModelRequest,
db: Session = Depends(get_db),
current_admin: Admin = Depends(get_current_admin),
) -> dict:
target = db.get(ModelConfig, payload.modelId)
if target is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="模型不存在")
if payload.enabled == 0 and target.is_default == 1:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="默认主模型不能直接停用,请先设置另一个默认主模型",
)
target.enabled = payload.enabled
if payload.enabled == 1 and _explicit_default_model(db) is None:
target.is_default = 1
db.add(target)
action = "enable" if payload.enabled == 1 else "disable"
OperationLogService.write(db, admin_id=current_admin.id, module="model", action=action, target_id=target.id)
db.commit()
return api_success()
@router.post("/model/default")
def set_default_model(
payload: DefaultModelRequest,
db: Session = Depends(get_db),
current_admin: Admin = Depends(get_current_admin),
) -> dict:
target = db.get(ModelConfig, payload.modelId)
if target is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="模型不存在")
for model in db.scalars(select(ModelConfig)).all():
model.enabled = 1 if model.id == target.id else 0
model.is_default = 1 if model.id == target.id else 0
if model.id == target.id:
model.enabled = 1
db.add(model)
OperationLogService.write(db, admin_id=current_admin.id, module="model", action="enable", target_id=target.id)
OperationLogService.write(
db,
admin_id=current_admin.id,
module="model",
action="set_default",
target_id=target.id,
)
db.commit()
return api_success()
@@ -296,6 +416,11 @@ def delete_model(
model = db.get(ModelConfig, model_id)
if model is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="模型不存在")
if model.is_default == 1:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="不能删除默认主模型,请先设置另一个默认主模型",
)
db.delete(model)
OperationLogService.write(db, admin_id=current_admin.id, module="model", action="delete", target_id=model.id)
db.commit()
@@ -323,12 +448,37 @@ def list_config(db: Session = Depends(get_db), current_admin: Admin = Depends(ge
return api_success([_config_dict(config) for config in configs])
@router.get("/config/{config_key}/secret")
def reveal_config_secret(
config_key: str,
db: Session = Depends(get_db),
current_admin: Admin = Depends(get_current_admin),
) -> dict:
require_permission(current_admin, "configs.view")
if config_key not in SENSITIVE_CONFIG_KEYS:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="该配置不是可查看的敏感配置")
config = db.scalar(select(SystemConfig).where(SystemConfig.config_key == config_key))
if config is None or not config.config_value:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="该配置尚未填写")
value = SecretService.decrypt(config.config_value)
OperationLogService.write(
db,
admin_id=current_admin.id,
module="config",
action="reveal_secret",
target_id=config.id,
)
db.commit()
return api_success({"configKey": config_key, "configValue": value})
@router.put("/config")
def save_config(
payload: SystemConfigSaveRequest,
db: Session = Depends(get_db),
current_admin: Admin = Depends(get_current_admin),
) -> dict:
payload.configValue = PublicSiteConfigService.normalize_admin_value(payload.configKey, payload.configValue)
config = db.scalar(select(SystemConfig).where(SystemConfig.config_key == payload.configKey))
if config is None:
config = SystemConfig(config_key=payload.configKey, config_value=payload.configValue)
@@ -373,6 +523,45 @@ def _model_dict(model: ModelConfig) -> dict:
"remark": model.remark,
"timeoutSecond": model.timeout_second,
"enabled": model.enabled,
"isDefault": model.is_default,
"inputPricePer1k": float(model.input_price_per_1k) if model.input_price_per_1k is not None else None,
"outputPricePer1k": float(model.output_price_per_1k) if model.output_price_per_1k is not None else None,
"currency": model.currency,
"usageScenarios": model.usage_scenarios,
"allowSummary": model.allow_summary,
"allowReport": model.allow_report,
"allowFixedInfo": model.allow_fixed_info,
"allowSimpleKnowledge": model.allow_simple_knowledge,
"allowDeepChat": model.allow_deep_chat,
}
def _enabled_model(db: Session) -> ModelConfig | None:
return ModelClientService._get_enabled_model(db)
def _explicit_default_model(db: Session) -> ModelConfig | None:
return db.scalar(
select(ModelConfig)
.where(ModelConfig.enabled == 1, ModelConfig.is_default == 1)
.order_by(ModelConfig.id.desc())
.limit(1)
)
def _agent_runtime_config_dict(model: ModelConfig | None, reasoning_visible: bool = False, response_depth: int = 35) -> dict:
return {
"modelId": model.id if model is not None else None,
"modelName": (model.display_name or model.model_name) if model is not None else None,
"temperature": float(model.temperature) if model is not None and model.temperature is not None else None,
"topP": float(model.top_p) if model is not None and model.top_p is not None else None,
"topK": model.top_k if model is not None else None,
"presencePenalty": float(model.presence_penalty) if model is not None and model.presence_penalty is not None else None,
"frequencyPenalty": float(model.frequency_penalty) if model is not None and model.frequency_penalty is not None else None,
"maxToken": model.max_token if model is not None and model.max_token is not None else 8192,
"streamEnabled": model.stream_enabled if model is not None else 1,
"reasoningVisible": 1 if reasoning_visible else 0,
"responseDepth": response_depth,
}

View File

@@ -0,0 +1,325 @@
from __future__ import annotations
import json
import secrets
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy import delete, func, or_, select
from sqlalchemy.orm import Session
from app.api.pagination import page_result
from app.core.database import get_db
from app.core.dependencies import get_current_admin
from app.core.responses import api_success
from app.models.admin import Admin
from app.models.ai_config import SystemConfig
from app.models.entitlement import EntitlementPlan
from app.models.sso import SsoClient, SsoLoginAudit, UserExternalIdentity
from app.models.user import User
from app.schemas.sso import SsoClientSaveRequest, SsoClientUpdateRequest, SsoPublicConfigRequest
from app.services.admin_service import OperationLogService
from app.services.secret_service import SecretService
from app.services.sso_service import _json_list
router = APIRouter()
@router.get("/sso/config")
def get_sso_config(
db: Session = Depends(get_db),
current_admin: Admin = Depends(get_current_admin),
) -> dict:
value = db.scalar(
select(SystemConfig.config_value).where(SystemConfig.config_key == "sso_user_client_url")
)
return api_success({"userClientUrl": value or ""})
@router.put("/sso/config")
def save_sso_config(
payload: SsoPublicConfigRequest,
db: Session = Depends(get_db),
current_admin: Admin = Depends(get_current_admin),
) -> dict:
config = db.scalar(
select(SystemConfig).where(SystemConfig.config_key == "sso_user_client_url")
)
if config is None:
config = SystemConfig(
config_key="sso_user_client_url",
config_value=payload.userClientUrl,
description="其他应用完成免登录后进入的千问千答用户端公网地址。",
updated_by=current_admin.id,
)
db.add(config)
else:
config.config_value = payload.userClientUrl
config.updated_by = current_admin.id
OperationLogService.write(
db,
admin_id=current_admin.id,
module="sso",
action="save_public_url",
target_id=config.id,
)
db.commit()
return api_success({"userClientUrl": payload.userClientUrl})
@router.get("/sso/client/list")
def list_sso_clients(
db: Session = Depends(get_db),
current_admin: Admin = Depends(get_current_admin),
) -> dict:
identity_counts = (
select(
UserExternalIdentity.client_id.label("client_id"),
func.count(UserExternalIdentity.id).label("identity_count"),
)
.group_by(UserExternalIdentity.client_id)
.subquery()
)
rows = db.execute(
select(SsoClient, func.coalesce(identity_counts.c.identity_count, 0), EntitlementPlan.name)
.outerjoin(identity_counts, identity_counts.c.client_id == SsoClient.id)
.outerjoin(EntitlementPlan, EntitlementPlan.id == SsoClient.default_entitlement_plan_id)
.order_by(SsoClient.created_at.desc(), SsoClient.id.desc())
).all()
return api_success([_client_item(client, int(identity_count), plan_name) for client, identity_count, plan_name in rows])
@router.post("/sso/client")
def create_sso_client(
payload: SsoClientSaveRequest,
db: Session = Depends(get_db),
current_admin: Admin = Depends(get_current_admin),
) -> dict:
app_id = payload.appId.strip()
if db.scalar(select(SsoClient.id).where(SsoClient.app_id == app_id)) is not None:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="应用ID已存在")
_validate_default_plan(db, payload.allowAutoRegister, payload.defaultEntitlementPlanId)
plaintext_secret = secrets.token_urlsafe(32)
client = SsoClient(
app_id=app_id,
name=payload.name.strip(),
client_secret=SecretService.encrypt(plaintext_secret),
redirect_uris=json.dumps(payload.redirectUris, ensure_ascii=False),
allow_auto_register=1 if payload.allowAutoRegister else 0,
default_entitlement_plan_id=payload.defaultEntitlementPlanId,
status=payload.status,
created_by=current_admin.id,
)
db.add(client)
db.flush()
OperationLogService.write(
db,
admin_id=current_admin.id,
module="sso",
action="create_client",
target_id=client.id,
)
db.commit()
db.refresh(client)
return api_success({**_client_item(client, 0), "clientSecret": plaintext_secret})
@router.put("/sso/client/{client_id}")
def update_sso_client(
client_id: int,
payload: SsoClientUpdateRequest,
db: Session = Depends(get_db),
current_admin: Admin = Depends(get_current_admin),
) -> dict:
client = _require_client(db, client_id)
_validate_default_plan(db, payload.allowAutoRegister, payload.defaultEntitlementPlanId)
client.name = payload.name.strip()
client.redirect_uris = json.dumps(payload.redirectUris, ensure_ascii=False)
client.allow_auto_register = 1 if payload.allowAutoRegister else 0
client.default_entitlement_plan_id = payload.defaultEntitlementPlanId
client.status = payload.status
OperationLogService.write(
db,
admin_id=current_admin.id,
module="sso",
action="update_client",
target_id=client.id,
)
db.commit()
identity_count = db.scalar(
select(func.count(UserExternalIdentity.id)).where(UserExternalIdentity.client_id == client.id)
) or 0
plan_name = db.scalar(select(EntitlementPlan.name).where(EntitlementPlan.id == client.default_entitlement_plan_id))
return api_success(_client_item(client, identity_count, plan_name))
@router.post("/sso/client/{client_id}/secret/rotate")
def rotate_sso_client_secret(
client_id: int,
db: Session = Depends(get_db),
current_admin: Admin = Depends(get_current_admin),
) -> dict:
client = _require_client(db, client_id)
plaintext_secret = secrets.token_urlsafe(32)
client.client_secret = SecretService.encrypt(plaintext_secret)
OperationLogService.write(
db,
admin_id=current_admin.id,
module="sso",
action="rotate_secret",
target_id=client.id,
)
db.commit()
return api_success({"clientId": client.id, "clientSecret": plaintext_secret})
@router.get("/sso/identity/list")
def list_sso_identities(
clientId: int | None = Query(default=None, gt=0),
keyword: str = Query(default="", max_length=100),
page: int = Query(default=1, ge=1),
pageSize: int = Query(default=10, ge=10, le=100),
db: Session = Depends(get_db),
current_admin: Admin = Depends(get_current_admin),
) -> dict:
filters = []
if clientId:
filters.append(UserExternalIdentity.client_id == clientId)
if keyword.strip():
value = f"%{keyword.strip()}%"
filters.append(
or_(
UserExternalIdentity.external_user_id.ilike(value),
User.phone.ilike(value),
User.name.ilike(value),
SsoClient.name.ilike(value),
)
)
total = db.scalar(
select(func.count(UserExternalIdentity.id))
.join(SsoClient, SsoClient.id == UserExternalIdentity.client_id)
.join(User, User.id == UserExternalIdentity.user_id)
.where(*filters)
) or 0
rows = db.execute(
select(UserExternalIdentity, SsoClient, User)
.join(SsoClient, SsoClient.id == UserExternalIdentity.client_id)
.join(User, User.id == UserExternalIdentity.user_id)
.where(*filters)
.order_by(UserExternalIdentity.last_login_at.desc(), UserExternalIdentity.id.desc())
.offset((page - 1) * pageSize)
.limit(pageSize)
).all()
items = [_identity_item(identity, client, user) for identity, client, user in rows]
return api_success(page_result(items, total=total, page=page, page_size=pageSize))
@router.delete("/sso/identity/{identity_id}")
def delete_sso_identity(
identity_id: int,
db: Session = Depends(get_db),
current_admin: Admin = Depends(get_current_admin),
) -> dict:
identity = db.get(UserExternalIdentity, identity_id)
if identity is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="账号绑定不存在")
db.execute(delete(UserExternalIdentity).where(UserExternalIdentity.id == identity_id))
OperationLogService.write(
db,
admin_id=current_admin.id,
module="sso",
action="unlink_identity",
target_id=identity_id,
)
db.commit()
return api_success()
@router.get("/sso/audit/list")
def list_sso_audits(
clientId: int | None = Query(default=None, gt=0),
auditStatus: str = Query(default="", max_length=20),
page: int = Query(default=1, ge=1),
pageSize: int = Query(default=10, ge=10, le=100),
db: Session = Depends(get_db),
current_admin: Admin = Depends(get_current_admin),
) -> dict:
filters = []
if clientId:
filters.append(SsoLoginAudit.client_id == clientId)
if auditStatus:
filters.append(SsoLoginAudit.status == auditStatus.upper())
total = db.scalar(select(func.count(SsoLoginAudit.id)).where(*filters)) or 0
rows = db.execute(
select(SsoLoginAudit, SsoClient)
.outerjoin(SsoClient, SsoClient.id == SsoLoginAudit.client_id)
.where(*filters)
.order_by(SsoLoginAudit.created_at.desc(), SsoLoginAudit.id.desc())
.offset((page - 1) * pageSize)
.limit(pageSize)
).all()
items = [_audit_item(audit, client) for audit, client in rows]
return api_success(page_result(items, total=total, page=page, page_size=pageSize))
def _require_client(db: Session, client_id: int) -> SsoClient:
client = db.get(SsoClient, client_id)
if client is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="接入应用不存在")
return client
def _client_item(client: SsoClient, identity_count: int, plan_name: str | None = None) -> dict:
return {
"id": client.id,
"appId": client.app_id,
"name": client.name,
"redirectUris": _json_list(client.redirect_uris),
"allowAutoRegister": bool(client.allow_auto_register),
"defaultEntitlementPlanId": client.default_entitlement_plan_id,
"defaultEntitlementPlanName": plan_name,
"status": client.status,
"identityCount": identity_count,
"lastUsedAt": client.last_used_at,
"createdAt": client.created_at,
"updatedAt": client.updated_at,
}
def _validate_default_plan(db: Session, allow_auto_register: bool, plan_id: int | None) -> None:
if not allow_auto_register:
return
plan = db.get(EntitlementPlan, plan_id) if plan_id else None
if plan is None or plan.status != 1 or plan.plan_type == "teacher":
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="默认权益版本不存在或已停用")
def _identity_item(identity: UserExternalIdentity, client: SsoClient, user: User) -> dict:
return {
"id": identity.id,
"clientId": client.id,
"clientName": client.name,
"appId": client.app_id,
"userId": user.id,
"userName": user.name,
"phone": user.phone,
"externalUserId": identity.external_user_id,
"displayNameSnapshot": identity.display_name_snapshot,
"lastLoginAt": identity.last_login_at,
"createdAt": identity.created_at,
}
def _audit_item(audit: SsoLoginAudit, client: SsoClient | None) -> dict:
return {
"id": audit.id,
"appId": client.app_id if client else None,
"clientName": client.name if client else None,
"userId": audit.user_id,
"externalUserId": audit.external_user_id,
"action": audit.action,
"status": audit.status,
"errorMessage": audit.error_message,
"ip": audit.ip,
"createdAt": audit.created_at,
}

View File

@@ -0,0 +1,107 @@
from __future__ import annotations
from datetime import date
from fastapi import APIRouter, Depends, HTTPException, Query, status
from fastapi.responses import Response
from sqlalchemy.orm import Session
from app.core.database import get_db
from app.core.dependencies import get_current_admin
from app.core.responses import api_success
from app.models.admin import Admin
from app.services.admin_service import OperationLogService
from app.services.user_behavior_export_service import UserBehaviorExportService
from app.services.user_behavior_service import UserBehaviorService
router = APIRouter()
@router.get("/user-behavior/overview")
def behavior_overview(
start: date | None = Query(default=None),
end: date | None = Query(default=None),
db: Session = Depends(get_db),
_admin: Admin = Depends(get_current_admin),
) -> dict:
return api_success(UserBehaviorService.overview(db, start=start, end=end))
@router.get("/user-behavior/users")
def behavior_users(
start: date | None = Query(default=None),
end: date | None = Query(default=None),
keyword: str = Query(default="", max_length=50),
sortBy: str = Query(default="lastEventAt", pattern="^(userName|phone|eventCount|lastEventAt)$"),
sortOrder: str = Query(default="desc", pattern="^(asc|desc)$"),
page: int = Query(default=1, ge=1),
pageSize: int = Query(default=20, ge=5, le=100),
db: Session = Depends(get_db),
_admin: Admin = Depends(get_current_admin),
) -> dict:
return api_success(
UserBehaviorService.users(
db,
start=start,
end=end,
keyword=keyword,
page=page,
page_size=pageSize,
sort_by=sortBy,
sort_order=sortOrder,
)
)
@router.get("/user-behavior/export")
def export_behavior_report(
start: date | None = Query(default=None),
end: date | None = Query(default=None),
keyword: str = Query(default="", max_length=50),
sortBy: str = Query(default="lastEventAt", pattern="^(userName|phone|eventCount|lastEventAt)$"),
sortOrder: str = Query(default="desc", pattern="^(asc|desc)$"),
db: Session = Depends(get_db),
current_admin: Admin = Depends(get_current_admin),
) -> Response:
stream = UserBehaviorExportService.build_workbook(
db,
start=start,
end=end,
keyword=keyword,
sort_by=sortBy,
sort_order=sortOrder,
)
OperationLogService.write(db, admin_id=current_admin.id, module="behavior", action="export")
db.commit()
start_label = start.isoformat() if start else "default"
end_label = end.isoformat() if end else "today"
filename = f"user_behavior_report_{start_label}_{end_label}.xlsx"
return Response(
content=stream.getvalue(),
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
)
@router.get("/user-behavior/user/{user_id}/timeline")
def behavior_timeline(
user_id: int,
start: date | None = Query(default=None),
end: date | None = Query(default=None),
page: int = Query(default=1, ge=1),
pageSize: int = Query(default=50, ge=10, le=100),
db: Session = Depends(get_db),
_admin: Admin = Depends(get_current_admin),
) -> dict:
result = UserBehaviorService.timeline(
db,
user_id=user_id,
start=start,
end=end,
page=page,
page_size=pageSize,
)
if result["user"] is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="用户不存在")
return api_success(result)

View File

@@ -1,34 +1,55 @@
from __future__ import annotations
import re
from datetime import date, datetime
from dataclasses import replace
from datetime import UTC, date, datetime, timedelta
from io import BytesIO
from typing import Annotated
from fastapi import APIRouter, Depends, File, HTTPException, Query, UploadFile, status
from fastapi.responses import StreamingResponse
from openpyxl import Workbook, load_workbook
from pydantic import BaseModel
from pydantic import ValidationError
from sqlalchemy import func, select
from sqlalchemy import func, or_, select
from sqlalchemy.orm import Session
from app.core.database import get_db
from app.core.dependencies import get_current_admin
from app.core.responses import api_success
from app.core.time_utils import as_utc_naive, business_datetime_to_utc_naive, business_day_boundary
from app.models.admin import Admin
from app.models.ai_config import SystemConfig
from app.models.entitlement import EntitlementPlan, UserEntitlement
from app.models.chat import ChatMessage, ChatSession, TopicSession
from app.models.growth import ShareDraft, TeacherHelpCard, TopicSummary
from app.models.logs import AiRequestLog
from app.models.user import User
from app.schemas.admin import AdminUserCreateRequest, AdminUserImportItem, AdminUserImportRequest, AdminUserUpdateRequest
from app.services.admin_service import OperationLogService
from app.services.entitlement_service import EntitlementService, entitlement_dict, view_from_plan
from app.services.growth_profile_service import GrowthProfileService, growth_profile_dict, topic_dict, topic_summary_dict
from app.services.help_card_service import help_card_dict
from app.services.periodic_report_service import PeriodicReportService, periodic_report_dict
from app.services.share_draft_service import share_draft_dict
from app.api.pagination import page_result
router = APIRouter()
STUDENT_TEMPLATE_HEADERS = ["手机号", "姓名", "昵称", "每日聊天额度", "状态", "有效期"]
STUDENT_TEMPLATE_HEADERS = ["手机号", "姓名", "昵称", "每日聊天额度", "状态", "有效期", "权益名称"]
class AdminGenerateReportRequest(BaseModel):
reportType: str
periodStart: datetime | None = None
periodEnd: datetime | None = None
@router.get("/user/list")
def list_users(
keyword: str = Query(default=""),
planId: Annotated[int | None, Query(gt=0)] = None,
entitlementStatus: Annotated[str, Query(pattern="^(all|active|expiring7|expiring30|expired|default)$")] = "all",
page: int = Query(default=1, ge=1),
pageSize: int = Query(default=20, ge=10, le=100),
db: Session = Depends(get_db),
@@ -38,9 +59,55 @@ def list_users(
if keyword:
like = f"%{keyword}%"
query = query.where((User.phone.like(like)) | (User.name.like(like)))
now = datetime.now(UTC).replace(tzinfo=None)
active_user_ids = select(UserEntitlement.user_id).join(
EntitlementPlan, EntitlementPlan.id == UserEntitlement.plan_id
).where(
UserEntitlement.status == "active",
EntitlementPlan.status == 1,
EntitlementPlan.plan_type != "teacher",
(UserEntitlement.effective_at.is_(None)) | (UserEntitlement.effective_at <= now),
(UserEntitlement.expired_at.is_(None)) | (UserEntitlement.expired_at >= now),
)
if planId is not None:
query = query.where(User.id.in_(active_user_ids.where(UserEntitlement.plan_id == planId)))
if entitlementStatus == "active":
query = query.where(User.id.in_(active_user_ids))
elif entitlementStatus == "expiring7":
query = query.where(
User.id.in_(
active_user_ids.where(
UserEntitlement.expired_at.is_not(None),
UserEntitlement.expired_at <= now + timedelta(days=7),
)
)
)
elif entitlementStatus == "expiring30":
query = query.where(
User.id.in_(
active_user_ids.where(
UserEntitlement.expired_at.is_not(None),
UserEntitlement.expired_at > now + timedelta(days=7),
UserEntitlement.expired_at <= now + timedelta(days=30),
)
)
)
elif entitlementStatus == "default":
query = query.where(User.id.not_in(active_user_ids))
elif entitlementStatus == "expired":
expired_user_ids = select(UserEntitlement.user_id).join(
EntitlementPlan, EntitlementPlan.id == UserEntitlement.plan_id
).where(
UserEntitlement.status.in_(("active", "expired")),
UserEntitlement.expired_at.is_not(None),
UserEntitlement.expired_at < now,
EntitlementPlan.plan_type != "teacher",
)
query = query.where(User.id.not_in(active_user_ids), User.id.in_(expired_user_ids))
total = db.scalar(select(func.count()).select_from(query.order_by(None).subquery())) or 0
users = db.scalars(query.offset((page - 1) * pageSize).limit(pageSize)).all()
return api_success(page_result([_user_dict(user) for user in users], total=total, page=page, page_size=pageSize))
entitlements = _entitlement_views(db, users)
return api_success(page_result([_user_dict(user, entitlements.get(user.id)) for user in users], total=total, page=page, page_size=pageSize))
@router.post("/user")
@@ -68,7 +135,8 @@ def create_user(
OperationLogService.write(db, admin_id=current_admin.id, module="user", action="create", target_id=user.id)
db.commit()
db.refresh(user)
return api_success(_user_dict(user))
entitlement = EntitlementService.active_entitlement(db, user)
return api_success(_user_dict(user, entitlement_dict(entitlement)))
@router.post("/user/import")
@@ -88,17 +156,7 @@ def download_user_import_template(
db: Session = Depends(get_db),
current_admin: Admin = Depends(get_current_admin),
) -> StreamingResponse:
workbook = Workbook()
sheet = workbook.active
sheet.title = "学员导入模板"
sheet.append(STUDENT_TEMPLATE_HEADERS)
sheet.append(["13800000001", "张三", "三三", _default_daily_chat_limit(db), "启用", "2026-12-31 23:59:59"])
sheet.append(["13800000002", "李四", "", "", "启用", ""])
widths = [18, 14, 14, 16, 12, 22]
for index, width in enumerate(widths, start=1):
sheet.column_dimensions[chr(64 + index)].width = width
workbook = _user_import_template_workbook(db)
stream = BytesIO()
workbook.save(stream)
stream.seek(0)
@@ -109,6 +167,32 @@ def download_user_import_template(
)
def _user_import_template_workbook(db: Session) -> Workbook:
workbook = Workbook()
sheet = workbook.active
sheet.title = "学员导入模板"
sheet.append(STUDENT_TEMPLATE_HEADERS)
available_plan = EntitlementService.list_plans(db)
example_plan_name = available_plan[0].name if available_plan else ""
sheet.append(
[
"13800000001",
"张三",
"三三",
_default_daily_chat_limit(db),
"启用",
"2026-12-31 23:59:59",
example_plan_name,
]
)
sheet.append(["13800000002", "李四", "", "", "启用", "", ""])
widths = [18, 14, 14, 16, 12, 22, 22]
for index, width in enumerate(widths, start=1):
sheet.column_dimensions[chr(64 + index)].width = width
return workbook
@router.post("/user/import/excel")
def import_users_excel(
file: UploadFile = File(...),
@@ -147,6 +231,7 @@ def import_users_excel(
dailyChatLimit=_parse_optional_int(_excel_value(row, header_map, "每日聊天额度")),
status=_parse_status(_excel_value(row, header_map, "状态")),
expiredAt=_parse_optional_datetime(_excel_value(row, header_map, "有效期")),
entitlementName=_cell_text(_excel_value(row, header_map, "权益名称")) or None,
)
students.append((row_number, item))
except (ValueError, ValidationError) as exc:
@@ -176,6 +261,11 @@ def _import_user_items(
failed: list[dict] = list(initial_failures or [])
seen: set[str] = set()
default_daily_limit = _default_daily_chat_limit(db)
entitlement_plans = {
plan.name.strip(): plan
for plan in EntitlementService.list_plans(db)
if plan.name.strip()
}
for row_number, item in students:
phone = _normalize_phone(item.phone)
@@ -197,6 +287,28 @@ def _import_user_items(
updated += 1
_apply_user_payload(user, item, default_daily_limit)
db.add(user)
db.flush()
entitlement_name = (item.entitlementName or "").strip()
entitlement_plan = entitlement_plans.get(entitlement_name)
if entitlement_plan is not None:
EntitlementService.assign_user_plan(
db,
user=user,
plan_id=entitlement_plan.id,
operated_by=current_admin.id,
remark="用户批量导入按权益名称匹配",
)
else:
EntitlementService.clear_user_plan(
db,
user=user,
operated_by=current_admin.id,
reason=(
f"导入的权益名称不存在:{entitlement_name}"
if entitlement_name
else "导入时未填写权益名称"
),
)
except ValueError as exc:
failed.append({"row": row_number, "phone": item.phone, "reason": str(exc)})
@@ -225,6 +337,132 @@ def user_detail(
return api_success(_user_dict(user))
@router.get("/user/{user_id}/detail")
def user_operation_detail(
user_id: int,
db: Session = Depends(get_db),
current_admin: Admin = Depends(get_current_admin),
) -> dict:
user = _get_user(db, user_id)
entitlement = EntitlementService.active_entitlement(db, user)
return api_success(
{
"user": _user_dict(user, entitlement_dict(entitlement)),
"metrics": _user_metrics(db, user=user),
"growthProfile": growth_profile_dict(GrowthProfileService.get_growth_profile(db, user.id)),
"recentTopics": _recent_topics(db, user.id),
"recentHelpCards": [help_card_dict(item) for item in _recent_help_cards(db, user.id)],
"recentShareDrafts": [share_draft_dict(item) for item in _recent_share_drafts(db, user.id)],
"recentReports": [
periodic_report_dict(item)
for item in PeriodicReportService.list_user_reports(db, user_id=user.id, limit=10)
],
}
)
@router.get("/user/{user_id}/topics")
def user_topic_options(
user_id: int,
keyword: str = Query(default="", max_length=100),
page: int = Query(default=1, ge=1),
pageSize: int = Query(default=50, ge=10, le=100),
db: Session = Depends(get_db),
current_admin: Admin = Depends(get_current_admin),
) -> dict:
"""Lightweight topic selector data for Agent preview; no message bodies are returned."""
user = _get_user(db, user_id)
conditions = [TopicSession.user_id == user.id]
normalized_keyword = keyword.strip()
if normalized_keyword:
like = f"%{normalized_keyword}%"
conditions.append(or_(TopicSession.title.like(like), TopicSession.core_question.like(like)))
total = db.scalar(select(func.count(TopicSession.id)).where(*conditions)) or 0
rows = db.execute(
select(TopicSession, TopicSummary.id)
.join(TopicSummary, TopicSummary.topic_session_id == TopicSession.id, isouter=True)
.where(*conditions)
.order_by(TopicSession.updated_at.desc(), TopicSession.id.desc())
.offset((page - 1) * pageSize)
.limit(pageSize)
).all()
items = [
{
**topic_dict(topic),
"summaryAvailable": summary_id is not None,
}
for topic, summary_id in rows
]
return api_success(page_result(items, total=total, page=page, page_size=pageSize))
@router.get("/user/{user_id}/reports")
def user_reports(
user_id: int,
limit: int = Query(default=20, ge=1, le=50),
db: Session = Depends(get_db),
current_admin: Admin = Depends(get_current_admin),
) -> dict:
user = _get_user(db, user_id)
reports = PeriodicReportService.list_user_reports(db, user_id=user.id, limit=limit)
return api_success([periodic_report_dict(item) for item in reports])
@router.post("/user/{user_id}/reports/generate")
def generate_user_report(
user_id: int,
payload: AdminGenerateReportRequest,
db: Session = Depends(get_db),
current_admin: Admin = Depends(get_current_admin),
) -> dict:
user = _get_user(db, user_id)
if payload.reportType not in ("weekly", "monthly", "stage"):
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="不支持的报告类型")
try:
report = PeriodicReportService.enqueue_for_user(
db,
user=user,
report_type=payload.reportType, # type: ignore[arg-type]
period_start=payload.periodStart,
period_end=payload.periodEnd,
generated_by=f"admin:{current_admin.id}",
)
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
OperationLogService.write(db, admin_id=current_admin.id, module="user_report", action="enqueue", target_id=report.id)
db.commit()
db.refresh(report)
return api_success(periodic_report_dict(report), message="报告任务已进入后台队列")
@router.post("/user/{user_id}/topic/{topic_id}/settlement/retry")
def retry_topic_settlement(
user_id: int,
topic_id: int,
db: Session = Depends(get_db),
current_admin: Admin = Depends(get_current_admin),
) -> dict:
user = _get_user(db, user_id)
topic = db.scalar(
select(TopicSession).where(TopicSession.id == topic_id, TopicSession.user_id == user.id)
)
if topic is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="主题不存在")
if topic.status != "completed":
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="只能重试已结束主题的沉淀任务")
summary = GrowthProfileService.retry_topic_settlement(db, user=user, topic=topic)
OperationLogService.write(
db,
admin_id=current_admin.id,
module="topic_settlement",
action="retry",
target_id=summary.id,
)
db.commit()
db.refresh(summary)
return api_success(topic_summary_dict(summary), message="主题沉淀任务已重新进入队列")
@router.put("/user/{user_id}")
def update_user(
user_id: int,
@@ -238,12 +476,22 @@ def update_user(
if payload.dailyChatLimit is not None:
user.daily_chat_limit = payload.dailyChatLimit
if payload.expiredAt is not None:
user.expired_at = payload.expiredAt.replace(tzinfo=None)
user.expired_at = as_utc_naive(payload.expiredAt)
db.add(user)
if payload.entitlementPlanId is not None:
EntitlementService.assign_user_plan(
db,
user=user,
plan_id=payload.entitlementPlanId,
operated_by=current_admin.id,
expired_at=payload.entitlementExpiredAt,
remark=payload.entitlementRemark,
)
OperationLogService.write(db, admin_id=current_admin.id, module="user", action="update", target_id=user.id)
db.commit()
db.refresh(user)
return api_success(_user_dict(user))
entitlement = EntitlementService.active_entitlement(db, user)
return api_success(_user_dict(user, entitlement_dict(entitlement)))
@router.delete("/user/{user_id}")
@@ -269,21 +517,179 @@ def _get_user(db: Session, user_id: int) -> User:
return user
def _user_dict(user: User) -> dict:
def _user_dict(user: User, entitlement: dict | None = None) -> dict:
return {
"id": user.id,
"phone": user.phone,
"name": user.name,
"nickname": user.nickname,
"registrationSource": user.registration_source,
"registrationClientId": user.registration_client_id,
"status": user.status,
"dailyChatLimit": user.daily_chat_limit,
"dailyChatUsed": user.daily_chat_used,
"expiredAt": user.expired_at,
"lastLoginAt": user.last_login_at,
"createdAt": user.created_at,
"entitlement": entitlement,
}
def _entitlement_views(db: Session, users: list[User]) -> dict[int, dict]:
user_ids = [user.id for user in users]
if not user_ids:
return {}
explicit = _active_entitlement_rows(db, user_ids)
previous_expired = _latest_expired_entitlement_rows(db, user_ids)
default_plan = EntitlementService.default_plan(db)
result: dict[int, dict] = {}
for user in users:
if user.id in explicit:
entitlement, plan = explicit[user.id]
view = view_from_plan(plan, entitlement=entitlement, source="assigned")
else:
if default_plan is None:
view = EntitlementService.active_entitlement(db, user)
else:
view = view_from_plan(
default_plan,
entitlement=None,
source="default",
)
if user.id in previous_expired:
expired_entitlement, expired_plan = previous_expired[user.id]
view = replace(
view,
source="default_after_expiry",
lifecycle_status="expired_fallback",
previous_plan_name=expired_plan.name,
previous_expired_at=expired_entitlement.expired_at,
)
result[user.id] = entitlement_dict(view)
return result
def _active_entitlement_rows(db: Session, user_ids: list[int]) -> dict[int, tuple[UserEntitlement, EntitlementPlan]]:
now = datetime.now(UTC).replace(tzinfo=None)
rows = db.execute(
select(UserEntitlement, EntitlementPlan)
.join(EntitlementPlan, EntitlementPlan.id == UserEntitlement.plan_id)
.where(
UserEntitlement.user_id.in_(user_ids),
UserEntitlement.status == "active",
EntitlementPlan.status == 1,
)
.where((UserEntitlement.effective_at.is_(None)) | (UserEntitlement.effective_at <= now))
.where((UserEntitlement.expired_at.is_(None)) | (UserEntitlement.expired_at >= now))
.order_by(UserEntitlement.created_at.desc(), UserEntitlement.id.desc())
).all()
result: dict[int, tuple[UserEntitlement, EntitlementPlan]] = {}
for entitlement, plan in rows:
result.setdefault(entitlement.user_id, (entitlement, plan))
return result
def _latest_expired_entitlement_rows(db: Session, user_ids: list[int]) -> dict[int, tuple[UserEntitlement, EntitlementPlan]]:
now = datetime.now(UTC).replace(tzinfo=None)
rows = db.execute(
select(UserEntitlement, EntitlementPlan)
.join(EntitlementPlan, EntitlementPlan.id == UserEntitlement.plan_id)
.where(
UserEntitlement.user_id.in_(user_ids),
UserEntitlement.status.in_(("active", "expired")),
UserEntitlement.expired_at.is_not(None),
UserEntitlement.expired_at < now,
EntitlementPlan.plan_type != "teacher",
)
.order_by(UserEntitlement.expired_at.desc(), UserEntitlement.id.desc())
).all()
result: dict[int, tuple[UserEntitlement, EntitlementPlan]] = {}
for entitlement, plan in rows:
result.setdefault(entitlement.user_id, (entitlement, plan))
return result
def _user_metrics(db: Session, *, user: User) -> dict:
now = datetime.now(UTC).replace(tzinfo=None)
recent_start = now - timedelta(days=30)
total_sessions = db.scalar(select(func.count(ChatSession.id)).where(ChatSession.user_id == user.id, ChatSession.is_deleted == 0)) or 0
total_messages = db.scalar(select(func.count(ChatMessage.id)).where(ChatMessage.user_id == user.id)) or 0
recent_active_days = db.scalar(
select(func.count(func.distinct(func.date(ChatMessage.created_at)))).where(
ChatMessage.user_id == user.id,
ChatMessage.created_at >= recent_start,
)
) or 0
last_message_at = db.scalar(select(func.max(ChatMessage.created_at)).where(ChatMessage.user_id == user.id))
token_row = db.execute(
select(
func.coalesce(func.sum(AiRequestLog.input_token), 0),
func.coalesce(func.sum(AiRequestLog.output_token), 0),
func.coalesce(func.sum(AiRequestLog.total_token), 0),
func.coalesce(func.sum(AiRequestLog.estimated_cost), 0),
func.max(AiRequestLog.currency),
func.count(AiRequestLog.id),
).where(AiRequestLog.user_id == user.id)
).one()
help_card_count = db.scalar(select(func.count(TeacherHelpCard.id)).where(TeacherHelpCard.user_id == user.id)) or 0
share_draft_count = db.scalar(select(func.count(ShareDraft.id)).where(ShareDraft.user_id == user.id)) or 0
return {
"totalSessions": int(total_sessions),
"totalMessages": int(total_messages),
"recentActiveDays": int(recent_active_days),
"lastMessageAt": last_message_at,
"aiRequestCount": int(token_row[5] or 0),
"inputToken": int(token_row[0] or 0),
"outputToken": int(token_row[1] or 0),
"totalToken": int(token_row[2] or 0),
"estimatedCost": float(token_row[3] or 0),
"costCurrency": token_row[4] or "CNY",
"helpCardCount": int(help_card_count),
"shareDraftCount": int(share_draft_count),
"isHighFrequency": recent_active_days >= 15,
"isInactive": total_sessions == 0 or last_message_at is None or last_message_at < recent_start,
}
def _recent_topics(db: Session, user_id: int, *, limit: int = 10) -> list[dict]:
rows = db.execute(
select(TopicSession, TopicSummary)
.join(TopicSummary, TopicSummary.topic_session_id == TopicSession.id, isouter=True)
.where(TopicSession.user_id == user_id)
.order_by(TopicSession.updated_at.desc(), TopicSession.id.desc())
.limit(limit)
).all()
return [
{
**topic_dict(topic),
"summary": topic_summary_dict(summary) if summary else None,
}
for topic, summary in rows
]
def _recent_help_cards(db: Session, user_id: int, *, limit: int = 10) -> list[TeacherHelpCard]:
return list(
db.scalars(
select(TeacherHelpCard)
.where(TeacherHelpCard.user_id == user_id)
.order_by(TeacherHelpCard.created_at.desc(), TeacherHelpCard.id.desc())
.limit(limit)
)
)
def _recent_share_drafts(db: Session, user_id: int, *, limit: int = 10) -> list[ShareDraft]:
return list(
db.scalars(
select(ShareDraft)
.where(ShareDraft.user_id == user_id)
.order_by(ShareDraft.created_at.desc(), ShareDraft.id.desc())
.limit(limit)
)
)
def _apply_user_payload(
user: User,
payload: AdminUserCreateRequest | AdminUserImportItem,
@@ -293,7 +699,7 @@ def _apply_user_payload(
user.nickname = payload.nickname.strip() if payload.nickname else None
user.status = payload.status
user.daily_chat_limit = payload.dailyChatLimit if payload.dailyChatLimit is not None else default_daily_limit
user.expired_at = payload.expiredAt.replace(tzinfo=None) if payload.expiredAt is not None else None
user.expired_at = as_utc_naive(payload.expiredAt) if payload.expiredAt is not None else None
def _default_daily_chat_limit(db: Session) -> int:
@@ -358,14 +764,17 @@ def _parse_optional_datetime(value: object | None) -> datetime | None:
if value is None or _cell_text(value) == "":
return None
if isinstance(value, datetime):
return value.replace(tzinfo=None)
return business_datetime_to_utc_naive(value)
if isinstance(value, date):
return datetime.combine(value, datetime.min.time())
return business_day_boundary(value, end_exclusive=True) - timedelta(microseconds=1)
text = _cell_text(value)
for fmt in ("%Y-%m-%d %H:%M:%S", "%Y-%m-%d", "%Y/%m/%d %H:%M:%S", "%Y/%m/%d"):
try:
return datetime.strptime(text, fmt)
parsed = datetime.strptime(text, fmt)
if "%H" not in fmt:
return business_day_boundary(parsed.date(), end_exclusive=True) - timedelta(microseconds=1)
return business_datetime_to_utc_naive(parsed)
except ValueError:
continue
raise ValueError("有效期格式应为 YYYY-MM-DD 或 YYYY-MM-DD HH:mm:ss")

View File

@@ -7,9 +7,12 @@ from app.core.database import get_db
from app.core.dependencies import get_current_token_payload
from app.core.responses import api_success
from app.schemas.auth import CaptchaResponse, LoginRequest, LoginResponse, SendSmsRequest
from app.schemas.sso import SsoExchangeRequest
from app.services.auth_service import AuthService
from app.services.captcha_service import CaptchaService
from app.services.periodic_report_lazy_service import PeriodicReportLazyService
from app.services.security_state_service import client_ip
from app.services.sso_service import SsoService
router = APIRouter()
@@ -28,6 +31,14 @@ def send_sms(payload: SendSmsRequest, request: Request, db: Session = Depends(ge
@router.post("/login")
def login(payload: LoginRequest, db: Session = Depends(get_db)) -> dict:
result = AuthService.login_with_sms(db, payload.phone, payload.code)
PeriodicReportLazyService.check_after_authentication(db, user=result["user"])
return api_success(LoginResponse.model_validate(result).model_dump(mode="json"))
@router.post("/sso/exchange")
def exchange_sso(payload: SsoExchangeRequest, request: Request, db: Session = Depends(get_db)) -> dict:
result = SsoService.exchange(db, code=payload.code, ip=client_ip(request))
PeriodicReportLazyService.check_after_authentication(db, user=result["user"])
return api_success(LoginResponse.model_validate(result).model_dump(mode="json"))

View File

@@ -0,0 +1,24 @@
from __future__ import annotations
from fastapi import APIRouter, Depends
from sqlalchemy.orm import Session
from app.core.auth_context import UserAuthContext
from app.core.database import get_db
from app.core.dependencies import get_current_user_context
from app.core.responses import api_success
from app.schemas.behavior import UserBehaviorBatchCreate
from app.services.user_behavior_service import UserBehaviorService
router = APIRouter()
@router.post("/events")
def record_behavior_events(
payload: UserBehaviorBatchCreate,
db: Session = Depends(get_db),
current: UserAuthContext = Depends(get_current_user_context),
) -> dict:
accepted = UserBehaviorService.record_batch(db, user=current.user, items=payload.events)
return api_success({"accepted": accepted})

View File

@@ -7,16 +7,20 @@ from collections.abc import AsyncIterator
from fastapi import APIRouter, Depends, HTTPException, Query
from fastapi.responses import StreamingResponse
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.core.database import get_db
from app.core.dependencies import get_current_user
from app.core.auth_context import ChatAccessScope, UserAuthContext
from app.core.dependencies import get_current_user_context
from app.core.responses import api_success
from app.models.user import User
from app.models.chat import TopicSession
from app.models.growth import TopicSummary
from app.schemas.chat import (
ChatCompletionRequest,
ChatMessageRead,
ChatSessionRead,
CreateSessionRequest,
CreateSessionResponse,
StopChatRequest,
UpdateSessionTitleRequest,
@@ -30,20 +34,33 @@ from app.services.chat_queue_runtime import (
)
from app.services.chat_queue_service import load_chat_queue_config
from app.services.chat_stream_service import ChatStreamService
from app.services.growth_profile_service import GrowthProfileService
from app.services.help_card_service import HelpCardService, help_card_dict
from app.services.reasoning_policy_service import ReasoningPolicyService
from app.services.share_draft_service import ShareDraftService, share_draft_dict
router = APIRouter()
logger = logging.getLogger(__name__)
@router.post("/session")
def create_session(db: Session = Depends(get_db), current_user: User = Depends(get_current_user)) -> dict:
session = ChatService.create_session(db, current_user)
def create_session(
payload: CreateSessionRequest | None = None,
db: Session = Depends(get_db),
current: UserAuthContext = Depends(get_current_user_context),
) -> dict:
session = ChatService.create_session(
db,
current.user,
current.chat_scope,
current_session_id=payload.currentSessionId if payload else None,
)
return api_success(CreateSessionResponse(sessionId=session.id).model_dump())
@router.get("/session/list")
def list_sessions(db: Session = Depends(get_db), current_user: User = Depends(get_current_user)) -> dict:
sessions = ChatService.list_sessions(db, current_user)
def list_sessions(db: Session = Depends(get_db), current: UserAuthContext = Depends(get_current_user_context)) -> dict:
sessions = ChatService.list_sessions(db, current.user, current.chat_scope)
return api_success([ChatSessionRead.model_validate(session).model_dump() for session in sessions])
@@ -51,19 +68,26 @@ def list_sessions(db: Session = Depends(get_db), current_user: User = Depends(ge
def history(
sessionId: int = Query(...),
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
current: UserAuthContext = Depends(get_current_user_context),
) -> dict:
messages = ChatService.get_history(db, current_user, sessionId)
return api_success([ChatMessageRead.model_validate(message).model_dump(mode="json") for message in messages])
messages = ChatService.get_history(db, current.user, sessionId, current.chat_scope)
reasoning_visible = ReasoningPolicyService.is_visible(db)
result = []
for message in messages:
item = ChatMessageRead.model_validate(message).model_dump(mode="json")
if message.role == "assistant" and not reasoning_visible:
item["content"] = ReasoningPolicyService.strip_reasoning(item["content"])
result.append(item)
return api_success(result)
@router.put("/session/title")
def update_title(
payload: UpdateSessionTitleRequest,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
current: UserAuthContext = Depends(get_current_user_context),
) -> dict:
session = ChatService.update_title(db, current_user, payload.sessionId, payload.title)
session = ChatService.update_title(db, current.user, payload.sessionId, payload.title, current.chat_scope)
return api_success(ChatSessionRead.model_validate(session).model_dump())
@@ -71,9 +95,126 @@ def update_title(
def delete_session(
session_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
current: UserAuthContext = Depends(get_current_user_context),
) -> dict:
ChatService.delete_session(db, current_user, session_id)
ChatService.delete_session(db, current.user, session_id, current.chat_scope)
return api_success()
@router.post("/session/{session_id}/topic/finish")
def finish_topic(
session_id: int,
db: Session = Depends(get_db),
current: UserAuthContext = Depends(get_current_user_context),
) -> dict:
session = ChatService._get_user_session(db, current.user, session_id, current.chat_scope)
return api_success(GrowthProfileService.finish_active_topic(db, user=current.user, session=session))
@router.get("/topic/settlement/{summary_id}")
def topic_settlement(
summary_id: int,
db: Session = Depends(get_db),
current: UserAuthContext = Depends(get_current_user_context),
) -> dict:
topic_session_id = db.scalar(
select(TopicSession.chat_session_id)
.join(TopicSummary, TopicSummary.topic_session_id == TopicSession.id)
.where(TopicSummary.id == summary_id, TopicSummary.user_id == current.user.id)
)
if topic_session_id is None:
raise HTTPException(status_code=404, detail="主题沉淀任务不存在")
ChatService._get_user_session(db, current.user, topic_session_id, current.chat_scope)
return api_success(GrowthProfileService.topic_settlement_result(db, user=current.user, summary_id=summary_id))
@router.post("/session/{session_id}/help-card")
def generate_help_card(
session_id: int,
db: Session = Depends(get_db),
current: UserAuthContext = Depends(get_current_user_context),
) -> dict:
session = ChatService._get_user_session(db, current.user, session_id, current.chat_scope)
card = HelpCardService.generate_for_session(db, user=current.user, session=session)
return api_success(help_card_dict(card))
@router.get("/help-card/list")
def list_help_cards(
limit: int = Query(default=20, ge=1, le=50),
db: Session = Depends(get_db),
current: UserAuthContext = Depends(get_current_user_context),
) -> dict:
cards = HelpCardService.list_user_cards(
db,
user=current.user,
scope=current.chat_scope,
limit=limit,
)
return api_success([help_card_dict(card) for card in cards])
@router.post("/help-card/{card_id}/copied")
def mark_help_card_copied(
card_id: int,
db: Session = Depends(get_db),
current: UserAuthContext = Depends(get_current_user_context),
) -> dict:
return api_success(help_card_dict(HelpCardService.mark_copied(db, user=current.user, scope=current.chat_scope, card_id=card_id)))
@router.delete("/help-card/{card_id}")
def delete_help_card(
card_id: int,
db: Session = Depends(get_db),
current: UserAuthContext = Depends(get_current_user_context),
) -> dict:
HelpCardService.delete(db, user=current.user, scope=current.chat_scope, card_id=card_id)
return api_success()
@router.post("/session/{session_id}/share-draft")
def generate_share_draft(
session_id: int,
db: Session = Depends(get_db),
current: UserAuthContext = Depends(get_current_user_context),
) -> dict:
session = ChatService._get_user_session(db, current.user, session_id, current.chat_scope)
draft = ShareDraftService.generate_for_session(db, user=current.user, session=session)
return api_success(share_draft_dict(draft))
@router.get("/share-draft/list")
def list_share_drafts(
limit: int = Query(default=20, ge=1, le=50),
db: Session = Depends(get_db),
current: UserAuthContext = Depends(get_current_user_context),
) -> dict:
drafts = ShareDraftService.list_user_drafts(
db,
user=current.user,
scope=current.chat_scope,
limit=limit,
)
return api_success([share_draft_dict(draft) for draft in drafts])
@router.post("/share-draft/{draft_id}/copied")
def mark_share_draft_copied(
draft_id: int,
db: Session = Depends(get_db),
current: UserAuthContext = Depends(get_current_user_context),
) -> dict:
return api_success(share_draft_dict(ShareDraftService.mark_copied(db, user=current.user, scope=current.chat_scope, draft_id=draft_id)))
@router.delete("/share-draft/{draft_id}")
def delete_share_draft(
draft_id: int,
db: Session = Depends(get_db),
current: UserAuthContext = Depends(get_current_user_context),
) -> dict:
ShareDraftService.delete(db, user=current.user, scope=current.chat_scope, draft_id=draft_id)
return api_success()
@@ -81,22 +222,34 @@ def delete_session(
def completions(
payload: ChatCompletionRequest,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
current: UserAuthContext = Depends(get_current_user_context),
) -> StreamingResponse:
return StreamingResponse(_chat_stream(payload, db, current_user), media_type="text/event-stream")
return StreamingResponse(
_chat_stream(payload, db, current),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache, no-transform",
"Connection": "keep-alive",
"X-Accel-Buffering": "no",
},
)
@router.post("/stop")
def stop(
payload: StopChatRequest,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
current: UserAuthContext = Depends(get_current_user_context),
) -> dict:
ChatService.stop_generation(db, current_user, payload.sessionId)
ChatService.stop_generation(db, current.user, payload.sessionId, current.chat_scope)
return api_success()
async def _chat_stream(payload: ChatCompletionRequest, db: Session, current_user: User) -> AsyncIterator[str]:
async def _chat_stream(payload: ChatCompletionRequest, db: Session, current: UserAuthContext) -> AsyncIterator[str]:
# Keep the internal stream helper compatible with direct service/test callers;
# HTTP requests always pass a fully validated UserAuthContext.
current_user = getattr(current, "user", current)
chat_scope = getattr(current, "chat_scope", ChatAccessScope.direct())
config = load_chat_queue_config(db)
queue_lease = await request_chat_slot(config)
queue_request = queue_lease.request
@@ -144,14 +297,27 @@ async def _chat_stream(payload: ChatCompletionRequest, db: Session, current_user
return
try:
reasoning_visible = ReasoningPolicyService.is_visible(db)
yield _sse_event(
"generating",
message="已进入生成队列,正在生成回答。",
message="思考中",
activeCount=queue_request.active_count,
waitingCount=queue_request.waiting_count,
reasoningVisible=reasoning_visible,
)
async for chunk in ChatStreamService.stream_answer_async(db, current_user, payload.sessionId, payload.message):
yield _sse_event("content", content=chunk)
chunks = ChatStreamService.stream_answer_async(
db,
current_user,
payload.sessionId,
payload.message,
retry_failed_question=payload.retry,
scope=chat_scope,
)
async for segment in ReasoningPolicyService.iter_segments(chunks):
if segment.kind == "content":
yield _sse_event("content", content=segment.content)
elif reasoning_visible:
yield _sse_event("reasoning", content=segment.content)
except HTTPException as exc:
yield _sse_event("error", message=str(exc.detail))
except asyncio.CancelledError:

View File

@@ -0,0 +1,231 @@
from __future__ import annotations
from datetime import date, timedelta
from io import BytesIO
from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, Query, status
from fastapi.responses import StreamingResponse
from openpyxl import Workbook
from openpyxl.styles import Alignment, Font, PatternFill
from openpyxl.worksheet.table import Table, TableStyleInfo
from pydantic import BaseModel, Field
from sqlalchemy import func, select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
from app.core.ai_content_label import ensure_ai_generated_notice
from app.api.pagination import page_result
from app.core.database import get_db
from app.core.dependencies import get_current_admin, get_current_user
from app.core.responses import api_success
from app.core.time_utils import business_day_boundary, to_business_naive, utc_now_naive
from app.models.admin import Admin
from app.models.chat import ChatMessage, ChatSession
from app.models.feedback import MessageFeedback
from app.models.user import User
from app.services.admin_service import OperationLogService
from app.services.admin_permission_service import require_permission
from app.services.chat_message_navigation_service import ChatMessageNavigationService
router = APIRouter()
class FeedbackCreate(BaseModel):
messageId: int = Field(gt=0)
content: str = Field(min_length=1, max_length=200)
@router.post("")
def create_feedback(payload: FeedbackCreate, db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> dict:
message = db.scalar(select(ChatMessage).where(ChatMessage.id == payload.messageId, ChatMessage.user_id == user.id))
if message is None or message.role != "assistant" or message.message_status != "FINISHED":
raise HTTPException(status_code=404, detail="反馈的回答不存在")
content = payload.content.strip()
if not content:
raise HTTPException(status_code=400, detail="请填写反馈内容")
existing = db.scalar(select(MessageFeedback).where(MessageFeedback.user_id == user.id, MessageFeedback.message_id == message.id))
if existing:
raise HTTPException(status_code=409, detail="这条回答已经反馈过了")
item = MessageFeedback(user_id=user.id, session_id=message.session_id, message_id=message.id, content=content)
db.add(item)
try:
db.commit()
except IntegrityError as exc:
db.rollback()
raise HTTPException(status_code=409, detail="这条回答已经反馈过了") from exc
db.refresh(item)
return api_success({"id": item.id})
@router.get("/admin/list")
def feedback_list(readStatus: str = Query(default="all", pattern="^(all|read|unread)$"), startDate: date | None = None, endDate: date | None = None, page: int = Query(default=1, ge=1), pageSize: int = Query(default=20, ge=10, le=100), db: Session = Depends(get_db), _admin: Admin = Depends(get_current_admin)) -> dict:
require_permission(_admin, "feedback.view")
query = _feedback_query(readStatus, startDate, endDate)
total = db.scalar(select(func.count()).select_from(query.order_by(None).subquery())) or 0
rows = db.execute(query.order_by(MessageFeedback.created_at.desc()).offset((page - 1) * pageSize).limit(pageSize)).all()
return api_success(page_result([_summary(row[0], row[1], row[2]) for row in rows], total=total, page=page, page_size=pageSize))
@router.get("/admin/export")
def export_feedback(
startDate: date,
endDate: date,
readStatus: str = Query(default="all", pattern="^(all|read|unread)$"),
db: Session = Depends(get_db),
admin: Admin = Depends(get_current_admin),
) -> StreamingResponse:
require_permission(admin, "feedback.export")
if endDate < startDate:
raise HTTPException(status_code=400, detail="结束日期不能早于开始日期")
if endDate - startDate > timedelta(days=366):
raise HTTPException(status_code=400, detail="单次导出时间范围不能超过 366 天")
rows = db.execute(_feedback_query(readStatus, startDate, endDate).order_by(MessageFeedback.created_at.desc()).limit(100001)).all()
if len(rows) > 100000:
raise HTTPException(status_code=400, detail="导出数据超过 10 万条,请缩小时间范围")
workbook = _feedback_workbook(rows)
stream = BytesIO()
workbook.save(stream)
stream.seek(0)
OperationLogService.write(db, admin_id=admin.id, module="feedback", action="export")
db.commit()
filename = f"feedback_{startDate:%Y%m%d}_{endDate:%Y%m%d}.xlsx"
return StreamingResponse(
stream,
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
)
@router.get("/admin/{feedback_id}")
def feedback_detail(
feedback_id: int,
messagePage: Annotated[int | None, Query(ge=1)] = None,
messagePageSize: Annotated[int, Query(ge=10, le=100)] = 20,
db: Session = Depends(get_db),
admin: Admin = Depends(get_current_admin),
) -> dict:
require_permission(admin, "feedback.detail")
row = db.execute(select(MessageFeedback, User, ChatMessage, ChatSession).join(User, User.id == MessageFeedback.user_id).join(ChatMessage, ChatMessage.id == MessageFeedback.message_id).join(ChatSession, ChatSession.id == MessageFeedback.session_id).where(MessageFeedback.id == feedback_id)).first()
if row is None:
raise HTTPException(status_code=404, detail="反馈不存在")
feedback, user, target, session = row
if not feedback.is_read:
feedback.is_read = 1
feedback.read_by = admin.id
feedback.read_at = utc_now_naive()
db.commit()
message_query = select(ChatMessage).where(ChatMessage.session_id == feedback.session_id)
message_total = db.scalar(select(func.count()).select_from(message_query.subquery())) or 0
resolved_page = messagePage or ChatMessageNavigationService.page_for_message(
db,
session_id=feedback.session_id,
message=target,
page_size=messagePageSize,
)
messages = db.scalars(
message_query
.order_by(ChatMessage.created_at.asc(), ChatMessage.id.asc())
.offset((resolved_page - 1) * messagePageSize)
.limit(messagePageSize)
).all()
serialized_messages = [
{
"id": message.id,
"role": message.role,
"content": message.content,
"createdAt": message.created_at,
"isTarget": message.id == target.id,
}
for message in messages
]
return api_success({
**_summary(feedback, user, target),
"sessionTitle": session.title,
"messages": serialized_messages,
"messagesPage": page_result(
serialized_messages,
total=message_total,
page=resolved_page,
page_size=messagePageSize,
),
})
@router.delete("/admin/{feedback_id}")
def delete_feedback(feedback_id: int, db: Session = Depends(get_db), admin: Admin = Depends(get_current_admin)) -> dict:
require_permission(admin, "feedback.delete")
item = db.get(MessageFeedback, feedback_id)
if item is None:
raise HTTPException(status_code=404, detail="反馈不存在")
db.delete(item)
OperationLogService.write(db, admin_id=admin.id, module="feedback", action="delete", target_id=feedback_id)
db.commit()
return api_success()
def _summary(item: MessageFeedback, user: User, message: ChatMessage) -> dict:
return {"id": item.id, "userId": user.id, "userName": user.name, "userPhone": user.phone, "messageId": message.id, "messageContent": message.content, "content": item.content, "isRead": bool(item.is_read), "createdAt": item.created_at}
def _feedback_query(read_status: str, start_date: date | None, end_date: date | None):
query = (
select(MessageFeedback, User, ChatMessage, ChatSession)
.join(User, User.id == MessageFeedback.user_id)
.join(ChatMessage, ChatMessage.id == MessageFeedback.message_id)
.join(ChatSession, ChatSession.id == MessageFeedback.session_id)
)
if read_status != "all":
query = query.where(MessageFeedback.is_read == (1 if read_status == "read" else 0))
if start_date is not None:
query = query.where(MessageFeedback.created_at >= business_day_boundary(start_date))
if end_date is not None:
query = query.where(MessageFeedback.created_at < business_day_boundary(end_date, end_exclusive=True))
return query
def _feedback_workbook(rows: list[tuple]) -> Workbook:
workbook = Workbook()
sheet = workbook.active
sheet.title = "反馈记录"
sheet.sheet_view.showGridLines = False
headers = ["序号", "状态", "用户姓名", "手机号", "反馈内容", "会话标题", "消息ID", "对应AI回答AI生成", "提交时间", "阅读时间"]
sheet.append(headers)
for index, (feedback, user, message, session) in enumerate(rows, start=1):
sheet.append([
index,
"已读" if feedback.is_read else "未读",
_excel_safe_text(user.name),
_excel_safe_text(user.phone),
_excel_safe_text(feedback.content),
_excel_safe_text(session.title),
message.id,
_excel_safe_text(ensure_ai_generated_notice(message.content)),
to_business_naive(feedback.created_at),
to_business_naive(feedback.read_at),
])
header_fill = PatternFill("solid", fgColor="1F6F5F")
for cell in sheet[1]:
cell.fill = header_fill
cell.font = Font(color="FFFFFF", bold=True)
cell.alignment = Alignment(horizontal="center", vertical="center")
sheet.freeze_panes = "A2"
sheet.auto_filter.ref = f"A1:J{max(1, sheet.max_row)}"
widths = [8, 10, 16, 16, 38, 24, 12, 70, 20, 20]
for index, width in enumerate(widths, start=1):
sheet.column_dimensions[chr(64 + index)].width = width
for row in sheet.iter_rows(min_row=2):
row[4].alignment = Alignment(vertical="top", wrap_text=True)
row[7].alignment = Alignment(vertical="top", wrap_text=True)
for cell in (row[8], row[9]):
cell.number_format = "yyyy-mm-dd hh:mm:ss"
if sheet.max_row > 1:
table = Table(displayName="FeedbackRecords", ref=f"A1:J{sheet.max_row}")
table.tableStyleInfo = TableStyleInfo(name="TableStyleMedium2", showRowStripes=True, showFirstColumn=False, showLastColumn=False)
sheet.add_table(table)
return workbook
def _excel_safe_text(value: object | None) -> str:
text_value = str(value or "")
return f"'{text_value}" if text_value.startswith(("=", "+", "-", "@")) else text_value

View File

@@ -0,0 +1,43 @@
from __future__ import annotations
from fastapi import APIRouter, Depends, HTTPException, Request, status
from pydantic import ValidationError
from sqlalchemy.orm import Session
from app.core.database import get_db
from app.core.responses import api_success
from app.schemas.sso import SsoTicketRequest
from app.services.security_state_service import client_ip
from app.services.sso_service import SsoService
router = APIRouter()
@router.post("/ticket")
async def create_ticket(request: Request, db: Session = Depends(get_db)) -> dict:
raw_body = await request.body()
try:
payload = SsoTicketRequest.model_validate_json(raw_body)
except ValidationError as exc:
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="接入用户参数不完整") from exc
app_id = request.headers.get("X-App-Id", "").strip()
timestamp = request.headers.get("X-Timestamp", "").strip()
nonce = request.headers.get("X-Nonce", "").strip()
signature = request.headers.get("X-Signature", "").strip()
if not all((app_id, timestamp, nonce, signature)):
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="缺少应用签名信息")
return api_success(
SsoService.issue_ticket(
db,
app_id=app_id,
timestamp=timestamp,
nonce=nonce,
signature=signature,
raw_body=raw_body,
payload=payload,
ip=client_ip(request),
)
)

View File

@@ -0,0 +1,16 @@
from __future__ import annotations
from fastapi import APIRouter, Depends
from sqlalchemy.orm import Session
from app.core.database import get_db
from app.core.responses import api_success
from app.services.public_site_config_service import PublicSiteConfigService
router = APIRouter()
@router.get("/config")
def public_site_config(db: Session = Depends(get_db)) -> dict:
return api_success(PublicSiteConfigService.public_config(db))

View File

@@ -1,32 +1,58 @@
from __future__ import annotations
from fastapi import APIRouter
from fastapi import APIRouter, Depends
from app.core.dependencies import enforce_admin_access
from app.api import (
admin_auth,
admin_attention,
admin_content_generation,
admin_user_behavior,
admin_agent_records,
admin_agent_batch,
admin_dashboard,
admin_entitlements,
admin_knowledge,
admin_knowledge_lifecycle,
admin_management,
admin_records,
admin_settings,
admin_sso,
admin_users,
auth,
feedback,
chat,
health,
integration_sso,
public_config,
user,
voice,
behavior,
)
api_router = APIRouter()
api_router.include_router(health.router, tags=["health"])
api_router.include_router(public_config.router, prefix="/site", tags=["public-site-config"])
api_router.include_router(auth.router, prefix="/auth", tags=["auth"])
api_router.include_router(integration_sso.router, prefix="/integration/sso", tags=["integration-sso"])
api_router.include_router(user.router, prefix="/user", tags=["user"])
api_router.include_router(voice.router, prefix="/voice", tags=["voice"])
api_router.include_router(chat.router, prefix="/chat", tags=["chat"])
api_router.include_router(feedback.router, prefix="/feedback", tags=["feedback"])
api_router.include_router(behavior.router, prefix="/behavior", tags=["user-behavior"])
api_router.include_router(admin_auth.router, prefix="/admin", tags=["admin"])
api_router.include_router(admin_agent_records.router, prefix="/admin", tags=["admin-agent-records"])
api_router.include_router(admin_dashboard.router, prefix="/admin", tags=["admin"])
api_router.include_router(admin_users.router, prefix="/admin", tags=["admin"])
api_router.include_router(admin_knowledge.router, prefix="/admin", tags=["admin"])
api_router.include_router(admin_knowledge_lifecycle.router, prefix="/admin", tags=["admin-knowledge-lifecycle"])
api_router.include_router(admin_settings.router, prefix="/admin", tags=["admin"])
api_router.include_router(admin_records.router, prefix="/admin", tags=["admin"])
api_router.include_router(admin_management.router, prefix="/admin", tags=["admin-management"])
guard = [Depends(enforce_admin_access)]
api_router.include_router(admin_attention.router, prefix="/admin", tags=["admin-attention"], dependencies=guard)
api_router.include_router(admin_content_generation.router, prefix="/admin", tags=["admin-content-generation"], dependencies=guard)
api_router.include_router(admin_user_behavior.router, prefix="/admin", tags=["admin-user-behavior"], dependencies=guard)
api_router.include_router(admin_agent_records.router, prefix="/admin", tags=["admin-agent-records"], dependencies=guard)
api_router.include_router(admin_agent_batch.router, prefix="/admin", tags=["admin-agent-batch"], dependencies=guard)
api_router.include_router(admin_dashboard.router, prefix="/admin", tags=["admin"], dependencies=guard)
api_router.include_router(admin_entitlements.router, prefix="/admin", tags=["admin-entitlements"], dependencies=guard)
api_router.include_router(admin_users.router, prefix="/admin", tags=["admin"], dependencies=guard)
api_router.include_router(admin_knowledge.router, prefix="/admin", tags=["admin"], dependencies=guard)
api_router.include_router(admin_knowledge_lifecycle.router, prefix="/admin", tags=["admin-knowledge-lifecycle"], dependencies=guard)
api_router.include_router(admin_settings.router, prefix="/admin", tags=["admin"], dependencies=guard)
api_router.include_router(admin_sso.router, prefix="/admin", tags=["admin-sso"], dependencies=guard)
api_router.include_router(admin_records.router, prefix="/admin", tags=["admin"], dependencies=guard)

View File

@@ -1,15 +1,82 @@
from __future__ import annotations
from fastapi import APIRouter, Depends
from fastapi import APIRouter, Depends, Query
from sqlalchemy.orm import Session
from app.core.database import get_db
from app.core.dependencies import get_current_user
from app.core.responses import api_success
from app.models.user import User
from app.schemas.user import UserProfile
from app.services.entitlement_service import EntitlementService, entitlement_dict
from app.services.growth_profile_service import GrowthProfileService, growth_profile_dict
from app.services.periodic_report_service import PeriodicReportService, periodic_report_user_dict
from app.services.periodic_report_lazy_service import PeriodicReportLazyService
router = APIRouter()
@router.get("/profile")
def profile(current_user: User = Depends(get_current_user)) -> dict:
return api_success(UserProfile.model_validate(current_user).model_dump(mode="json"))
def profile(
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
) -> dict:
data = UserProfile.model_validate(current_user).model_dump(mode="json")
view = EntitlementService.active_entitlement(db, current_user)
data["entitlement"] = entitlement_dict(view)
return api_success(data)
@router.get("/growth-profile")
def growth_profile(
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
) -> dict:
profile = GrowthProfileService.get_growth_profile(db, current_user.id)
summaries = GrowthProfileService.recent_topic_summaries(db, current_user.id, limit=10)
return api_success(
{
"profile": growth_profile_dict(profile),
"recentSummaries": [
{
"id": item.id,
"topicSessionId": item.topic_session_id,
"schemaVersion": item.schema_version,
"summary": item.summary,
"currentFocus": item.main_events,
"nextObservation": item.next_observation,
"status": item.status,
"errorMessage": item.error_message,
"attemptCount": item.attempt_count,
"maxAttempts": item.max_attempts,
"nextRunAt": item.next_run_at,
"lastStartedAt": item.last_started_at,
"finishedAt": item.finished_at,
"generatedAt": item.generated_at,
}
for item in summaries
],
}
)
@router.get("/periodic-report/list")
def periodic_reports(
limit: int = 20,
ensure: bool = Query(default=False),
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
) -> dict:
entitlement = EntitlementService.active_entitlement(db, current_user)
if not entitlement.enable_periodic_reports:
return api_success([])
if ensure:
PeriodicReportLazyService.enqueue_missing_reports(db, user=current_user)
db.commit()
reports = PeriodicReportService.list_user_reports(
db,
user_id=current_user.id,
limit=max(1, min(limit, 50)),
statuses=("pending", "running", "success", "failed", "empty"),
)
return api_success([periodic_report_user_dict(item) for item in reports])

View File

@@ -0,0 +1,27 @@
from __future__ import annotations
from fastapi import APIRouter, Depends, File, UploadFile
from sqlalchemy.orm import Session
from app.core.database import get_db
from app.core.dependencies import get_current_user
from app.core.responses import api_success
from app.models.user import User
from app.services.voice_input_service import MAX_UPLOAD_BYTES, VoiceInputService
router = APIRouter()
@router.get("/config")
def voice_config(db: Session = Depends(get_db), _user: User = Depends(get_current_user)) -> dict:
return api_success(VoiceInputService.public_config(db))
@router.post("/transcribe")
def transcribe(
audio: UploadFile = File(...),
db: Session = Depends(get_db),
user: User = Depends(get_current_user),
) -> dict:
content = audio.file.read(MAX_UPLOAD_BYTES + 1)
return api_success(VoiceInputService.transcribe(db, user.id, content, audio.content_type))

View File

@@ -0,0 +1,11 @@
from __future__ import annotations
AI_GENERATED_NOTICE = "AI生成内容请结合实际情况核对后使用。"
def ensure_ai_generated_notice(content: str | None) -> str:
text = (content or "").strip()
if not text or AI_GENERATED_NOTICE in text:
return text
return f"{text}\n\n—— {AI_GENERATED_NOTICE}"

View File

@@ -0,0 +1,21 @@
from __future__ import annotations
from dataclasses import dataclass
from app.models.user import User
@dataclass(frozen=True)
class ChatAccessScope:
source_type: str = "direct"
source_client_id: int | None = None
@classmethod
def direct(cls) -> "ChatAccessScope":
return cls()
@dataclass(frozen=True)
class UserAuthContext:
user: User
chat_scope: ChatAccessScope

View File

@@ -48,6 +48,10 @@ class Settings(BaseSettings):
aliyun_sms_template_code: str = ""
aliyun_sms_template_param_key: str = "code"
aliyun_sms_endpoint: str = "dysmsapi.aliyuncs.com"
aliyun_nls_app_key: str = ""
aliyun_nls_access_key_id: str = ""
aliyun_nls_access_key_secret: str = ""
aliyun_nls_endpoint: str = "https://nls-gateway-cn-shanghai.aliyuncs.com/stream/v1/asr"
mock_rag_enabled: bool = False
mock_model_enabled: bool = True
feishu_mock_enabled: bool = False
@@ -59,10 +63,39 @@ class Settings(BaseSettings):
default_daily_chat_limit: int = 100
default_user_name_prefix: str = "用户"
chat_max_active_requests: int = 2
chat_max_queue_size: int = 20
chat_queue_timeout_seconds: int = 60
chat_max_active_requests: int = 30
chat_max_queue_size: int = 90
chat_queue_timeout_seconds: int = 90
chat_active_lease_seconds: int = 900
voice_input_enabled: bool = False
voice_max_duration_seconds: int = 60
periodic_report_worker_enabled: bool = True
periodic_report_poll_seconds: int = 5
periodic_report_stale_minutes: int = 30
periodic_report_max_attempts: int = 3
periodic_report_weekly_enabled: bool = True
periodic_report_monthly_enabled: bool = True
periodic_report_timezone: str = "Asia/Shanghai"
periodic_report_global_schedule_enabled: bool = False
periodic_report_lazy_check_enabled: bool = True
periodic_report_lazy_check_lock_seconds: int = 60
periodic_report_weekly_backfill_limit: int = 26
periodic_report_monthly_backfill_limit: int = 6
periodic_report_feature_start: str = "2026-07-31T00:00:00+08:00"
periodic_report_source_chunk_chars: int = 18000
topic_settlement_worker_enabled: bool = True
topic_settlement_poll_seconds: int = 2
topic_settlement_stale_minutes: int = 30
topic_settlement_max_attempts: int = 3
agent_batch_worker_enabled: bool = True
agent_batch_poll_seconds: int = 2
agent_batch_stale_minutes: int = 30
agent_batch_worker_concurrency: int = 10
human_attention_worker_enabled: bool = True
human_attention_worker_poll_seconds: int = 2
human_attention_worker_stale_minutes: int = 30
human_attention_worker_max_attempts: int = 3
user_behavior_retention_days: int = 30
bootstrap_admin_username: str = ""
bootstrap_admin_password: str = ""
bootstrap_admin_name: str = "系统管理员"

View File

@@ -2,7 +2,7 @@ from __future__ import annotations
from collections.abc import Generator
from sqlalchemy import create_engine
from sqlalchemy import create_engine, event
from sqlalchemy.orm import Session, sessionmaker
from app.core.config import get_settings
@@ -20,6 +20,16 @@ if not settings.database_url.startswith("sqlite"):
pool_recycle=settings.db_pool_recycle,
)
engine = create_engine(settings.database_url, **engine_options)
if engine.dialect.name == "mysql":
@event.listens_for(engine, "connect")
def _set_mysql_session_timezone(dbapi_connection, _connection_record) -> None:
"""Keep CURRENT_TIMESTAMP and application-written values on one UTC contract."""
cursor = dbapi_connection.cursor()
try:
cursor.execute("SET time_zone = '+00:00'")
finally:
cursor.close()
SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False, future=True)
register_knowledge_cache_invalidation_events()

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