Compare commits
77 Commits
c3f24d0ccd
...
codex/h5-q
| Author | SHA1 | Date | |
|---|---|---|---|
| f4b2836c8a | |||
| 5740bd26e7 | |||
| 910e67bd89 | |||
| 6ffdd2c259 | |||
| efe835be81 | |||
| 833763c461 | |||
| 77399df060 | |||
| f952d6dc58 | |||
| 3faecab6ac | |||
| c4231f268b | |||
| 83b259af2d | |||
| ff4ba1148b | |||
| 71541f9a22 | |||
| 74213a363f | |||
| fd2da26d10 | |||
| 3b9215cc3d | |||
| c4de4e8c56 | |||
| cd3c875106 | |||
| 26109648af | |||
| 59c306f88f | |||
| 1954f461af | |||
| 8e6da99dcb | |||
| 8ee4d65b7b | |||
| 3f8ff30bbc | |||
| 8fb24f94ab | |||
| 861a0ae7d5 | |||
| b2b56f43bb | |||
| 20ed4875b8 | |||
| 7747334ea4 | |||
| d2e08ab36c | |||
| a3946013f5 | |||
| b6aff78aac | |||
| ab2c945f0b | |||
| 85a6da5949 | |||
| da313f88ed | |||
| 0008903e8d | |||
| a589a25bdc | |||
| 6c03faf10c | |||
| 99e23b33ec | |||
| 88ed92d78c | |||
| 763c78bd7e | |||
| a84650bb9e | |||
| ec9f8a015a | |||
| 4e872febd5 | |||
| 429636f3e8 | |||
| d8fde93b69 | |||
| 79094d0832 | |||
| 3e60a6da42 | |||
| 884dc765ad | |||
| ad2161497f | |||
| e426f12e22 | |||
| 1f82b89295 | |||
| 918ef5fa79 | |||
| 8e2dc1d781 | |||
| 162658384f | |||
| 77a899e24c | |||
| d1f573108e | |||
| c2655361b5 | |||
| 537978082a | |||
| cadf44e0b8 | |||
| 821cb26930 | |||
| d647a3d44b | |||
| 9173267e09 | |||
| c7bafda135 | |||
| a879dc2ff1 | |||
| bfaf2ebf67 | |||
| 13fed0467a | |||
| f7076569a7 | |||
| dde28aba83 | |||
| 6f07c70f1f | |||
| e3634eb26c | |||
| 9ba8fb6e0c | |||
| 921b56cfb9 | |||
| 1b5fcc4d52 | |||
| 6c02662d3b | |||
| 245b6b3eca | |||
| cbbdb41b80 |
@@ -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
|
||||
|
||||
3
ai_knowledge_base_v2/.gitignore
vendored
3
ai_knowledge_base_v2/.gitignore
vendored
@@ -1 +1,2 @@
|
||||
.env
|
||||
.env
|
||||
outputs/
|
||||
|
||||
138
ai_knowledge_base_v2/apps/admin-web/package-lock.json
generated
138
ai_knowledge_base_v2/apps/admin-web/package-lock.json
generated
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -0,0 +1,109 @@
|
||||
<script setup lang="ts">
|
||||
import { ElMessage, ElMessageBox, type ElTree } from "element-plus";
|
||||
import { nextTick, onMounted, reactive, ref } from "vue";
|
||||
import { api } from "../services/api";
|
||||
import type { ManagedAdmin, PermissionNode } from "../types/api";
|
||||
|
||||
const admins = ref<ManagedAdmin[]>([]);
|
||||
const tree = ref<PermissionNode[]>([]);
|
||||
const permissionTree = ref<InstanceType<typeof ElTree>>();
|
||||
const dialogVisible = ref(false);
|
||||
const saving = ref(false);
|
||||
const editingId = ref<number | null>(null);
|
||||
const form = reactive({ username: "", name: "", initialPassword: "", resetPassword: "", status: 1 });
|
||||
|
||||
async function load() {
|
||||
[admins.value, tree.value] = await Promise.all([api.administrators(), api.permissionTree()]);
|
||||
}
|
||||
|
||||
function openCreate() {
|
||||
editingId.value = null;
|
||||
Object.assign(form, { username: "", name: "", initialPassword: "", resetPassword: "", status: 1 });
|
||||
dialogVisible.value = true;
|
||||
nextTick(() => permissionTree.value?.setCheckedKeys([]));
|
||||
}
|
||||
|
||||
function openEdit(item: ManagedAdmin) {
|
||||
editingId.value = item.id;
|
||||
Object.assign(form, { username: item.username, name: item.name, initialPassword: "", resetPassword: "", status: item.status });
|
||||
dialogVisible.value = true;
|
||||
nextTick(() => permissionTree.value?.setCheckedKeys(item.permissions));
|
||||
}
|
||||
|
||||
function selectedPermissions() {
|
||||
return (permissionTree.value?.getCheckedKeys(false) ?? []).map(String).filter((code) => code.includes("."));
|
||||
}
|
||||
|
||||
async function save() {
|
||||
if (!form.name.trim() || (!editingId.value && (!form.username.trim() || form.initialPassword.length < 8))) {
|
||||
ElMessage.warning("请完整填写账号、姓名和至少 8 位的初始密码");
|
||||
return;
|
||||
}
|
||||
saving.value = true;
|
||||
try {
|
||||
const permissions = selectedPermissions();
|
||||
if (editingId.value) {
|
||||
await api.updateAdministrator(editingId.value, { name: form.name, status: form.status, permissions, resetPassword: form.resetPassword || null });
|
||||
} else {
|
||||
await api.createAdministrator({ username: form.username, name: form.name, initialPassword: form.initialPassword, status: form.status, permissions });
|
||||
}
|
||||
ElMessage.success(editingId.value ? "管理员已更新" : "管理员已创建");
|
||||
dialogVisible.value = false;
|
||||
await load();
|
||||
} catch (error) {
|
||||
ElMessage.error(error instanceof Error ? error.message : "保存失败");
|
||||
} finally { saving.value = false; }
|
||||
}
|
||||
|
||||
async function remove(item: ManagedAdmin) {
|
||||
await ElMessageBox.confirm(`确定删除管理员“${item.name}”?删除后将无法登录。`, "删除管理员", { type: "warning", confirmButtonText: "确定删除" });
|
||||
await api.deleteAdministrator(item.id);
|
||||
ElMessage.success("管理员已删除");
|
||||
await load();
|
||||
}
|
||||
|
||||
onMounted(load);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="admin-management">
|
||||
<div class="page-head inline">
|
||||
<div><h2>管理员与权限</h2><p>按页面和功能分配权限;未授权的页面不会出现,后端也会拒绝越权请求。</p></div>
|
||||
<el-button type="primary" @click="openCreate">+新增管理员</el-button>
|
||||
</div>
|
||||
<el-card shadow="never">
|
||||
<el-table :data="admins" stripe>
|
||||
<el-table-column prop="name" label="管理员" min-width="150" />
|
||||
<el-table-column prop="username" label="登录账号" min-width="160" />
|
||||
<el-table-column label="类型" width="130"><template #default="{ row }"><el-tag :type="row.isSuperAdmin ? 'danger' : 'info'">{{ row.isSuperAdmin ? '超级管理员' : '普通管理员' }}</el-tag></template></el-table-column>
|
||||
<el-table-column label="权限" min-width="150"><template #default="{ row }">{{ row.isSuperAdmin ? '全部权限' : `${row.permissions.length} 项功能` }}</template></el-table-column>
|
||||
<el-table-column label="状态" width="120"><template #default="{ row }"><el-tag :type="row.status === 1 ? 'success' : 'info'">{{ row.status === 1 ? '正常' : '已停用' }}</el-tag></template></el-table-column>
|
||||
<el-table-column label="密码状态" width="150"><template #default="{ row }">{{ row.isSuperAdmin ? '环境变量托管' : (row.mustChangePassword ? '待首次修改' : '已修改') }}</template></el-table-column>
|
||||
<el-table-column label="操作" width="150" fixed="right"><template #default="{ row }"><template v-if="!row.isSuperAdmin"><el-button link type="primary" @click="openEdit(row)">编辑</el-button><el-button link type="danger" @click="remove(row)">删除</el-button></template><span v-else class="locked">系统托管</span></template></el-table-column>
|
||||
</el-table>
|
||||
</el-card>
|
||||
|
||||
<el-dialog v-model="dialogVisible" class="admin-editor-dialog" :title="editingId ? '编辑管理员' : '新增管理员'" width="820px" destroy-on-close>
|
||||
<el-form class="admin-editor-form" label-position="top">
|
||||
<section class="form-section">
|
||||
<div class="section-title"><strong>基本信息</strong><span>用于登录和识别管理员</span></div>
|
||||
<div class="form-grid">
|
||||
<el-form-item label="登录账号"><el-input v-model="form.username" :disabled="!!editingId" placeholder="英文、数字、点、下划线或短横线" /></el-form-item>
|
||||
<el-form-item label="管理员姓名"><el-input v-model="form.name" placeholder="请输入真实姓名或职位名称" /></el-form-item>
|
||||
<el-form-item :label="editingId ? '重置密码(可选)' : '初始密码'"><el-input v-if="editingId" v-model="form.resetPassword" show-password placeholder="留空表示不重置" /><el-input v-else v-model="form.initialPassword" show-password placeholder="至少 8 位,首次登录后强制修改" /></el-form-item>
|
||||
<el-form-item label="账号状态"><div class="status-control"><el-switch v-model="form.status" :active-value="1" :inactive-value="0" /><span>{{ form.status === 1 ? '正常,可以登录' : '已停用,无法登录' }}</span></div></el-form-item>
|
||||
</div>
|
||||
</section>
|
||||
<section class="form-section permission-section">
|
||||
<div class="section-title"><strong>功能权限</strong><span>勾选一级菜单可快速全选,也可逐项精细分配</span></div>
|
||||
<div class="permission-box"><el-tree ref="permissionTree" :data="tree.filter(n => !n.superOnly)" node-key="code" show-checkbox default-expand-all :props="{ label: 'name', children: 'children' }" /></div>
|
||||
</section>
|
||||
</el-form>
|
||||
<template #footer><el-button @click="dialogVisible = false">取消</el-button><el-button type="primary" :loading="saving" @click="save">保存管理员</el-button></template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.admin-editor-form{display:grid;gap:22px}.form-section{padding:18px 20px;border:1px solid #e3e9e6;border-radius:12px;background:#fff}.section-title{display:flex;align-items:baseline;gap:10px;margin-bottom:18px}.section-title strong{font-size:16px}.section-title span{color:#7c8d87;font-size:13px}.form-grid{display:grid;grid-template-columns:1fr 1fr;column-gap:24px;row-gap:18px}.admin-editor-form :deep(.el-form-item){margin-bottom:0}.admin-editor-form :deep(.el-form-item__label){height:auto;padding:0 0 8px;color:#465650;font-weight:600;line-height:1.4}.status-control{display:flex;align-items:center;gap:10px;height:40px}.status-control span{color:#687a74;font-size:13px}.permission-section{background:#f8faf9}.permission-box{width:100%;max-height:390px;overflow:auto;padding:12px 14px;border:1px solid #dfe6e3;border-radius:9px;background:#fff}.locked{color:#909399}.admin-management :deep(.el-tree-node__content){height:34px;border-radius:6px}.admin-management :deep(.el-tree-node__content:hover){background:#eef6f3}.admin-management :deep(.el-dialog__body){max-height:calc(100vh - 190px);padding:20px 24px;overflow:auto}.admin-management :deep(.el-dialog__header){padding:22px 24px 16px;border-bottom:1px solid #edf1ef}.admin-management :deep(.el-dialog__footer){padding:16px 24px 20px;border-top:1px solid #edf1ef}@media(max-width:760px){.form-grid{grid-template-columns:1fr}.section-title{align-items:flex-start;flex-direction:column;gap:4px}}
|
||||
</style>
|
||||
@@ -0,0 +1,242 @@
|
||||
<script setup lang="ts">
|
||||
import { ElMessage, ElMessageBox } from "element-plus";
|
||||
import { computed, onBeforeUnmount, onMounted, reactive, ref } from "vue";
|
||||
|
||||
import { api } from "../services/api";
|
||||
import type { AgentBatchItem, AgentBatchJob, AgentBatchJobDetail } from "../types/api";
|
||||
import AdminPagination from "./AdminPagination.vue";
|
||||
import { formatDateTime } from "../utils/dateTime";
|
||||
|
||||
const props = defineProps<{
|
||||
config: Record<string, unknown>;
|
||||
modelName: string;
|
||||
knowledgeSummary: string;
|
||||
}>();
|
||||
|
||||
const fileInput = ref<HTMLInputElement | null>(null);
|
||||
const selectedFile = ref<File | null>(null);
|
||||
const taskName = ref("");
|
||||
const executionMode = ref<"sequential" | "concurrent">("sequential");
|
||||
const concurrencyLimit = ref(5);
|
||||
const creating = ref(false);
|
||||
const loading = ref(false);
|
||||
const exportingId = ref<number | null>(null);
|
||||
const jobs = ref<AgentBatchJob[]>([]);
|
||||
const pager = reactive({ page: 1, pageSize: 10, total: 0 });
|
||||
const detailOpen = ref(false);
|
||||
const detailLoading = ref(false);
|
||||
const selectedJob = ref<AgentBatchJobDetail | null>(null);
|
||||
const detailPager = reactive({ page: 1, pageSize: 20, total: 0 });
|
||||
let pollTimer: number | null = null;
|
||||
|
||||
const hasRunningJob = computed(() => jobs.value.some((job) => ["pending", "running"].includes(job.status)));
|
||||
|
||||
onMounted(() => void loadJobs());
|
||||
onBeforeUnmount(stopPolling);
|
||||
|
||||
async function loadJobs(page = pager.page, pageSize = pager.pageSize, quiet = false) {
|
||||
if (!quiet) loading.value = true;
|
||||
try {
|
||||
const result = await api.agentBatchJobs({ page, pageSize });
|
||||
jobs.value = result.items;
|
||||
Object.assign(pager, { page: result.page, pageSize: result.pageSize, total: result.total });
|
||||
schedulePolling();
|
||||
if (selectedJob.value && detailOpen.value) await loadDetail(selectedJob.value.id, detailPager.page, detailPager.pageSize, true);
|
||||
} catch (error) {
|
||||
if (!quiet) ElMessage.error(errorMessage(error, "批量测试任务加载失败"));
|
||||
} finally {
|
||||
if (!quiet) loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function schedulePolling() {
|
||||
stopPolling();
|
||||
if (!hasRunningJob.value) return;
|
||||
pollTimer = window.setTimeout(() => void loadJobs(pager.page, pager.pageSize, true), 2500);
|
||||
}
|
||||
|
||||
function stopPolling() {
|
||||
if (pollTimer !== null) window.clearTimeout(pollTimer);
|
||||
pollTimer = null;
|
||||
}
|
||||
|
||||
function selectFile(event: Event) {
|
||||
const input = event.target as HTMLInputElement;
|
||||
selectedFile.value = input.files?.[0] || null;
|
||||
if (selectedFile.value && !taskName.value.trim()) taskName.value = selectedFile.value.name.replace(/\.xlsx$/i, "");
|
||||
}
|
||||
|
||||
function clearFile() {
|
||||
selectedFile.value = null;
|
||||
if (fileInput.value) fileInput.value.value = "";
|
||||
}
|
||||
|
||||
async function downloadTemplate() {
|
||||
try {
|
||||
await api.downloadAgentBatchTemplate();
|
||||
} catch (error) {
|
||||
ElMessage.error(errorMessage(error, "模板下载失败"));
|
||||
}
|
||||
}
|
||||
|
||||
async function createJob() {
|
||||
if (!selectedFile.value) return ElMessage.warning("请先选择填写好的 Excel 文件");
|
||||
if (!String(props.config.modelId || "")) return ElMessage.warning("请先在调试配置中选择模型");
|
||||
creating.value = true;
|
||||
try {
|
||||
const created = await api.createAgentBatch(selectedFile.value, {
|
||||
...props.config,
|
||||
name: taskName.value.trim(),
|
||||
executionMode: executionMode.value,
|
||||
concurrencyLimit: executionMode.value === "sequential" ? 1 : concurrencyLimit.value,
|
||||
});
|
||||
ElMessage.success(`已创建批量测试任务,共 ${created.totalCount} 个问题`);
|
||||
clearFile();
|
||||
taskName.value = "";
|
||||
await loadJobs(1, pager.pageSize);
|
||||
await openDetail(created);
|
||||
} catch (error) {
|
||||
ElMessage.error(errorMessage(error, "批量测试任务创建失败"));
|
||||
} finally {
|
||||
creating.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function openDetail(job: AgentBatchJob) {
|
||||
detailOpen.value = true;
|
||||
detailPager.page = 1;
|
||||
await loadDetail(job.id, 1, detailPager.pageSize);
|
||||
}
|
||||
|
||||
async function loadDetail(jobId: number, page = detailPager.page, pageSize = detailPager.pageSize, quiet = false) {
|
||||
if (!quiet) detailLoading.value = true;
|
||||
try {
|
||||
const result = await api.agentBatchJob(jobId, { page, pageSize });
|
||||
selectedJob.value = result;
|
||||
Object.assign(detailPager, { page: result.items.page, pageSize: result.items.pageSize, total: result.items.total });
|
||||
} catch (error) {
|
||||
if (!quiet) ElMessage.error(errorMessage(error, "任务详情加载失败"));
|
||||
} finally {
|
||||
if (!quiet) detailLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function exportJob(job: AgentBatchJob) {
|
||||
exportingId.value = job.id;
|
||||
try {
|
||||
await api.exportAgentBatch(job.id);
|
||||
ElMessage.success("测试结果已导出");
|
||||
} catch (error) {
|
||||
ElMessage.error(errorMessage(error, "结果导出失败"));
|
||||
} finally {
|
||||
exportingId.value = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function cancelJob(job: AgentBatchJob) {
|
||||
try {
|
||||
await ElMessageBox.confirm("取消后尚未开始的问题不会继续生成,已完成结果仍可导出。", "取消批量测试", {
|
||||
type: "warning", confirmButtonText: "确认取消", cancelButtonText: "继续执行",
|
||||
});
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await api.cancelAgentBatch(job.id);
|
||||
ElMessage.success("批量测试已取消");
|
||||
await loadJobs(pager.page, pager.pageSize);
|
||||
} catch (error) {
|
||||
ElMessage.error(errorMessage(error, "取消失败"));
|
||||
}
|
||||
}
|
||||
|
||||
function statusLabel(status: string) {
|
||||
return ({ pending: "等待执行", running: "生成中", completed: "已完成", completed_with_errors: "部分失败", cancelled: "已取消", success: "成功", failed: "失败" } as Record<string, string>)[status] || status;
|
||||
}
|
||||
|
||||
function statusType(status: string) {
|
||||
if (["completed", "success"].includes(status)) return "success";
|
||||
if (["completed_with_errors", "failed"].includes(status)) return "danger";
|
||||
if (status === "running") return "primary";
|
||||
if (status === "cancelled") return "info";
|
||||
return "warning";
|
||||
}
|
||||
|
||||
function executionLabel(job: AgentBatchJob) {
|
||||
return job.executionMode === "concurrent" ? `并发 · ${job.concurrencyLimit} 路` : "逐条串行";
|
||||
}
|
||||
|
||||
function itemDuration(item: AgentBatchItem) {
|
||||
return item.durationMs == null ? "—" : `${(item.durationMs / 1000).toFixed(1)} 秒`;
|
||||
}
|
||||
|
||||
function errorMessage(error: unknown, fallback: string) {
|
||||
return error instanceof Error ? error.message : fallback;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="agent-batch-panel">
|
||||
<div class="agent-section-intro">
|
||||
<div><h3>Excel 批量测试</h3><p>普通管理员仅看到自己的任务,超级管理员可统一审计;创建时会固化当前提示词、模型、知识库和生成参数。</p></div>
|
||||
<el-button @click="downloadTemplate">下载导入模板</el-button>
|
||||
</div>
|
||||
|
||||
<section class="agent-batch-create-card">
|
||||
<div class="agent-batch-config-summary">
|
||||
<span><small>模型</small><strong>{{ modelName }}</strong></span>
|
||||
<span><small>知识库</small><strong>{{ knowledgeSummary }}</strong></span>
|
||||
<span><small>单次上限</small><strong>200 个问题</strong></span>
|
||||
</div>
|
||||
<div class="agent-batch-mode-setting">
|
||||
<div class="agent-batch-mode-copy"><strong>执行方式</strong><span>串行适合日常回归;并发适合压测吞吐与稳定性,并发数会被系统安全上限约束。</span></div>
|
||||
<el-radio-group v-model="executionMode" class="agent-batch-mode-options">
|
||||
<el-radio-button value="sequential">逐条串行</el-radio-button>
|
||||
<el-radio-button value="concurrent">并发测试</el-radio-button>
|
||||
</el-radio-group>
|
||||
<label v-if="executionMode === 'concurrent'" class="agent-batch-concurrency"><span>并发数</span><el-input-number v-model="concurrencyLimit" :min="2" :max="10" controls-position="right" /></label>
|
||||
</div>
|
||||
<div class="agent-batch-upload-row">
|
||||
<label class="agent-batch-name"><span>任务名称</span><input v-model="taskName" maxlength="160" placeholder="例如:8月主提示词回归测试" /></label>
|
||||
<label class="agent-batch-file" :class="{ selected: selectedFile }">
|
||||
<input ref="fileInput" type="file" accept=".xlsx" @change="selectFile" />
|
||||
<span>{{ selectedFile?.name || "选择填写好的 Excel 文件" }}</span>
|
||||
<small>{{ selectedFile ? `${(selectedFile.size / 1024).toFixed(1)} KB` : "仅支持 .xlsx,最大 5MB" }}</small>
|
||||
</label>
|
||||
<el-button v-if="selectedFile" @click="clearFile">移除</el-button>
|
||||
<el-button type="primary" :loading="creating" :disabled="!selectedFile" @click="createJob">开始批量测试</el-button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="agent-batch-list-head"><div><h4>测试记录</h4><p>任务在后台执行,离开页面不会中断。</p></div><el-button :loading="loading" @click="loadJobs()">刷新</el-button></div>
|
||||
<el-table v-loading="loading" :data="jobs" stripe>
|
||||
<el-table-column label="任务" min-width="190"><template #default="{ row }"><strong>{{ row.name }}</strong><small class="agent-batch-cell-note">#{{ row.id }} · {{ formatDateTime(row.createdAt, "—") }}</small></template></el-table-column>
|
||||
<el-table-column label="配置快照" min-width="180"><template #default="{ row }"><span>{{ row.modelName }}</span><small class="agent-batch-cell-note">{{ row.knowledgeNames.join('、') }}</small></template></el-table-column>
|
||||
<el-table-column label="进度" min-width="190"><template #default="{ row }"><el-progress :percentage="row.progress" :status="row.status === 'completed' ? 'success' : undefined" /><small class="agent-batch-cell-note">{{ row.processedCount }}/{{ row.totalCount }} · 成功 {{ row.successCount }} · 失败 {{ row.failedCount }}</small></template></el-table-column>
|
||||
<el-table-column label="执行方式" width="118"><template #default="{ row }"><el-tag type="info">{{ executionLabel(row) }}</el-tag></template></el-table-column>
|
||||
<el-table-column label="状态" width="108"><template #default="{ row }"><el-tag :type="statusType(row.status)">{{ statusLabel(row.status) }}</el-tag></template></el-table-column>
|
||||
<el-table-column label="操作" width="190" fixed="right"><template #default="{ row }"><el-button link type="primary" @click="openDetail(row)">查看</el-button><el-button link type="primary" :loading="exportingId === row.id" @click="exportJob(row)">导出</el-button><el-button v-if="['pending','running'].includes(row.status)" link type="danger" @click="cancelJob(row)">取消</el-button></template></el-table-column>
|
||||
</el-table>
|
||||
<AdminPagination :page="pager.page" :page-size="pager.pageSize" :total="pager.total" @change="loadJobs" />
|
||||
</div>
|
||||
|
||||
<el-dialog v-model="detailOpen" width="min(1100px, 94vw)" title="批量测试详情" destroy-on-close>
|
||||
<div v-loading="detailLoading" class="agent-batch-detail">
|
||||
<template v-if="selectedJob">
|
||||
<div class="agent-batch-detail-head">
|
||||
<div><strong>{{ selectedJob.name }}</strong><span>{{ selectedJob.modelName }} · {{ selectedJob.knowledgeNames.join('、') }} · {{ executionLabel(selectedJob) }}</span></div>
|
||||
<el-tag :type="statusType(selectedJob.status)">{{ statusLabel(selectedJob.status) }}</el-tag>
|
||||
</div>
|
||||
<el-table :data="selectedJob.items.items" stripe max-height="520">
|
||||
<el-table-column prop="externalNo" label="序号" width="76" />
|
||||
<el-table-column prop="question" label="问题" min-width="230" show-overflow-tooltip />
|
||||
<el-table-column label="答案" min-width="360"><template #default="{ row }"><div class="agent-batch-answer">{{ row.answer || row.errorMessage || statusLabel(row.status) }}</div></template></el-table-column>
|
||||
<el-table-column label="状态" width="90"><template #default="{ row }"><el-tag size="small" :type="statusType(row.status)">{{ statusLabel(row.status) }}</el-tag></template></el-table-column>
|
||||
<el-table-column label="耗时" width="92"><template #default="{ row }">{{ itemDuration(row) }}</template></el-table-column>
|
||||
</el-table>
|
||||
<AdminPagination :page="detailPager.page" :page-size="detailPager.pageSize" :total="detailPager.total" @change="(page, pageSize) => loadDetail(selectedJob!.id, page, pageSize)" />
|
||||
</template>
|
||||
</div>
|
||||
<template #footer><el-button @click="detailOpen = false">关闭</el-button><el-button v-if="selectedJob" type="primary" :loading="exportingId === selectedJob.id" @click="exportJob(selectedJob)">导出 Excel</el-button></template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
@@ -0,0 +1,49 @@
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
temperature: number | null;
|
||||
topP: number | null;
|
||||
topK: number | null;
|
||||
presencePenalty: number | null;
|
||||
frequencyPenalty: number | null;
|
||||
maxToken: number | null;
|
||||
disabled?: boolean;
|
||||
}>();
|
||||
|
||||
defineEmits<{
|
||||
"update:temperature": [value: number | null];
|
||||
"update:topP": [value: number | null];
|
||||
"update:topK": [value: number | null];
|
||||
"update:presencePenalty": [value: number | null];
|
||||
"update:frequencyPenalty": [value: number | null];
|
||||
"update:maxToken": [value: number | null];
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="agent-config-grid">
|
||||
<el-form-item>
|
||||
<template #label><span class="agent-parameter-label">回答随机性<el-tooltip placement="top" :show-after="200" content="取值 0–2。数值越小,回答越稳定、一致;数值越大,表达越多样,但也更容易发散。留空表示使用模型供应商默认值;做同题对比时建议设为 0。"><button type="button" class="agent-parameter-help" aria-label="回答随机性说明">?</button></el-tooltip></span></template>
|
||||
<el-input-number :model-value="temperature" :disabled="disabled" :min="0" :max="2" :step="0.1" @update:model-value="$emit('update:temperature', $event)" />
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<template #label><span class="agent-parameter-label">核采样概率<el-tooltip placement="top" :show-after="200" content="取值 0–1。模型只从累计概率达到该数值的候选词中选择。数值越小越专注,越大越多样。留空表示使用模型供应商默认值。"><button type="button" class="agent-parameter-help" aria-label="核采样概率说明">?</button></el-tooltip></span></template>
|
||||
<el-input-number :model-value="topP" :disabled="disabled" :min="0" :max="1" :step="0.05" @update:model-value="$emit('update:topP', $event)" />
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<template #label><span class="agent-parameter-label">候选词数量<el-tooltip placement="top" :show-after="200" content="取值 1–1000。每次生成只从概率最高的前 K 个候选词中选择。数值越小越保守,越大选择越多。留空表示使用供应商默认值,部分模型可能不支持。"><button type="button" class="agent-parameter-help" aria-label="候选词数量说明">?</button></el-tooltip></span></template>
|
||||
<el-input-number :model-value="topK" :disabled="disabled" :min="1" :max="1000" @update:model-value="$emit('update:topK', $event)" />
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<template #label><span class="agent-parameter-label">最大输出长度<el-tooltip placement="top" :show-after="200" content="限制模型本次最多生成的 Token 数,不等于字数。建议正式答疑使用 4096–8192;数值越大允许回答越长,也会增加耗时和费用,最终仍受模型自身上限约束。"><button type="button" class="agent-parameter-help" aria-label="最大输出长度说明">?</button></el-tooltip></span></template>
|
||||
<el-input-number :model-value="maxToken" :disabled="disabled" :min="256" :max="100000" :step="256" @update:model-value="$emit('update:maxToken', $event)" />
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<template #label><span class="agent-parameter-label">主题重复惩罚<el-tooltip placement="top" :show-after="200" content="取值 -2–2,0 表示不调整。正数会降低已出现内容再次出现的概率,鼓励引入新主题;负数会更容易重复已出现内容。数值过高可能导致回答跳跃。"><button type="button" class="agent-parameter-help" aria-label="主题重复惩罚说明">?</button></el-tooltip></span></template>
|
||||
<el-input-number :model-value="presencePenalty" :disabled="disabled" :min="-2" :max="2" :step="0.1" @update:model-value="$emit('update:presencePenalty', $event)" />
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<template #label><span class="agent-parameter-label">高频重复惩罚<el-tooltip placement="top" :show-after="200" content="取值 -2–2,0 表示不调整。正数会根据词语已出现的次数降低其再次出现的概率,用于减少反复表达;负数会增加重复倾向。数值过高可能影响语句连贯性。"><button type="button" class="agent-parameter-help" aria-label="高频重复惩罚说明">?</button></el-tooltip></span></template>
|
||||
<el-input-number :model-value="frequencyPenalty" :disabled="disabled" :min="-2" :max="2" :step="0.1" @update:model-value="$emit('update:frequencyPenalty', $event)" />
|
||||
</el-form-item>
|
||||
</div>
|
||||
</template>
|
||||
@@ -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="取值 0–2。数值越小,回答越稳定、一致;数值越大,表达越多样,但也更容易发散。答疑场景建议使用较低数值。"><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="取值 0–1。模型只从累计概率达到该数值的候选词中选择。数值越小越专注,越大越多样。留空表示使用模型供应商默认值。"><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="取值 1–1000。每次生成只从概率最高的前 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="取值 -2–2,0 表示不调整。正数会降低已出现内容再次出现的概率,鼓励引入新主题;负数会更容易重复已出现内容。数值过高可能导致回答跳跃。"><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="取值 -2–2,0 表示不调整。正数会根据词语已出现的次数降低其再次出现的概率,用于减少反复表达;负数会增加重复倾向。数值过高可能影响语句连贯性。"><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>
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
<script setup lang="ts">
|
||||
const props = defineProps<{
|
||||
modelValue: number;
|
||||
disabled?: boolean;
|
||||
}>();
|
||||
|
||||
defineEmits<{
|
||||
"update:modelValue": [value: number];
|
||||
}>();
|
||||
|
||||
function depthLabel(value: number) {
|
||||
if (value <= 33) return "精简";
|
||||
if (value <= 66) return "平衡";
|
||||
return "深入";
|
||||
}
|
||||
|
||||
function depthCopy(value: number) {
|
||||
if (value <= 33) return "少分析、短建议,优先回到当下和身体感受。";
|
||||
if (value <= 66) return "保留必要解释,给出清楚但不过度展开的建议。";
|
||||
return "允许更完整拆解,但仍保持回归自身、觉察和释放的方向。";
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<el-form-item class="agent-response-depth">
|
||||
<div class="agent-response-depth-head">
|
||||
<div class="agent-stream-setting-copy">
|
||||
<strong>回答深度</strong>
|
||||
<span>{{ depthCopy(props.modelValue) }}</span>
|
||||
</div>
|
||||
<el-tag effect="plain">{{ depthLabel(props.modelValue) }} {{ props.modelValue }}</el-tag>
|
||||
</div>
|
||||
<el-slider
|
||||
:model-value="props.modelValue"
|
||||
:disabled="disabled"
|
||||
:min="0"
|
||||
:max="100"
|
||||
:step="5"
|
||||
:marks="{ 0: '精简', 50: '平衡', 100: '深入' }"
|
||||
@update:model-value="$emit('update:modelValue', Number($event))"
|
||||
/>
|
||||
</el-form-item>
|
||||
</template>
|
||||
@@ -0,0 +1,126 @@
|
||||
<script setup lang="ts">
|
||||
import { ElMessage } from "element-plus";
|
||||
import { ref, watch } from "vue";
|
||||
import { api } from "../services/api";
|
||||
import type { AiLogRecord } from "../types/api";
|
||||
import { formatDateTime } from "../utils/dateTime";
|
||||
|
||||
const props = defineProps<{ modelValue: boolean; logId: number | null }>();
|
||||
const emit = defineEmits<{ "update:modelValue": [value: boolean] }>();
|
||||
const detail = ref<AiLogRecord | null>(null);
|
||||
const loading = ref(false);
|
||||
|
||||
watch(
|
||||
() => [props.modelValue, props.logId] as const,
|
||||
async ([open, logId]) => {
|
||||
if (!open || !logId) return;
|
||||
detail.value = null;
|
||||
loading.value = true;
|
||||
try {
|
||||
detail.value = await api.aiLogDetail(logId);
|
||||
} catch (error) {
|
||||
ElMessage.error(
|
||||
error instanceof Error ? error.message : "AI 请求详情加载失败",
|
||||
);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
function formatMoney(value?: number | null, currency = "CNY") {
|
||||
if (typeof value !== "number") return "-";
|
||||
return `${currency || "CNY"} ${value.toFixed(6)}`;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<el-drawer
|
||||
:model-value="modelValue"
|
||||
size="820px"
|
||||
title="AI 请求详情"
|
||||
@update:model-value="emit('update:modelValue', $event)"
|
||||
>
|
||||
<div v-loading="loading">
|
||||
<template v-if="detail">
|
||||
<section class="chat-summary">
|
||||
<div>
|
||||
<span>请求ID</span><strong>{{ detail.id }}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>会话ID</span><strong>{{ detail.sessionId || "-" }}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>消息ID</span><strong>{{ detail.messageId || "-" }}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>用户ID</span><strong>{{ detail.userId || "-" }}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>模型</span><strong>{{ detail.modelName || "-" }}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>状态</span><strong>{{ detail.status }}</strong>
|
||||
</div>
|
||||
</section>
|
||||
<section class="audit-detail-grid">
|
||||
<span>知识库:{{ detail.knowledgeIds || "-" }}</span
|
||||
><span>命中数:{{ detail.retrieveCount }}</span
|
||||
><span>片段数:{{ detail.retrievedChunks?.length || 0 }}</span
|
||||
><span>输入 Token:{{ detail.inputToken || "-" }}</span
|
||||
><span>输出 Token:{{ detail.outputToken || "-" }}</span
|
||||
><span>总 Token:{{ detail.totalToken || "-" }}</span
|
||||
><span
|
||||
>估算成本:{{
|
||||
formatMoney(detail.estimatedCost, detail.currency || "CNY")
|
||||
}}</span
|
||||
><span>问题类型:{{ detail.questionType || "-" }}</span
|
||||
><span>知识命中:{{ detail.knowledgeHit ? "是" : "否" }}</span
|
||||
><span>路由原因:{{ detail.routeReason || "-" }}</span
|
||||
><span>耗时:{{ detail.costMs || "-" }}ms</span
|
||||
><span>时间:{{ formatDateTime(detail.createdAt) }}</span>
|
||||
</section>
|
||||
<p v-if="detail.errorMessage" class="error-text">
|
||||
{{ detail.errorMessage }}
|
||||
</p>
|
||||
<h3 class="detail-title">召回知识片段</h3>
|
||||
<section v-if="detail.retrievedChunks?.length" class="retrieval-chunks">
|
||||
<article
|
||||
v-for="chunk in detail.retrievedChunks"
|
||||
:key="`${detail.id}-${chunk.index}`"
|
||||
class="retrieval-chunk"
|
||||
>
|
||||
<div class="chunk-head">
|
||||
<strong
|
||||
>片段 {{ chunk.index }}/{{
|
||||
detail.retrievedChunks.length
|
||||
}}</strong
|
||||
><span>{{ chunk.knowledgeName || "未知知识库" }}</span
|
||||
><span v-if="chunk.knowledgeId"
|
||||
>知识库ID:{{ chunk.knowledgeId }}</span
|
||||
>
|
||||
</div>
|
||||
<div class="chunk-title">{{ chunk.title || "未命名片段" }}</div>
|
||||
<pre>{{ chunk.content }}</pre>
|
||||
<a
|
||||
v-if="chunk.sourceUrl"
|
||||
:href="chunk.sourceUrl"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>查看来源</a
|
||||
>
|
||||
</article>
|
||||
</section>
|
||||
<el-empty
|
||||
v-else
|
||||
description="本条请求未保存召回片段"
|
||||
:image-size="72"
|
||||
/>
|
||||
<h3 class="detail-title">完整 Prompt</h3>
|
||||
<pre class="prompt-preview">{{
|
||||
detail.prompt || "无 Prompt 记录"
|
||||
}}</pre>
|
||||
</template>
|
||||
</div>
|
||||
</el-drawer>
|
||||
</template>
|
||||
@@ -0,0 +1,208 @@
|
||||
<script setup lang="ts">
|
||||
import { ElMessage, ElMessageBox } from "element-plus";
|
||||
import { onMounted, reactive, ref } from "vue";
|
||||
import { api } from "../services/api";
|
||||
import type { AttentionItem } from "../types/api";
|
||||
import AdminPagination from "./AdminPagination.vue";
|
||||
import AttentionRuleConfigPanel from "./AttentionRuleConfigPanel.vue";
|
||||
import ChatDetailDrawer from "./ChatDetailDrawer.vue";
|
||||
import { formatDateTime, toUtcIsoFromBusinessDate } from "../utils/dateTime";
|
||||
|
||||
defineProps<{ canConfig: boolean; canPreview: boolean }>();
|
||||
|
||||
const loading = ref(false);
|
||||
const records = ref<AttentionItem[]>([]);
|
||||
const pager = reactive({ page: 1, pageSize: 20, total: 0 });
|
||||
const filters = reactive({ priority: "", dateFrom: "", dateTo: "" });
|
||||
const chatDetailOpen = ref(false);
|
||||
const sessionId = ref<number | null>(null);
|
||||
const focusMessageId = ref<number | null>(null);
|
||||
onMounted(() => loadRecords());
|
||||
async function loadRecords(page = 1, pageSize = pager.pageSize) {
|
||||
loading.value = true;
|
||||
try {
|
||||
const result = await api.attentionList({
|
||||
priority: filters.priority || undefined,
|
||||
dateFrom: toUtcIsoFromBusinessDate(filters.dateFrom, "start"),
|
||||
dateTo: toUtcIsoFromBusinessDate(filters.dateTo, "end"),
|
||||
page,
|
||||
pageSize,
|
||||
});
|
||||
records.value = result.items;
|
||||
Object.assign(pager, {
|
||||
page: result.page,
|
||||
pageSize: result.pageSize,
|
||||
total: result.total,
|
||||
});
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
function applyFilters() {
|
||||
if (filters.dateFrom && filters.dateTo && filters.dateFrom > filters.dateTo) {
|
||||
ElMessage.warning("开始日期不能晚于结束日期");
|
||||
return;
|
||||
}
|
||||
void loadRecords(1, pager.pageSize);
|
||||
}
|
||||
function resetFilters() {
|
||||
Object.assign(filters, { priority: "", dateFrom: "", dateTo: "" });
|
||||
void loadRecords(1, pager.pageSize);
|
||||
}
|
||||
async function updateStatus(row: AttentionItem, status: string) {
|
||||
await api.updateAttention(row.id, status);
|
||||
ElMessage.success("人工关注状态已更新");
|
||||
await loadRecords(pager.page, pager.pageSize);
|
||||
}
|
||||
async function deleteRecord(row: AttentionItem) {
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
"确定删除这条人工关注记录吗?操作将写入审计日志。",
|
||||
"删除人工关注记录",
|
||||
{
|
||||
confirmButtonText: "确认删除",
|
||||
cancelButtonText: "取消",
|
||||
type: "warning",
|
||||
},
|
||||
);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
await api.deleteAttention(row.id);
|
||||
ElMessage.success("人工关注记录已删除");
|
||||
await loadRecords(pager.page, pager.pageSize);
|
||||
}
|
||||
function openChat(row: AttentionItem) {
|
||||
sessionId.value = row.sessionId;
|
||||
focusMessageId.value = row.messageId;
|
||||
chatDetailOpen.value = true;
|
||||
}
|
||||
function priorityLabel(priority: string) {
|
||||
return (
|
||||
{ urgent: "紧急", important: "重要", normal: "普通" }[
|
||||
priority as "urgent" | "important" | "normal"
|
||||
] || priority
|
||||
);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section v-loading="loading" class="feature-page">
|
||||
<div class="page-head">
|
||||
<h2>人工关注</h2>
|
||||
<p>处理安全风险、复杂卡住、知识缺失和用户主动求助记录。</p>
|
||||
</div>
|
||||
<el-tabs class="attention-tabs">
|
||||
<el-tab-pane label="关注记录">
|
||||
<section class="record-filter attention-toolbar" aria-label="人工关注筛选">
|
||||
<el-select v-model="filters.priority" placeholder="全部优先级" clearable aria-label="优先级">
|
||||
<el-option label="紧急" value="urgent" />
|
||||
<el-option label="重要" value="important" />
|
||||
<el-option label="普通" value="normal" />
|
||||
</el-select>
|
||||
<div class="record-date-range" aria-label="触发时间范围">
|
||||
<label class="record-date-field"><span>开始日期</span><input v-model="filters.dateFrom" type="date" aria-label="人工关注开始日期" /></label>
|
||||
<span class="record-date-sep">至</span>
|
||||
<label class="record-date-field"><span>结束日期</span><input v-model="filters.dateTo" type="date" aria-label="人工关注结束日期" /></label>
|
||||
</div>
|
||||
<div class="record-filter-actions">
|
||||
<el-button type="primary" @click="applyFilters">查询</el-button>
|
||||
<el-button @click="resetFilters">重置</el-button>
|
||||
</div>
|
||||
</section>
|
||||
<el-table :data="records" stripe
|
||||
><el-table-column label="优先级" width="100"
|
||||
><template #default="{ row }"
|
||||
><el-tag
|
||||
:type="
|
||||
row.priority === 'urgent'
|
||||
? 'danger'
|
||||
: row.priority === 'important'
|
||||
? 'warning'
|
||||
: 'info'
|
||||
"
|
||||
>{{ priorityLabel(row.priority) }}</el-tag
|
||||
></template
|
||||
></el-table-column
|
||||
><el-table-column
|
||||
prop="problemSummary"
|
||||
label="问题摘要"
|
||||
min-width="220"
|
||||
/><el-table-column
|
||||
prop="triggerReason"
|
||||
label="触发原因"
|
||||
min-width="220"
|
||||
/><el-table-column
|
||||
prop="userId"
|
||||
label="用户"
|
||||
width="80"
|
||||
/><el-table-column
|
||||
prop="sessionId"
|
||||
label="会话"
|
||||
width="80"
|
||||
/><el-table-column label="状态" width="150"
|
||||
><template #default="{ row }"
|
||||
><el-select
|
||||
:model-value="row.status"
|
||||
size="small"
|
||||
@change="updateStatus(row, $event)"
|
||||
><el-option label="待处理" value="pending" /><el-option
|
||||
label="处理中"
|
||||
value="processing" /><el-option
|
||||
label="已处理"
|
||||
value="resolved" /><el-option
|
||||
label="无需处理"
|
||||
value="ignored" /></el-select></template></el-table-column
|
||||
><el-table-column label="触发时间" width="180"
|
||||
><template #default="{ row }">{{ formatDateTime(row.createdAt) }}</template
|
||||
></el-table-column><el-table-column label="操作" width="130" fixed="right"
|
||||
><template #default="{ row }"
|
||||
><el-tooltip
|
||||
v-if="row.targetAvailable === false"
|
||||
content="原会话或触发消息已删除"
|
||||
placement="top"
|
||||
>
|
||||
<span><el-button link disabled>原会话</el-button></span>
|
||||
</el-tooltip>
|
||||
<el-button v-else link type="primary" @click="openChat(row)"
|
||||
>原会话</el-button
|
||||
><el-button link type="danger" @click="deleteRecord(row)"
|
||||
>删除</el-button
|
||||
></template
|
||||
></el-table-column
|
||||
></el-table
|
||||
><AdminPagination
|
||||
:page="pager.page"
|
||||
:page-size="pager.pageSize"
|
||||
:total="pager.total"
|
||||
@change="loadRecords"
|
||||
/><ChatDetailDrawer
|
||||
v-model="chatDetailOpen"
|
||||
:session-id="sessionId"
|
||||
:focus-message-id="focusMessageId"
|
||||
/>
|
||||
</el-tab-pane>
|
||||
<el-tab-pane v-if="canConfig || canPreview" label="筛选规则与预览" lazy>
|
||||
<AttentionRuleConfigPanel :can-config="canConfig" :can-preview="canPreview" />
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.attention-tabs { margin-top: 14px; }
|
||||
.attention-toolbar {
|
||||
align-items: center;
|
||||
margin-bottom: 16px;
|
||||
padding: 14px 16px;
|
||||
border: 1px solid #e7ebf0;
|
||||
border-radius: 12px;
|
||||
background: #fafbfc;
|
||||
}
|
||||
@media (max-width: 900px) {
|
||||
.attention-toolbar { align-items: stretch; }
|
||||
.attention-toolbar .record-date-range { width: 100%; }
|
||||
.attention-toolbar .record-date-field { flex: 1; width: auto; }
|
||||
.attention-toolbar .record-filter-actions { width: 100%; }
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,229 @@
|
||||
<script setup lang="ts">
|
||||
import { ElMessage } from "element-plus";
|
||||
import { computed, onMounted, reactive, ref } from "vue";
|
||||
|
||||
import { api } from "../services/api";
|
||||
import type { AttentionConfig, AttentionPreviewMessage, AttentionPreviewResult } from "../types/api";
|
||||
import AdminPagination from "./AdminPagination.vue";
|
||||
import { formatDateTime } from "../utils/dateTime";
|
||||
|
||||
const props = defineProps<{ canConfig: boolean; canPreview: boolean }>();
|
||||
|
||||
const loading = ref(false);
|
||||
const saving = ref(false);
|
||||
const previewLoading = ref(false);
|
||||
const previewMessages = ref<AttentionPreviewMessage[]>([]);
|
||||
const selectedMessages = ref<AttentionPreviewMessage[]>([]);
|
||||
const previewResults = ref<AttentionPreviewResult[]>([]);
|
||||
const keyword = ref("");
|
||||
const pager = reactive({ page: 1, pageSize: 20, total: 0 });
|
||||
const terms = reactive({ urgent: "", important: "", normal: "" });
|
||||
const config = reactive<AttentionConfig>({
|
||||
enabled: true,
|
||||
keywordEnabled: true,
|
||||
aiEnabled: false,
|
||||
knowledgeMissingEnabled: true,
|
||||
urgentTerms: [],
|
||||
importantTerms: [],
|
||||
normalTerms: [],
|
||||
promptTemplate: "",
|
||||
recognitionItems: [],
|
||||
});
|
||||
|
||||
const canRunPreview = computed(() => props.canPreview && selectedMessages.value.length > 0 && !previewLoading.value);
|
||||
|
||||
onMounted(async () => {
|
||||
if (props.canConfig || props.canPreview) await loadConfig();
|
||||
if (props.canPreview) await loadPreviewMessages();
|
||||
});
|
||||
|
||||
async function loadConfig() {
|
||||
loading.value = true;
|
||||
try {
|
||||
assignConfig(props.canConfig ? await api.attentionConfig() : await api.attentionPreviewConfig());
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function assignConfig(value: AttentionConfig) {
|
||||
Object.assign(config, value);
|
||||
terms.urgent = value.urgentTerms.join("\n");
|
||||
terms.important = value.importantTerms.join("\n");
|
||||
terms.normal = value.normalTerms.join("\n");
|
||||
}
|
||||
|
||||
function currentConfig(): AttentionConfig {
|
||||
return {
|
||||
...config,
|
||||
urgentTerms: splitTerms(terms.urgent),
|
||||
importantTerms: splitTerms(terms.important),
|
||||
normalTerms: splitTerms(terms.normal),
|
||||
};
|
||||
}
|
||||
|
||||
function splitTerms(value: string) {
|
||||
return [...new Set(value.split(/[\n,,]+/).map((item) => item.trim()).filter(Boolean))];
|
||||
}
|
||||
|
||||
function addRecognitionItem() {
|
||||
if (config.recognitionItems.length >= 30) {
|
||||
ElMessage.warning("可识别项最多配置 30 个");
|
||||
return;
|
||||
}
|
||||
config.recognitionItems.push({ name: "", description: "", priority: "normal", enabled: true });
|
||||
}
|
||||
|
||||
function removeRecognitionItem(index: number) {
|
||||
config.recognitionItems.splice(index, 1);
|
||||
}
|
||||
|
||||
async function saveConfig() {
|
||||
saving.value = true;
|
||||
try {
|
||||
assignConfig(await api.saveAttentionConfig(currentConfig()));
|
||||
ElMessage.success("人工关注筛选规则已保存,后续新问答立即生效");
|
||||
} catch (error) {
|
||||
ElMessage.error(error instanceof Error ? error.message : "配置保存失败");
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadPreviewMessages(page = 1, pageSize = pager.pageSize) {
|
||||
previewLoading.value = true;
|
||||
try {
|
||||
const result = await api.attentionPreviewMessages({ keyword: keyword.value.trim(), page, pageSize });
|
||||
previewMessages.value = result.items;
|
||||
selectedMessages.value = [];
|
||||
Object.assign(pager, { page: result.page, pageSize: result.pageSize, total: result.total });
|
||||
} finally {
|
||||
previewLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function onSelectionChange(rows: AttentionPreviewMessage[]) {
|
||||
if (rows.length > 20) {
|
||||
ElMessage.warning("单次最多预览 20 条历史消息");
|
||||
selectedMessages.value = rows.slice(0, 20);
|
||||
return;
|
||||
}
|
||||
selectedMessages.value = rows;
|
||||
}
|
||||
|
||||
async function runPreview() {
|
||||
if (!canRunPreview.value) return;
|
||||
previewLoading.value = true;
|
||||
try {
|
||||
previewResults.value = await api.previewAttention(
|
||||
selectedMessages.value.map((item) => item.messageId),
|
||||
currentConfig(),
|
||||
);
|
||||
ElMessage.success(`已完成 ${previewResults.value.length} 条历史消息筛选预览`);
|
||||
} catch (error) {
|
||||
ElMessage.error(error instanceof Error ? error.message : "提示词筛选预览失败");
|
||||
} finally {
|
||||
previewLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function priorityLabel(value: string) {
|
||||
return ({ urgent: "紧急", important: "重要", normal: "普通" } as Record<string, string>)[value] || "-";
|
||||
}
|
||||
|
||||
function sourceLabel(value: string) {
|
||||
return ({ keyword: "关键词", knowledge_missing: "知识缺失", ai: "AI 提示词", rules: "未触发", disabled: "总开关关闭" } as Record<string, string>)[value] || value;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section v-loading="loading" class="attention-config-workspace">
|
||||
<el-alert
|
||||
title="运行顺序:紧急/重要/普通关键词 → 知识缺失 → AI 提示词。确定性规则命中后不再调用模型。"
|
||||
type="info"
|
||||
:closable="false"
|
||||
show-icon
|
||||
/>
|
||||
|
||||
<article v-if="canConfig" class="attention-config-card">
|
||||
<header><div><h3>自动筛选规则</h3><p>配置只影响保存后的新问答,不会自动重跑已有记录。</p></div><el-button type="primary" :loading="saving" @click="saveConfig">保存规则</el-button></header>
|
||||
<div class="attention-switch-grid">
|
||||
<label><span><strong>启用人工关注自动筛选</strong><small>关闭后不再自动创建新的关注记录</small></span><el-switch v-model="config.enabled" /></label>
|
||||
<label><span><strong>启用关键词筛选</strong><small>优先执行,不产生模型调用费用</small></span><el-switch v-model="config.keywordEnabled" /></label>
|
||||
<label><span><strong>知识缺失自动关注</strong><small>知识库应答缺少可靠正式知识时触发</small></span><el-switch v-model="config.knowledgeMissingEnabled" /></label>
|
||||
<label><span><strong>启用 AI 提示词筛选</strong><small>仅在前面规则未命中时异步执行,不会拖慢用户收到回答</small></span><el-switch v-model="config.aiEnabled" /></label>
|
||||
</div>
|
||||
<div class="attention-term-grid">
|
||||
<label><strong>紧急关键词</strong><small>每行一个,命中后标记为紧急</small><el-input v-model="terms.urgent" type="textarea" :rows="6" /></label>
|
||||
<label><strong>重要关键词</strong><small>每行一个,命中后标记为重要</small><el-input v-model="terms.important" type="textarea" :rows="6" /></label>
|
||||
<label><strong>普通关键词</strong><small>每行一个,命中后标记为普通</small><el-input v-model="terms.normal" type="textarea" :rows="6" /></label>
|
||||
</div>
|
||||
<section class="recognition-item-editor">
|
||||
<header><span><strong>AI 可识别项</strong><small>语义筛选只会从已启用项目中选择;可按业务需要继续增加。</small></span><el-button @click="addRecognitionItem">新增可识别项</el-button></header>
|
||||
<el-table :data="config.recognitionItems" border>
|
||||
<el-table-column label="启用" width="72"><template #default="{ row }"><el-switch v-model="row.enabled" /></template></el-table-column>
|
||||
<el-table-column label="项目名称" width="180"><template #default="{ row }"><el-input v-model="row.name" maxlength="50" placeholder="例如:疑似投诉" /></template></el-table-column>
|
||||
<el-table-column label="识别说明" min-width="360"><template #default="{ row }"><el-input v-model="row.description" maxlength="500" placeholder="说明什么情况下算命中,尽量写清边界" /></template></el-table-column>
|
||||
<el-table-column label="优先级" width="130"><template #default="{ row }"><el-select v-model="row.priority"><el-option label="紧急" value="urgent" /><el-option label="重要" value="important" /><el-option label="普通" value="normal" /></el-select></template></el-table-column>
|
||||
<el-table-column label="操作" width="80"><template #default="{ $index }"><el-button link type="danger" @click="removeRecognitionItem($index)">删除</el-button></template></el-table-column>
|
||||
</el-table>
|
||||
</section>
|
||||
<label class="attention-prompt-editor">
|
||||
<span><strong>AI 筛选提示词</strong><small>系统会自动追加历史问答数据、安全边界和固定 JSON 输出格式,无需在提示词中写变量。</small></span>
|
||||
<el-input v-model="config.promptTemplate" type="textarea" :rows="12" maxlength="8000" show-word-limit />
|
||||
</label>
|
||||
</article>
|
||||
|
||||
<article v-if="canPreview" class="attention-config-card">
|
||||
<header><div><h3>历史消息筛选预览</h3><p>可使用当前页面尚未保存的关键词和提示词运行,不会创建人工关注记录。</p></div><el-button type="primary" :loading="previewLoading" :disabled="!canRunPreview" @click="runPreview">预览选中消息</el-button></header>
|
||||
<div class="attention-preview-search"><el-input v-model="keyword" clearable placeholder="搜索历史问题、用户姓名或手机号" @keyup.enter="loadPreviewMessages(1)" /><el-button @click="loadPreviewMessages(1)">查询</el-button><span>已选 {{ selectedMessages.length }}/20</span></div>
|
||||
<el-table :data="previewMessages" stripe @selection-change="onSelectionChange">
|
||||
<el-table-column type="selection" width="48" />
|
||||
<el-table-column prop="question" label="历史问题" min-width="280" show-overflow-tooltip />
|
||||
<el-table-column prop="userName" label="用户" width="120" />
|
||||
<el-table-column prop="sessionTitle" label="会话" min-width="180" show-overflow-tooltip />
|
||||
<el-table-column label="知识缺失" width="100"><template #default="{ row }"><el-tag :type="row.knowledgeMissing ? 'warning' : 'info'">{{ row.knowledgeMissing ? "是" : "否" }}</el-tag></template></el-table-column>
|
||||
<el-table-column label="提问时间" width="180"><template #default="{ row }">{{ formatDateTime(row.createdAt) }}</template></el-table-column>
|
||||
</el-table>
|
||||
<AdminPagination :page="pager.page" :page-size="pager.pageSize" :total="pager.total" @change="loadPreviewMessages" />
|
||||
</article>
|
||||
|
||||
<article v-if="previewResults.length" class="attention-config-card preview-result-card">
|
||||
<header><div><h3>筛选结果</h3><p>结果仅供调整规则和提示词,不会写入关注记录。</p></div></header>
|
||||
<el-table :data="previewResults" stripe>
|
||||
<el-table-column label="结果" width="100"><template #default="{ row }"><el-tag :type="row.needsAttention ? 'danger' : 'success'">{{ row.needsAttention ? "需关注" : "不关注" }}</el-tag></template></el-table-column>
|
||||
<el-table-column label="优先级" width="90"><template #default="{ row }">{{ priorityLabel(row.priority) }}</template></el-table-column>
|
||||
<el-table-column label="来源" width="110"><template #default="{ row }">{{ sourceLabel(row.source) }}</template></el-table-column>
|
||||
<el-table-column prop="matchedItem" label="命中项目" width="140" />
|
||||
<el-table-column prop="question" label="历史问题" min-width="220" show-overflow-tooltip />
|
||||
<el-table-column prop="reason" label="判断理由" min-width="220" />
|
||||
<el-table-column prop="summary" label="问题摘要" min-width="220" />
|
||||
<el-table-column label="详情" width="100"><template #default="{ row }"><el-popover placement="left" :width="560" trigger="click"><template #reference><el-button link type="primary">提示词/原始输出</el-button></template><div class="attention-preview-detail"><strong>最终提示词</strong><pre>{{ row.renderedPrompt || '关键词规则命中,未调用 AI' }}</pre><strong>模型原始输出</strong><pre>{{ row.rawOutput || '无' }}</pre></div></el-popover></template></el-table-column>
|
||||
</el-table>
|
||||
</article>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.attention-config-workspace { display: grid; gap: 18px; }
|
||||
.attention-config-card { padding: 20px; border: 1px solid #e3e9e7; border-radius: 14px; background: #fff; }
|
||||
.attention-config-card > header { display: flex; align-items: flex-start; justify-content: space-between; gap: 18px; margin-bottom: 18px; }
|
||||
.attention-config-card h3 { margin: 0; color: #1f2d29; }
|
||||
.attention-config-card p { margin: 6px 0 0; color: #73817c; font-size: 13px; }
|
||||
.attention-switch-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; }
|
||||
.attention-switch-grid label { display: flex; align-items: center; justify-content: space-between; gap: 16px; padding: 14px; border: 1px solid #e6ece9; border-radius: 10px; }
|
||||
.attention-switch-grid span, .attention-prompt-editor > span { display: grid; gap: 5px; }
|
||||
.attention-switch-grid small, .attention-term-grid small, .attention-prompt-editor small { color: #82908b; font-weight: 400; }
|
||||
.attention-term-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 14px; margin-top: 16px; }
|
||||
.attention-term-grid label { display: grid; gap: 7px; }
|
||||
.attention-prompt-editor { display: grid; gap: 10px; margin-top: 18px; }
|
||||
.recognition-item-editor { display: grid; gap: 10px; margin-top: 18px; }
|
||||
.recognition-item-editor > header { display: flex; align-items: center; justify-content: space-between; gap: 16px; }
|
||||
.recognition-item-editor > header span { display: grid; gap: 5px; }
|
||||
.recognition-item-editor small { color: #82908b; font-weight: 400; }
|
||||
.attention-preview-search { display: grid; grid-template-columns: minmax(260px, 520px) auto 1fr; align-items: center; gap: 10px; margin-bottom: 14px; }
|
||||
.attention-preview-search span { color: #73817c; font-size: 13px; }
|
||||
.attention-preview-detail { display: grid; gap: 8px; }
|
||||
.attention-preview-detail pre { max-height: 300px; margin: 0 0 10px; padding: 10px; overflow: auto; border-radius: 8px; background: #f6f8f7; white-space: pre-wrap; word-break: break-word; }
|
||||
@media (max-width: 980px) { .attention-switch-grid, .attention-term-grid { grid-template-columns: 1fr; } }
|
||||
</style>
|
||||
@@ -0,0 +1,518 @@
|
||||
<script setup lang="ts">
|
||||
import { ElMessage } from "element-plus";
|
||||
import { nextTick, reactive, ref, watch } from "vue";
|
||||
|
||||
import { api } from "../services/api";
|
||||
import type { ChatDetail } from "../types/api";
|
||||
import AdminPagination from "./AdminPagination.vue";
|
||||
import { formatDateTime } from "../utils/dateTime";
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: boolean;
|
||||
sessionId: number | null;
|
||||
focusMessageId?: number | null;
|
||||
canExport?: boolean;
|
||||
}>();
|
||||
const emit = defineEmits<{ "update:modelValue": [value: boolean] }>();
|
||||
const detail = ref<ChatDetail | null>(null);
|
||||
const loading = ref(false);
|
||||
const messagesLoading = ref(false);
|
||||
const aiLogsLoading = ref(false);
|
||||
const aiLogDetailsLoading = ref<number[]>([]);
|
||||
const activeAiLogIds = ref<number[]>([]);
|
||||
const messageKeyword = ref("");
|
||||
const appliedMessageKeyword = ref("");
|
||||
const exporting = ref(false);
|
||||
const bodyRef = ref<HTMLElement | null>(null);
|
||||
const pager = reactive({ page: 1, pageSize: 20, total: 0 });
|
||||
const aiPager = reactive({ page: 1, pageSize: 20, total: 0 });
|
||||
|
||||
watch(
|
||||
() => [props.modelValue, props.sessionId, props.focusMessageId] as const,
|
||||
async ([open, sessionId]) => {
|
||||
if (!open || !sessionId) return;
|
||||
detail.value = null;
|
||||
Object.assign(pager, { page: 1, total: 0 });
|
||||
Object.assign(aiPager, { page: 1, total: 0 });
|
||||
messageKeyword.value = "";
|
||||
appliedMessageKeyword.value = "";
|
||||
activeAiLogIds.value = [];
|
||||
await loadDetail(sessionId, 1, pager.pageSize);
|
||||
},
|
||||
);
|
||||
|
||||
async function loadDetail(sessionId: number, page: number, pageSize: number) {
|
||||
loading.value = true;
|
||||
try {
|
||||
detail.value = await api.chatDetail(sessionId, {
|
||||
messagePage: page,
|
||||
messagePageSize: pageSize,
|
||||
focusMessageId: props.focusMessageId || undefined,
|
||||
});
|
||||
const messagePage = detail.value.messagesPage;
|
||||
Object.assign(pager, {
|
||||
page: messagePage?.page ?? page,
|
||||
pageSize: messagePage?.pageSize ?? pageSize,
|
||||
total: messagePage?.total ?? detail.value.messages.length,
|
||||
});
|
||||
void loadAiLogs(sessionId, 1, aiPager.pageSize);
|
||||
await scrollToFocusedMessage();
|
||||
} catch (error) {
|
||||
ElMessage.error(
|
||||
error instanceof Error ? error.message : "聊天详情加载失败",
|
||||
);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function scrollToFocusedMessage() {
|
||||
if (!props.focusMessageId) return;
|
||||
await nextTick();
|
||||
const target = bodyRef.value?.querySelector<HTMLElement>(
|
||||
`[data-message-id="${props.focusMessageId}"]`,
|
||||
);
|
||||
target?.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||
}
|
||||
|
||||
async function changeMessagePage(page: number, pageSize: number) {
|
||||
if (!props.sessionId || !detail.value) return;
|
||||
messagesLoading.value = true;
|
||||
try {
|
||||
const result = await api.chatMessages(props.sessionId, {
|
||||
keyword: appliedMessageKeyword.value || undefined,
|
||||
page,
|
||||
pageSize,
|
||||
});
|
||||
detail.value = {
|
||||
...detail.value,
|
||||
messages: result.items,
|
||||
messagesPage: result,
|
||||
};
|
||||
Object.assign(pager, {
|
||||
page: result.page,
|
||||
pageSize: result.pageSize,
|
||||
total: result.total,
|
||||
});
|
||||
await nextTick();
|
||||
bodyRef.value?.querySelector(".conversation-title-row")?.scrollIntoView({ behavior: "smooth", block: "start" });
|
||||
} catch (error) {
|
||||
ElMessage.error(
|
||||
error instanceof Error ? error.message : "聊天消息加载失败",
|
||||
);
|
||||
} finally {
|
||||
messagesLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function searchMessages() {
|
||||
appliedMessageKeyword.value = messageKeyword.value.trim();
|
||||
await changeMessagePage(1, pager.pageSize);
|
||||
}
|
||||
|
||||
async function clearMessageSearch() {
|
||||
messageKeyword.value = "";
|
||||
if (!appliedMessageKeyword.value) return;
|
||||
appliedMessageKeyword.value = "";
|
||||
await changeMessagePage(1, pager.pageSize);
|
||||
}
|
||||
|
||||
function messageParts(content: string) {
|
||||
const keyword = appliedMessageKeyword.value;
|
||||
if (!keyword) return [{ text: content, matched: false }];
|
||||
const parts: Array<{ text: string; matched: boolean }> = [];
|
||||
const lowerContent = content.toLocaleLowerCase();
|
||||
const lowerKeyword = keyword.toLocaleLowerCase();
|
||||
let cursor = 0;
|
||||
let index = lowerContent.indexOf(lowerKeyword);
|
||||
while (index >= 0) {
|
||||
if (index > cursor) parts.push({ text: content.slice(cursor, index), matched: false });
|
||||
parts.push({ text: content.slice(index, index + keyword.length), matched: true });
|
||||
cursor = index + keyword.length;
|
||||
index = lowerContent.indexOf(lowerKeyword, cursor);
|
||||
}
|
||||
if (cursor < content.length) parts.push({ text: content.slice(cursor), matched: false });
|
||||
return parts.length ? parts : [{ text: content, matched: false }];
|
||||
}
|
||||
|
||||
async function loadAiLogs(sessionId: number, page: number, pageSize: number) {
|
||||
aiLogsLoading.value = true;
|
||||
try {
|
||||
const result = await api.aiLogs({ sessionId, page, pageSize });
|
||||
if (!detail.value || props.sessionId !== sessionId) return;
|
||||
detail.value.aiLogs = result.items;
|
||||
Object.assign(aiPager, {
|
||||
page: result.page,
|
||||
pageSize: result.pageSize,
|
||||
total: result.total,
|
||||
});
|
||||
activeAiLogIds.value = [];
|
||||
} catch (error) {
|
||||
ElMessage.error(error instanceof Error ? error.message : "关联 AI 请求加载失败");
|
||||
} finally {
|
||||
aiLogsLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleAiLogChange(activeNames: string | number | Array<string | number>) {
|
||||
const ids = (Array.isArray(activeNames) ? activeNames : [activeNames])
|
||||
.map(Number)
|
||||
.filter(Number.isFinite);
|
||||
for (const id of ids) {
|
||||
const log = detail.value?.aiLogs.find((item) => item.id === id);
|
||||
if (!log || Object.prototype.hasOwnProperty.call(log, "prompt") || aiLogDetailsLoading.value.includes(id)) continue;
|
||||
aiLogDetailsLoading.value = [...aiLogDetailsLoading.value, id];
|
||||
try {
|
||||
const fullLog = await api.aiLogDetail(id);
|
||||
const index = detail.value?.aiLogs.findIndex((item) => item.id === id) ?? -1;
|
||||
if (detail.value && index >= 0) detail.value.aiLogs[index] = fullLog;
|
||||
} catch (error) {
|
||||
ElMessage.error(error instanceof Error ? error.message : "AI 请求详情加载失败");
|
||||
} finally {
|
||||
aiLogDetailsLoading.value = aiLogDetailsLoading.value.filter((item) => item !== id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function exportConversation() {
|
||||
if (!props.sessionId) return;
|
||||
exporting.value = true;
|
||||
try {
|
||||
await api.exportChatDetail(props.sessionId);
|
||||
ElMessage.success("当前完整对话已导出");
|
||||
} catch (error) {
|
||||
ElMessage.error(error instanceof Error ? error.message : "完整对话导出失败");
|
||||
} finally {
|
||||
exporting.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<el-drawer
|
||||
:model-value="modelValue"
|
||||
size="760px"
|
||||
title="聊天详情"
|
||||
@update:model-value="emit('update:modelValue', $event)"
|
||||
>
|
||||
<div ref="bodyRef" v-loading="loading" class="chat-detail-body">
|
||||
<template v-if="detail">
|
||||
<div v-if="canExport" class="chat-detail-actions">
|
||||
<el-button type="primary" plain :loading="exporting" @click="exportConversation">导出完整对话</el-button>
|
||||
</div>
|
||||
<section class="chat-summary">
|
||||
<div>
|
||||
<span>会话ID</span><strong>{{ detail.session.id }}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>用户</span
|
||||
><strong>{{ detail.session.userName || "-" }}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>手机号</span
|
||||
><strong>{{ detail.session.userPhone || "-" }}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>消息数</span
|
||||
><strong>{{ detail.session.messageCount }}</strong>
|
||||
</div>
|
||||
</section>
|
||||
<h3 class="detail-title">主题会话</h3>
|
||||
<el-collapse
|
||||
v-if="detail.topics?.length"
|
||||
class="topic-summary-collapse"
|
||||
>
|
||||
<el-collapse-item
|
||||
v-for="topic in detail.topics"
|
||||
:key="topic.id"
|
||||
:title="`#${topic.id} ${topic.title} / ${topic.status}`"
|
||||
>
|
||||
<section class="topic-summary-card">
|
||||
<div class="topic-summary-meta">
|
||||
<span>核心问题:{{ topic.coreQuestion }}</span
|
||||
><span>消息数:{{ topic.messageCount }}</span
|
||||
><span
|
||||
>Token:{{ topic.tokenInput }}/{{ topic.tokenOutput }}</span
|
||||
><span>开始:{{ formatDateTime(topic.startedAt) }}</span
|
||||
><span>结束:{{ formatDateTime(topic.endedAt) }}</span>
|
||||
</div>
|
||||
<template v-if="topic.summary">
|
||||
<pre>{{ topic.summary.summary }}</pre>
|
||||
<div class="topic-summary-fields">
|
||||
<p v-if="topic.summary.currentFocus">
|
||||
<strong>本次关注</strong>{{ topic.summary.currentFocus }}
|
||||
</p>
|
||||
<p v-if="topic.summary.nextObservation">
|
||||
<strong>可以继续留意</strong
|
||||
>{{ topic.summary.nextObservation }}
|
||||
</p>
|
||||
</div></template
|
||||
>
|
||||
<el-empty
|
||||
v-else
|
||||
description="该主题尚未沉淀摘要"
|
||||
:image-size="60"
|
||||
/>
|
||||
</section>
|
||||
</el-collapse-item>
|
||||
</el-collapse>
|
||||
<el-empty v-else description="该会话暂无主题记录" :image-size="64" />
|
||||
<h3 class="detail-title">
|
||||
老师求助卡({{ detail.helpCards?.length || 0 }})
|
||||
</h3>
|
||||
<el-collapse
|
||||
v-if="detail.helpCards?.length"
|
||||
class="topic-summary-collapse"
|
||||
><el-collapse-item
|
||||
v-for="card in detail.helpCards"
|
||||
:key="card.id"
|
||||
:title="`#${card.id} / 主题 #${card.topicSessionId} / ${card.copied ? '用户已复制' : '未复制'}`"
|
||||
><section class="topic-summary-card">
|
||||
<div class="topic-summary-meta">
|
||||
<span>生成:{{ formatDateTime(card.createdAt) }}</span
|
||||
><span>复制:{{ formatDateTime(card.copiedAt) }}</span
|
||||
><span>来源:{{ card.source }}</span>
|
||||
</div>
|
||||
<pre>{{ card.content }}</pre>
|
||||
</section></el-collapse-item
|
||||
></el-collapse
|
||||
>
|
||||
<el-empty
|
||||
v-if="!aiLogsLoading && !detail.aiLogs.length"
|
||||
description="该会话暂无关联 AI 请求"
|
||||
:image-size="64"
|
||||
/>
|
||||
<el-empty
|
||||
v-else
|
||||
description="该会话暂无用户生成的求助卡"
|
||||
:image-size="64"
|
||||
/>
|
||||
<h3 class="detail-title">
|
||||
班级分享稿({{ detail.shareDrafts?.length || 0 }})
|
||||
</h3>
|
||||
<el-collapse
|
||||
v-if="detail.shareDrafts?.length"
|
||||
class="topic-summary-collapse"
|
||||
><el-collapse-item
|
||||
v-for="draft in detail.shareDrafts"
|
||||
:key="draft.id"
|
||||
:title="`#${draft.id} / 主题 #${draft.topicSessionId} / ${draft.copied ? '用户已复制' : '未复制'}`"
|
||||
><section class="topic-summary-card">
|
||||
<div class="topic-summary-meta">
|
||||
<span>生成:{{ formatDateTime(draft.createdAt) }}</span
|
||||
><span>复制:{{ formatDateTime(draft.copiedAt) }}</span
|
||||
><span>来源:{{ draft.source }}</span>
|
||||
</div>
|
||||
<pre>{{ draft.content }}</pre>
|
||||
</section></el-collapse-item
|
||||
></el-collapse
|
||||
>
|
||||
<el-empty
|
||||
v-else
|
||||
description="该会话暂无用户生成的班级分享稿"
|
||||
:image-size="64"
|
||||
/>
|
||||
<div class="conversation-title-row">
|
||||
<h3 class="detail-title">完整对话</h3>
|
||||
<span v-if="appliedMessageKeyword">找到 {{ pager.total }} 条匹配消息</span>
|
||||
</div>
|
||||
<div class="conversation-search">
|
||||
<el-input
|
||||
v-model="messageKeyword"
|
||||
clearable
|
||||
maxlength="100"
|
||||
placeholder="输入关键字搜索当前会话"
|
||||
@keyup.enter="searchMessages"
|
||||
@clear="clearMessageSearch"
|
||||
/>
|
||||
<el-button type="primary" :loading="messagesLoading" @click="searchMessages">搜索</el-button>
|
||||
<el-button v-if="appliedMessageKeyword" @click="clearMessageSearch">返回完整对话</el-button>
|
||||
</div>
|
||||
<AdminPagination
|
||||
class="chat-detail-pagination top"
|
||||
:page="pager.page"
|
||||
:page-size="pager.pageSize"
|
||||
:total="pager.total"
|
||||
@change="changeMessagePage"
|
||||
/>
|
||||
<section v-loading="messagesLoading" class="conversation-list">
|
||||
<article
|
||||
v-for="message in detail.messages"
|
||||
:key="message.id"
|
||||
class="conversation-message"
|
||||
:class="[
|
||||
message.role,
|
||||
{ 'focus-message': message.id === focusMessageId },
|
||||
]"
|
||||
:data-message-id="message.id"
|
||||
>
|
||||
<div class="message-meta">
|
||||
<strong>{{
|
||||
message.role === "user" ? "用户" : "大本营答疑助手"
|
||||
}}</strong
|
||||
><el-tag
|
||||
v-if="message.id === focusMessageId"
|
||||
type="warning"
|
||||
effect="light"
|
||||
>触发人工关注的消息</el-tag
|
||||
><span>{{ formatDateTime(message.createdAt) }}</span>
|
||||
</div>
|
||||
<pre><template v-for="(part, index) in messageParts(message.content)" :key="index"><mark v-if="part.matched">{{ part.text }}</mark><template v-else>{{ part.text }}</template></template></pre>
|
||||
<div class="message-extra">
|
||||
<span>状态:{{ message.messageStatus }}</span
|
||||
><span v-if="message.tokenInput"
|
||||
>输入 Token:{{ message.tokenInput }}</span
|
||||
><span v-if="message.tokenOutput"
|
||||
>输出 Token:{{ message.tokenOutput }}</span
|
||||
><span v-if="message.responseTimeMs"
|
||||
>耗时:{{ message.responseTimeMs }}ms</span
|
||||
>
|
||||
</div>
|
||||
</article>
|
||||
</section>
|
||||
<AdminPagination
|
||||
class="chat-detail-pagination"
|
||||
:page="pager.page"
|
||||
:page-size="pager.pageSize"
|
||||
:total="pager.total"
|
||||
@change="changeMessagePage"
|
||||
/>
|
||||
<h3 class="detail-title">关联 AI 请求</h3>
|
||||
<el-collapse
|
||||
v-model="activeAiLogIds"
|
||||
v-loading="aiLogsLoading"
|
||||
@change="handleAiLogChange"
|
||||
><el-collapse-item
|
||||
v-for="log in detail.aiLogs"
|
||||
:key="log.id"
|
||||
:title="`请求 #${log.id} / ${log.status}`"
|
||||
><div class="ai-log-grid">
|
||||
<span>模型:{{ log.modelName || "-" }}</span
|
||||
><span>知识库:{{ log.knowledgeIds || "-" }}</span
|
||||
><span>命中数:{{ log.retrieveCount }}</span
|
||||
><span>Token:{{ log.totalToken || "-" }}</span
|
||||
><span>耗时:{{ log.costMs || "-" }}ms</span
|
||||
><span>时间:{{ formatDateTime(log.createdAt) }}</span>
|
||||
</div>
|
||||
<p v-if="aiLogDetailsLoading.includes(log.id)" class="ai-log-loading">正在加载请求详情…</p>
|
||||
<section
|
||||
v-else-if="log.retrievedChunks?.length"
|
||||
class="retrieval-chunks"
|
||||
>
|
||||
<article
|
||||
v-for="chunk in log.retrievedChunks"
|
||||
:key="`${log.id}-${chunk.index}`"
|
||||
class="retrieval-chunk"
|
||||
>
|
||||
<div class="chunk-head">
|
||||
<strong
|
||||
>#{{ chunk.index }}
|
||||
{{ chunk.knowledgeName || "未知知识库" }}</strong
|
||||
><span>{{ chunk.title || "未命名片段" }}</span>
|
||||
</div>
|
||||
<pre>{{ chunk.content }}</pre>
|
||||
<a
|
||||
v-if="chunk.sourceUrl"
|
||||
:href="chunk.sourceUrl"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>查看来源</a
|
||||
>
|
||||
</article>
|
||||
</section>
|
||||
<el-empty
|
||||
v-else
|
||||
description="本条请求未保存召回片段"
|
||||
:image-size="64"
|
||||
/>
|
||||
<pre v-if="!aiLogDetailsLoading.includes(log.id)" class="prompt-preview">{{
|
||||
log.prompt || "无 Prompt 记录"
|
||||
}}</pre>
|
||||
<p v-if="log.errorMessage" class="error-text">
|
||||
{{ log.errorMessage }}
|
||||
</p></el-collapse-item
|
||||
></el-collapse
|
||||
>
|
||||
<AdminPagination
|
||||
v-if="aiPager.total"
|
||||
:page="aiPager.page"
|
||||
:page-size="aiPager.pageSize"
|
||||
:total="aiPager.total"
|
||||
@change="(page, size) => sessionId && loadAiLogs(sessionId, page, size)"
|
||||
/>
|
||||
</template>
|
||||
<el-empty
|
||||
v-else-if="!loading"
|
||||
description="聊天详情加载失败或暂无数据"
|
||||
:image-size="80"
|
||||
/>
|
||||
</div>
|
||||
</el-drawer>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.chat-detail-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.conversation-title-row,
|
||||
.conversation-search {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.conversation-title-row {
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.conversation-title-row > span {
|
||||
color: #7b8494;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.conversation-search {
|
||||
margin: 0 0 12px;
|
||||
}
|
||||
|
||||
.conversation-search :deep(.el-input) {
|
||||
max-width: 360px;
|
||||
}
|
||||
|
||||
.conversation-message mark {
|
||||
padding: 1px 3px;
|
||||
border-radius: 4px;
|
||||
background: #ffe58f;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.ai-log-loading {
|
||||
padding: 16px;
|
||||
color: #7b8494;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.conversation-message.focus-message {
|
||||
border: 2px solid #e6a23c;
|
||||
background: #fffaf0;
|
||||
box-shadow: 0 0 0 4px rgb(230 162 60 / 10%);
|
||||
animation: focus-message-pulse 1.2s ease-out;
|
||||
}
|
||||
|
||||
@keyframes focus-message-pulse {
|
||||
0% { box-shadow: 0 0 0 0 rgb(230 162 60 / 38%); }
|
||||
65% { box-shadow: 0 0 0 12px rgb(230 162 60 / 8%); }
|
||||
100% { box-shadow: 0 0 0 4px rgb(230 162 60 / 10%); }
|
||||
}
|
||||
|
||||
.conversation-message.focus-message .message-meta {
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.conversation-message.focus-message .message-meta span:last-child {
|
||||
margin-left: auto;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,478 @@
|
||||
<script setup lang="ts">
|
||||
import { ElMessage, ElMessageBox } from "element-plus";
|
||||
import type { InputInstance } from "element-plus";
|
||||
import { computed, nextTick, onMounted, reactive, ref } from "vue";
|
||||
|
||||
import { api } from "../services/api";
|
||||
import type {
|
||||
ContentGenerationConfigDetail,
|
||||
ContentGenerationHistoryItem,
|
||||
ContentGenerationType,
|
||||
ContentGenerationVariable,
|
||||
} from "../types/api";
|
||||
import AdminPagination from "./AdminPagination.vue";
|
||||
import ContentGenerationVariableEditor from "./ContentGenerationVariableEditor.vue";
|
||||
import { formatDateTime } from "../utils/dateTime";
|
||||
|
||||
const activeType = ref<ContentGenerationType>("help_card");
|
||||
const loading = ref(false);
|
||||
const saving = ref(false);
|
||||
const previewing = ref(false);
|
||||
const testing = ref(false);
|
||||
const detailLoading = ref(false);
|
||||
const current = ref<ContentGenerationConfigDetail | null>(null);
|
||||
const history = ref<ContentGenerationHistoryItem[]>([]);
|
||||
const historyTotal = ref(0);
|
||||
const historyPage = ref(1);
|
||||
const historyPageSize = ref(10);
|
||||
const previewContent = ref("");
|
||||
const testUsedFallback = ref(false);
|
||||
const selectedHistory = ref<ContentGenerationConfigDetail | null>(null);
|
||||
const historyDialogOpen = ref(false);
|
||||
const activeSection = ref<"variables" | "layout" | "test">("variables");
|
||||
const templateInputRef = ref<InputInstance>();
|
||||
const form = reactive<{ templateContent: string; instructionContent: string; variables: ContentGenerationVariable[] }>({
|
||||
templateContent: "",
|
||||
instructionContent: "",
|
||||
variables: [],
|
||||
});
|
||||
const saved = reactive({ templateContent: "", instructionContent: "", variablesJson: "[]" });
|
||||
const sampleText = ref(
|
||||
"我第一次参加带练,不太确定练习顺序。做到一半身体有些紧,我会担心自己是不是做错了,想请老师确认什么时候应该暂停。",
|
||||
);
|
||||
|
||||
const typeMeta: Record<ContentGenerationType, { label: string; description: string }> = {
|
||||
help_card: { label: "老师求助卡", description: "给老师确认方向,不会自动转人工" },
|
||||
share_draft: { label: "班级分享稿", description: "用户自行核对、复制和分享" },
|
||||
weekly_report: { label: "周报告", description: "根据一周内的全部聊天分批整理" },
|
||||
monthly_report: { label: "月报告", description: "根据本月覆盖的周报告汇总" },
|
||||
};
|
||||
|
||||
const dirty = computed(
|
||||
() => form.templateContent !== saved.templateContent
|
||||
|| form.instructionContent !== saved.instructionContent
|
||||
|| JSON.stringify(form.variables) !== saved.variablesJson,
|
||||
);
|
||||
const typeLabel = computed(() => typeMeta[activeType.value].label);
|
||||
|
||||
onMounted(loadAll);
|
||||
|
||||
async function loadAll() {
|
||||
loading.value = true;
|
||||
try {
|
||||
const [config, page] = await Promise.all([
|
||||
api.contentGenerationConfig(activeType.value),
|
||||
api.contentGenerationHistory(activeType.value, { page: historyPage.value, pageSize: historyPageSize.value }),
|
||||
]);
|
||||
applyConfig(config);
|
||||
history.value = page.items;
|
||||
historyTotal.value = page.total;
|
||||
await refreshPreview();
|
||||
} catch (error) {
|
||||
ElMessage.error(error instanceof Error ? error.message : "内容生成配置加载失败");
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function applyConfig(config: ContentGenerationConfigDetail) {
|
||||
current.value = config;
|
||||
form.templateContent = config.templateContent;
|
||||
form.instructionContent = config.instructionContent;
|
||||
form.variables = config.variables.map((item) => ({ ...item }));
|
||||
saved.templateContent = config.templateContent;
|
||||
saved.instructionContent = config.instructionContent;
|
||||
saved.variablesJson = JSON.stringify(config.variables);
|
||||
}
|
||||
|
||||
async function switchType(type: ContentGenerationType) {
|
||||
if (type === activeType.value) return;
|
||||
if (dirty.value) {
|
||||
try {
|
||||
await ElMessageBox.confirm("当前修改尚未保存,切换后将丢失。确定继续吗?", "切换配置", {
|
||||
confirmButtonText: "继续切换",
|
||||
cancelButtonText: "留在当前页",
|
||||
type: "warning",
|
||||
});
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
}
|
||||
activeType.value = type;
|
||||
historyPage.value = 1;
|
||||
previewContent.value = "";
|
||||
testUsedFallback.value = false;
|
||||
activeSection.value = "variables";
|
||||
sampleText.value = type === "weekly_report"
|
||||
? "周一讨论了练习顺序;周三聊到身体紧绷时会急着判断对错;周末继续确认什么时候应该暂停。请把这些内容当作一周内多段完整聊天的模拟材料。"
|
||||
: type === "monthly_report"
|
||||
? "第一周主要讨论练习准备,第二周关注身体紧绷,第三周继续梳理表达时的不确定感,第四周回顾了暂停和观察的区别。请把这些内容当作多份周报告的模拟材料。"
|
||||
: "我第一次参加带练,不太确定练习顺序。做到一半身体有些紧,我会担心自己是不是做错了,想请老师确认什么时候应该暂停。";
|
||||
await loadAll();
|
||||
}
|
||||
|
||||
async function insertVariable(name: string) {
|
||||
const token = `{{${name}}}`;
|
||||
const textarea = templateInputRef.value?.textarea;
|
||||
if (!textarea) {
|
||||
form.templateContent = `${form.templateContent}${form.templateContent.endsWith("\n") ? "" : "\n"}${token}`;
|
||||
return;
|
||||
}
|
||||
const start = textarea.selectionStart ?? form.templateContent.length;
|
||||
const end = textarea.selectionEnd ?? start;
|
||||
form.templateContent = `${form.templateContent.slice(0, start)}${token}${form.templateContent.slice(end)}`;
|
||||
await nextTick();
|
||||
textarea.focus();
|
||||
textarea.setSelectionRange(start + token.length, start + token.length);
|
||||
}
|
||||
|
||||
function variableToken(name: string) {
|
||||
return `{{${name}}}`;
|
||||
}
|
||||
|
||||
async function refreshPreview() {
|
||||
previewing.value = true;
|
||||
try {
|
||||
const result = await api.previewContentGeneration({
|
||||
configType: activeType.value,
|
||||
templateContent: form.templateContent,
|
||||
variables: form.variables,
|
||||
});
|
||||
previewContent.value = result.content;
|
||||
testUsedFallback.value = false;
|
||||
} catch (error) {
|
||||
ElMessage.error(error instanceof Error ? error.message : "预览生成失败");
|
||||
} finally {
|
||||
previewing.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function testGeneration() {
|
||||
testing.value = true;
|
||||
try {
|
||||
const result = await api.testContentGeneration({
|
||||
configType: activeType.value,
|
||||
templateContent: form.templateContent,
|
||||
instructionContent: form.instructionContent,
|
||||
sampleText: sampleText.value,
|
||||
variables: form.variables,
|
||||
});
|
||||
previewContent.value = result.content;
|
||||
testUsedFallback.value = result.usedFallback;
|
||||
ElMessage.success(result.usedFallback ? "模型结果不可用,已展示安全回退结果" : "AI 整理测试完成");
|
||||
} catch (error) {
|
||||
ElMessage.error(error instanceof Error ? error.message : "AI 整理测试失败");
|
||||
} finally {
|
||||
testing.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function saveConfig() {
|
||||
if (!dirty.value || saving.value) return;
|
||||
saving.value = true;
|
||||
try {
|
||||
const config = await api.saveContentGenerationConfig(activeType.value, {
|
||||
templateContent: form.templateContent,
|
||||
instructionContent: form.instructionContent,
|
||||
variables: form.variables,
|
||||
});
|
||||
applyConfig(config);
|
||||
ElMessage.success("已保存并立即发布为新版本");
|
||||
historyPage.value = 1;
|
||||
await loadHistory();
|
||||
await refreshPreview();
|
||||
} catch (error) {
|
||||
ElMessage.error(error instanceof Error ? error.message : "配置保存失败");
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function resetConfig() {
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
`确定将${typeLabel.value}恢复为系统默认配置吗?系统会保留当前版本,可随时回滚。`,
|
||||
"恢复默认配置",
|
||||
{ confirmButtonText: "恢复默认", cancelButtonText: "取消", type: "warning" },
|
||||
);
|
||||
saving.value = true;
|
||||
const config = await api.resetContentGenerationConfig(activeType.value);
|
||||
applyConfig(config);
|
||||
historyPage.value = 1;
|
||||
await loadHistory();
|
||||
await refreshPreview();
|
||||
ElMessage.success("已恢复默认配置");
|
||||
} catch (error) {
|
||||
if (error instanceof Error) ElMessage.error(error.message);
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadHistory() {
|
||||
const page = await api.contentGenerationHistory(activeType.value, {
|
||||
page: historyPage.value,
|
||||
pageSize: historyPageSize.value,
|
||||
});
|
||||
history.value = page.items;
|
||||
historyTotal.value = page.total;
|
||||
}
|
||||
|
||||
async function changeHistoryPagination(page: number, pageSize: number) {
|
||||
historyPage.value = page;
|
||||
historyPageSize.value = pageSize;
|
||||
await loadHistory();
|
||||
}
|
||||
|
||||
async function openHistory(item: ContentGenerationHistoryItem) {
|
||||
historyDialogOpen.value = true;
|
||||
detailLoading.value = true;
|
||||
selectedHistory.value = null;
|
||||
try {
|
||||
selectedHistory.value = await api.contentGenerationHistoryDetail(activeType.value, item.id);
|
||||
} catch (error) {
|
||||
ElMessage.error(error instanceof Error ? error.message : "历史版本加载失败");
|
||||
historyDialogOpen.value = false;
|
||||
} finally {
|
||||
detailLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function restoreHistory(id: number) {
|
||||
try {
|
||||
await ElMessageBox.confirm("确定回滚到这个版本吗?回滚会生成一个新版本,不会覆盖历史记录。", "回滚配置", {
|
||||
confirmButtonText: "确认回滚",
|
||||
cancelButtonText: "取消",
|
||||
type: "warning",
|
||||
});
|
||||
saving.value = true;
|
||||
const config = await api.restoreContentGenerationConfig(activeType.value, id);
|
||||
applyConfig(config);
|
||||
historyDialogOpen.value = false;
|
||||
historyPage.value = 1;
|
||||
await loadHistory();
|
||||
await refreshPreview();
|
||||
ElMessage.success("已回滚并发布为新版本");
|
||||
} catch (error) {
|
||||
if (error instanceof Error) ElMessage.error(error.message);
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function changeTypeLabel(value: string) {
|
||||
return { save: "保存", reset: "恢复默认", restore: "历史回滚", default: "系统默认" }[value] || value;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-loading="loading" class="content-generation-page">
|
||||
<div class="page-head inline">
|
||||
<div>
|
||||
<h2>内容生成配置</h2>
|
||||
<p>统一配置求助卡、分享稿、周报告和月报告的变量、AI 整理规则与排版;保存后立即生效并保留历史版本。</p>
|
||||
</div>
|
||||
<div class="config-head-actions">
|
||||
<el-button :loading="previewing" @click="refreshPreview">刷新预览</el-button>
|
||||
<el-button type="primary" :loading="saving" :disabled="!dirty" @click="saveConfig">保存并发布新版本</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<nav class="content-type-tabs" aria-label="内容生成类型">
|
||||
<button type="button" :class="{ active: activeType === 'help_card' }" @click="switchType('help_card')">
|
||||
<strong>老师求助卡</strong><span>给老师确认方向,不会自动转人工</span>
|
||||
</button>
|
||||
<button type="button" :class="{ active: activeType === 'share_draft' }" @click="switchType('share_draft')">
|
||||
<strong>班级分享稿</strong><span>用户自行核对、复制和分享</span>
|
||||
</button>
|
||||
<button type="button" :class="{ active: activeType === 'weekly_report' }" @click="switchType('weekly_report')">
|
||||
<strong>周报告</strong><span>根据一周内的全部聊天分批整理</span>
|
||||
</button>
|
||||
<button type="button" :class="{ active: activeType === 'monthly_report' }" @click="switchType('monthly_report')">
|
||||
<strong>月报告</strong><span>根据本月覆盖的周报告汇总</span>
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
<nav class="workflow-tabs" aria-label="内容配置步骤">
|
||||
<button type="button" :class="{ active: activeSection === 'variables' }" @click="activeSection = 'variables'">
|
||||
<span>1</span><div><strong>变量设置</strong><small>定义内容字段和提炼含义</small></div>
|
||||
</button>
|
||||
<button type="button" :class="{ active: activeSection === 'layout' }" @click="activeSection = 'layout'; refreshPreview()">
|
||||
<span>2</span><div><strong>内容排版</strong><small>组合变量并查看实际效果</small></div>
|
||||
</button>
|
||||
<button type="button" :class="{ active: activeSection === 'test' }" @click="activeSection = 'test'">
|
||||
<span>3</span><div><strong>AI 测试</strong><small>用模拟材料验证提炼质量</small></div>
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
<section v-if="activeSection === 'variables'" class="generation-editor-column">
|
||||
<article class="generation-panel">
|
||||
<header>
|
||||
<div><h3>自定义变量</h3><p>管理员可以定义名称、含义和取值方式;周报的 AI 变量从完整聊天材料提炼,月报的 AI 变量从周报材料提炼。</p></div>
|
||||
<el-button text :disabled="saving" @click="resetConfig">恢复系统默认</el-button>
|
||||
</header>
|
||||
<ContentGenerationVariableEditor
|
||||
v-model="form.variables"
|
||||
:source-options="current?.sourceOptions || []"
|
||||
/>
|
||||
</article>
|
||||
<article class="generation-panel">
|
||||
<header><div><h3>全局 AI 整理规则</h3><p>这里控制当前内容的语气和边界;每个字段具体提炼什么,由上方“变量含义”决定。</p></div></header>
|
||||
<el-input v-model="form.instructionContent" type="textarea" :rows="7" resize="vertical" />
|
||||
<small>{{ form.instructionContent.length }}/10000 字符</small>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<section v-else-if="activeSection === 'layout'" class="generation-editor-layout">
|
||||
<div class="generation-editor-column">
|
||||
<article class="generation-panel">
|
||||
<header>
|
||||
<div><h3>内容排版</h3><p>先把光标放到需要的位置,再点击变量。标题、说明、编号和空行都可以自由调整。</p></div>
|
||||
<el-button text :disabled="saving" @click="resetConfig">恢复系统默认</el-button>
|
||||
</header>
|
||||
<div class="variable-list">
|
||||
<button v-for="item in form.variables" :key="item.name" type="button" @click="insertVariable(item.name)">
|
||||
{{ item.label }} <code>{{ variableToken(item.name) }}</code>
|
||||
</button>
|
||||
</div>
|
||||
<el-input ref="templateInputRef" v-model="form.templateContent" type="textarea" :rows="21" resize="vertical" />
|
||||
<small>{{ form.templateContent.length }}/20000 字符</small>
|
||||
</article>
|
||||
<article class="locked-rules">
|
||||
<strong>系统锁定的安全提醒</strong>
|
||||
<p>{{ current?.lockedFooter }}</p>
|
||||
<small>这段内容会固定追加在当前类型的所有生成内容末尾,管理员不能删除。</small>
|
||||
</article>
|
||||
</div>
|
||||
<aside class="generation-preview-column">
|
||||
<article class="generation-panel preview-panel">
|
||||
<header>
|
||||
<div><h3>排版效果预览</h3><p>使用变量的“预览示例”填充;正式生成时,周报读取完整聊天,月报读取周报。</p></div>
|
||||
<el-button :loading="previewing" @click="refreshPreview">刷新</el-button>
|
||||
</header>
|
||||
<pre>{{ previewContent || "点击“刷新”查看效果" }}</pre>
|
||||
</article>
|
||||
</aside>
|
||||
</section>
|
||||
|
||||
<section v-else class="generation-editor-layout test-layout">
|
||||
<article class="generation-panel test-panel">
|
||||
<header><div><h3>测试材料</h3><p>粘贴一段模拟用户表达,AI 会按照当前自定义变量和变量含义进行提炼。</p></div></header>
|
||||
<el-input v-model="sampleText" type="textarea" :rows="14" resize="vertical" maxlength="20000" show-word-limit />
|
||||
<el-button type="primary" :loading="testing" @click="testGeneration">运行 AI 提炼测试</el-button>
|
||||
</article>
|
||||
<article class="generation-panel preview-panel test-result">
|
||||
<header><div><h3>测试结果</h3><p>本次测试不会保存配置,也不会生成正式用户内容。</p></div></header>
|
||||
<pre>{{ previewContent || "运行测试后在这里查看结果" }}</pre>
|
||||
<el-alert v-if="testUsedFallback" title="本次测试使用了安全回退结果,模型未返回有效结构化内容。" type="warning" :closable="false" show-icon />
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<section class="generation-history generation-panel">
|
||||
<header>
|
||||
<div><h3>版本记录</h3><p>当前配置:{{ current?.updatedByName }} · {{ formatDateTime(current?.updatedAt, "系统默认") }}</p></div>
|
||||
<el-tag v-if="current" type="success">{{ changeTypeLabel(current.changeType) }}</el-tag>
|
||||
</header>
|
||||
<el-empty v-if="!history.length" description="尚未保存过版本,当前使用系统默认配置" :image-size="72" />
|
||||
<div v-else class="generation-history-list">
|
||||
<article v-for="item in history" :key="item.id" :class="{ current: item.isCurrent }">
|
||||
<div>
|
||||
<strong>版本 #{{ item.id }} <el-tag v-if="item.isCurrent" size="small" type="success">当前</el-tag></strong>
|
||||
<p>{{ item.preview }}</p>
|
||||
<small>{{ changeTypeLabel(item.changeType) }} · {{ item.updatedByName }} · {{ formatDateTime(item.updatedAt, "系统默认") }}</small>
|
||||
</div>
|
||||
<el-button @click="openHistory(item)">查看</el-button>
|
||||
</article>
|
||||
</div>
|
||||
<AdminPagination
|
||||
v-if="historyTotal > 0"
|
||||
:page="historyPage"
|
||||
:page-size="historyPageSize"
|
||||
:total="historyTotal"
|
||||
@change="changeHistoryPagination"
|
||||
/>
|
||||
</section>
|
||||
|
||||
<el-dialog v-model="historyDialogOpen" title="内容生成配置历史版本" width="min(760px, 92vw)" append-to-body>
|
||||
<div v-loading="detailLoading" class="generation-history-detail">
|
||||
<template v-if="selectedHistory">
|
||||
<dl>
|
||||
<div><dt>版本</dt><dd>#{{ selectedHistory.id }}</dd></div>
|
||||
<div><dt>操作</dt><dd>{{ changeTypeLabel(selectedHistory.changeType) }}</dd></div>
|
||||
<div><dt>操作人</dt><dd>{{ selectedHistory.updatedByName }}</dd></div>
|
||||
<div><dt>时间</dt><dd>{{ formatDateTime(selectedHistory.updatedAt, "系统默认") }}</dd></div>
|
||||
</dl>
|
||||
<h4>变量定义</h4>
|
||||
<div class="history-variables">
|
||||
<span v-for="item in selectedHistory.variables" :key="item.name"><strong>{{ item.label }}</strong><code>{{ variableToken(item.name) }}</code></span>
|
||||
</div>
|
||||
<h4>内容模板</h4><pre>{{ selectedHistory.templateContent }}</pre>
|
||||
<h4>AI 整理规则</h4><pre>{{ selectedHistory.instructionContent }}</pre>
|
||||
</template>
|
||||
</div>
|
||||
<template #footer>
|
||||
<el-button @click="historyDialogOpen = false">关闭</el-button>
|
||||
<el-button v-if="selectedHistory" type="primary" :loading="saving" @click="restoreHistory(selectedHistory.id!)">回滚到此版本</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.content-generation-page { display: grid; gap: 18px; }
|
||||
.config-head-actions { display: flex; gap: 10px; }
|
||||
.content-type-tabs { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 12px; }
|
||||
.content-type-tabs button { min-height: 78px; display: grid; gap: 5px; padding: 15px 18px; border: 1px solid #dfe8e4; border-radius: 16px; background: #fff; color: #65746f; text-align: left; transition: border-color .18s ease, background .18s ease, transform .18s ease; }
|
||||
.content-type-tabs button:hover { border-color: #91c8b5; transform: translateY(-1px); }
|
||||
.content-type-tabs button.active { border-color: #43a783; background: #eff8f4; color: #176c51; box-shadow: 0 8px 24px rgba(23, 108, 81, .08); }
|
||||
.content-type-tabs strong { font-size: 16px; }
|
||||
.content-type-tabs span { font-size: 12px; }
|
||||
.workflow-tabs { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); overflow: hidden; border: 1px solid #dfe8e4; border-radius: 16px; background: #fff; }
|
||||
.workflow-tabs button { display: flex; align-items: center; gap: 11px; min-height: 68px; padding: 12px 18px; border: 0; border-right: 1px solid #e7eeeb; background: transparent; color: #73827d; text-align: left; }
|
||||
.workflow-tabs button:last-child { border-right: 0; }
|
||||
.workflow-tabs button > span { display: grid; flex: 0 0 28px; height: 28px; place-items: center; border-radius: 9px; background: #eef3f1; font-size: 12px; font-weight: 700; }
|
||||
.workflow-tabs button > div { display: grid; gap: 3px; }
|
||||
.workflow-tabs strong { font-size: 14px; }
|
||||
.workflow-tabs small { font-size: 11px; }
|
||||
.workflow-tabs button.active { background: #f0f8f5; color: #176c51; }
|
||||
.workflow-tabs button.active > span { background: #42a782; color: #fff; }
|
||||
.generation-editor-layout { display: grid; grid-template-columns: minmax(0, 1.15fr) minmax(360px, .85fr); gap: 16px; align-items: start; }
|
||||
.generation-editor-column, .generation-preview-column { min-width: 0; display: grid; gap: 16px; }
|
||||
.generation-panel, .locked-rules { padding: 18px; border: 1px solid #dfe8e4; border-radius: 16px; background: #fff; }
|
||||
.generation-panel > header { display: flex; align-items: flex-start; justify-content: space-between; gap: 14px; margin-bottom: 14px; }
|
||||
.generation-panel h3 { margin: 0; color: #1d342d; font-size: 17px; }
|
||||
.generation-panel header p { margin: 5px 0 0; color: #75847f; font-size: 12px; line-height: 1.6; }
|
||||
.generation-panel > small { display: block; margin-top: 7px; color: #8a9893; font-size: 11px; text-align: right; }
|
||||
.variable-list { display: flex; flex-wrap: wrap; gap: 7px; margin-bottom: 12px; }
|
||||
.variable-list button { padding: 6px 9px; border: 1px solid #d8e6e0; border-radius: 9px; background: #f7faf9; color: #49665c; font-size: 12px; }
|
||||
.variable-list button:hover { border-color: #62b596; background: #edf7f3; }
|
||||
.variable-list code { margin-left: 3px; color: #17805f; font-size: 11px; }
|
||||
.locked-rules { border-color: #e9dfbd; background: #fffaf0; }
|
||||
.locked-rules strong { color: #705d2e; }
|
||||
.locked-rules p { margin: 9px 0 4px; color: #655b43; font-size: 13px; line-height: 1.75; white-space: pre-wrap; }
|
||||
.locked-rules small { color: #8c805f; font-size: 11px; }
|
||||
.preview-panel { position: sticky; top: 18px; }
|
||||
.preview-panel pre, .generation-history-detail pre { max-height: 520px; overflow: auto; margin: 0; padding: 16px; border: 1px solid #e3ebe8; border-radius: 13px; background: #f8fbfa; color: #324b43; font: inherit; font-size: 13px; line-height: 1.8; white-space: pre-wrap; }
|
||||
.preview-panel .el-alert { margin-top: 12px; }
|
||||
.test-panel .el-button { width: 100%; margin-top: 12px; }
|
||||
.test-layout { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
.test-result { position: static; }
|
||||
.generation-history-list { display: grid; gap: 10px; }
|
||||
.generation-history-list article { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: center; gap: 14px; padding: 13px 14px; border: 1px solid #e4ebe8; border-radius: 13px; }
|
||||
.generation-history-list article.current { border-color: #83c8ae; background: #f1f8f5; }
|
||||
.generation-history-list article > div { min-width: 0; }
|
||||
.generation-history-list strong { display: flex; align-items: center; gap: 7px; color: #2b4039; font-size: 13px; }
|
||||
.generation-history-list p { overflow: hidden; margin: 5px 0; color: #64736e; font-size: 12px; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.generation-history-list small { color: #89958f; font-size: 11px; }
|
||||
.generation-history-detail { min-height: 180px; }
|
||||
.generation-history-detail dl { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 10px; margin: 0 0 18px; }
|
||||
.generation-history-detail dl div { padding: 10px; border-radius: 10px; background: #f5f8f7; }
|
||||
.generation-history-detail dt { color: #89958f; font-size: 11px; }
|
||||
.generation-history-detail dd { margin: 5px 0 0; color: #30483f; font-size: 13px; }
|
||||
.generation-history-detail h4 { margin: 18px 0 8px; }
|
||||
.history-variables { display: flex; flex-wrap: wrap; gap: 7px; }
|
||||
.history-variables span { display: flex; gap: 6px; padding: 7px 9px; border-radius: 9px; background: #f2f7f5; color: #466158; font-size: 12px; }
|
||||
.history-variables code { color: #16805e; }
|
||||
@media (max-width: 1100px) { .content-type-tabs { grid-template-columns: repeat(2, minmax(0, 1fr)); } .generation-editor-layout { grid-template-columns: 1fr; } .preview-panel { position: static; } }
|
||||
@media (max-width: 720px) { .content-type-tabs, .workflow-tabs { grid-template-columns: 1fr; } .workflow-tabs button { border-right: 0; border-bottom: 1px solid #e7eeeb; } .workflow-tabs button:last-child { border-bottom: 0; } .config-head-actions { width: 100%; } .config-head-actions .el-button { flex: 1; } .generation-history-detail dl { grid-template-columns: 1fr 1fr; } }
|
||||
</style>
|
||||
@@ -0,0 +1,115 @@
|
||||
<script setup lang="ts">
|
||||
import type { ContentGenerationSourceOption, ContentGenerationVariable } from "../types/api";
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: ContentGenerationVariable[];
|
||||
sourceOptions: ContentGenerationSourceOption[];
|
||||
}>();
|
||||
const emit = defineEmits<{ "update:modelValue": [value: ContentGenerationVariable[]] }>();
|
||||
|
||||
function update(index: number, patch: Partial<ContentGenerationVariable>) {
|
||||
emit("update:modelValue", props.modelValue.map((item, itemIndex) => itemIndex === index ? { ...item, ...patch } : item));
|
||||
}
|
||||
|
||||
function addVariable() {
|
||||
const names = new Set(props.modelValue.map((item) => item.name));
|
||||
let sequence = 1;
|
||||
while (names.has(`custom_field_${sequence}`)) sequence += 1;
|
||||
emit("update:modelValue", [
|
||||
...props.modelValue,
|
||||
{
|
||||
name: `custom_field_${sequence}`,
|
||||
label: `自定义内容 ${sequence}`,
|
||||
description: "请说明 AI 应该从对话材料中提炼什么内容,以及期望的表达方式",
|
||||
valueSource: "ai",
|
||||
sourceKey: null,
|
||||
sampleValue: "这里显示预览示例",
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
function removeVariable(index: number) {
|
||||
if (props.modelValue.length <= 1) return;
|
||||
emit("update:modelValue", props.modelValue.filter((_, itemIndex) => itemIndex !== index));
|
||||
}
|
||||
|
||||
function token(name: string) {
|
||||
return `{{${name || "field_name"}}}`;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="variable-editor">
|
||||
<div class="variable-editor-head">
|
||||
<div>
|
||||
<strong>变量清单</strong>
|
||||
<span>每类内容最多配置 30 个变量;AI 会严格按照“含义说明”提炼对应内容。</span>
|
||||
</div>
|
||||
<el-button type="primary" plain @click="addVariable">+ 新增变量</el-button>
|
||||
</div>
|
||||
|
||||
<article v-for="(item, index) in modelValue" :key="`${item.name}-${index}`" class="variable-card">
|
||||
<header>
|
||||
<div class="variable-index"><span>{{ index + 1 }}</span><strong>{{ item.label || "未命名变量" }}</strong></div>
|
||||
<el-button text type="danger" :disabled="modelValue.length <= 1" @click="removeVariable(index)">删除</el-button>
|
||||
</header>
|
||||
<div class="variable-fields">
|
||||
<label>
|
||||
<span>变量名称 <em>展示给管理员</em></span>
|
||||
<el-input :model-value="item.label" maxlength="50" @update:model-value="update(index, { label: String($event) })" />
|
||||
</label>
|
||||
<label>
|
||||
<span>变量标识 <em>用于排版,例如 <code>{{ token(item.name) }}</code></em></span>
|
||||
<el-input :model-value="item.name" maxlength="40" @update:model-value="update(index, { name: String($event).toLowerCase().replace(/[^a-z0-9_]/g, '') })" />
|
||||
</label>
|
||||
<label>
|
||||
<span>取值方式</span>
|
||||
<el-select :model-value="item.valueSource" @update:model-value="update(index, { valueSource: $event as 'ai' | 'context', sourceKey: $event === 'ai' ? null : (item.sourceKey || sourceOptions[0]?.key || null) })">
|
||||
<el-option label="AI 根据变量含义提炼" value="ai" />
|
||||
<el-option label="直接使用系统上下文字段" value="context" />
|
||||
</el-select>
|
||||
</label>
|
||||
<label v-if="item.valueSource === 'context'">
|
||||
<span>系统字段</span>
|
||||
<el-select :model-value="item.sourceKey" @update:model-value="update(index, { sourceKey: String($event) })">
|
||||
<el-option v-for="option in sourceOptions" :key="option.key" :label="option.label" :value="option.key" />
|
||||
</el-select>
|
||||
</label>
|
||||
<label class="full">
|
||||
<span>变量含义 <em>越具体,AI 提炼越稳定</em></span>
|
||||
<el-input
|
||||
:model-value="item.description"
|
||||
type="textarea"
|
||||
:rows="2"
|
||||
maxlength="500"
|
||||
show-word-limit
|
||||
@update:model-value="update(index, { description: String($event) })"
|
||||
/>
|
||||
</label>
|
||||
<label class="full">
|
||||
<span>预览示例 <em>只用于排版预览,不会进入正式内容</em></span>
|
||||
<el-input :model-value="item.sampleValue" maxlength="1000" @update:model-value="update(index, { sampleValue: String($event) })" />
|
||||
</label>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.variable-editor { display: grid; gap: 12px; }
|
||||
.variable-editor-head { display: flex; align-items: center; justify-content: space-between; gap: 16px; padding: 2px 2px 8px; }
|
||||
.variable-editor-head > div { display: grid; gap: 4px; }
|
||||
.variable-editor-head strong { color: #243d35; font-size: 15px; }
|
||||
.variable-editor-head span { color: #768780; font-size: 12px; }
|
||||
.variable-card { padding: 16px; border: 1px solid #dfe9e5; border-radius: 14px; background: #fbfdfc; }
|
||||
.variable-card header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 13px; }
|
||||
.variable-index { display: flex; align-items: center; gap: 9px; color: #29453b; }
|
||||
.variable-index > span { display: grid; width: 24px; height: 24px; place-items: center; border-radius: 8px; background: #e5f4ee; color: #188060; font-size: 12px; }
|
||||
.variable-fields { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 13px 14px; }
|
||||
.variable-fields label { display: grid; min-width: 0; gap: 7px; }
|
||||
.variable-fields label.full { grid-column: 1 / -1; }
|
||||
.variable-fields label > span { color: #42574f; font-size: 12px; font-weight: 600; }
|
||||
.variable-fields em { margin-left: 5px; color: #8a9993; font-size: 11px; font-style: normal; font-weight: 400; }
|
||||
.variable-fields .el-select { width: 100%; }
|
||||
@media (max-width: 760px) { .variable-fields { grid-template-columns: 1fr; } .variable-fields label.full { grid-column: auto; } .variable-editor-head { align-items: flex-start; } }
|
||||
</style>
|
||||
@@ -0,0 +1,171 @@
|
||||
<script setup lang="ts">
|
||||
import { ElMessage } from "element-plus";
|
||||
import { LineChart } from "echarts/charts";
|
||||
import { DataZoomComponent, GridComponent, LegendComponent, MarkPointComponent, TooltipComponent } from "echarts/components";
|
||||
import { init, use, type ECharts } from "echarts/core";
|
||||
import { CanvasRenderer } from "echarts/renderers";
|
||||
import { nextTick, onBeforeUnmount, onMounted, reactive, ref } from "vue";
|
||||
|
||||
import { api } from "../services/api";
|
||||
import type { DashboardStats, PeakTrafficResult, TrafficGrain } from "../types/api";
|
||||
import { formatDateTime } from "../utils/dateTime";
|
||||
|
||||
use([LineChart, GridComponent, TooltipComponent, LegendComponent, DataZoomComponent, MarkPointComponent, CanvasRenderer]);
|
||||
|
||||
const stats = ref<DashboardStats | null>(null);
|
||||
const traffic = ref<PeakTrafficResult | null>(null);
|
||||
const trafficChart = ref<HTMLElement | null>(null);
|
||||
let trafficChartInstance: ECharts | null = null;
|
||||
const trafficGrain = ref<TrafficGrain>("hour");
|
||||
const storageStats = ref<Record<string, any> | null>(null);
|
||||
const storageLoading = ref(false);
|
||||
const trafficLoading = ref(false);
|
||||
const loading = ref(false);
|
||||
const filters = reactive({ start: "", end: "" });
|
||||
|
||||
const trafficOptions: Array<{ label: string; value: TrafficGrain }> = [
|
||||
{ label: "分钟", value: "minute" },
|
||||
{ label: "小时", value: "hour" },
|
||||
{ label: "天", value: "day" },
|
||||
{ label: "周", value: "week" },
|
||||
];
|
||||
|
||||
onMounted(async () => {
|
||||
await Promise.all([loadDashboard(), loadTraffic()]);
|
||||
window.addEventListener("resize", resizeTrafficChart);
|
||||
});
|
||||
onBeforeUnmount(() => { window.removeEventListener("resize", resizeTrafficChart); trafficChartInstance?.dispose(); });
|
||||
|
||||
async function loadDashboard() {
|
||||
loading.value = true;
|
||||
try {
|
||||
stats.value = await api.dashboard(filters.start, filters.end);
|
||||
storageLoading.value = true;
|
||||
api.storageStats().then((value) => { storageStats.value = value; }).catch(() => { storageStats.value = null; }).finally(() => { storageLoading.value = false; });
|
||||
} finally { loading.value = false; }
|
||||
}
|
||||
|
||||
async function loadTraffic(grain: TrafficGrain = trafficGrain.value) {
|
||||
trafficGrain.value = grain;
|
||||
trafficLoading.value = true;
|
||||
try { traffic.value = await api.peakTraffic(grain); await nextTick(); renderTrafficChart(); }
|
||||
catch (error) { ElMessage.error(error instanceof Error ? error.message : "峰值流量加载失败"); }
|
||||
finally { trafficLoading.value = false; }
|
||||
}
|
||||
|
||||
function resizeTrafficChart() { trafficChartInstance?.resize(); }
|
||||
|
||||
function renderTrafficChart() {
|
||||
if (!trafficChart.value) return;
|
||||
trafficChartInstance ??= init(trafficChart.value);
|
||||
const rows = traffic.value?.rows ?? [];
|
||||
const zoomStart = trafficGrain.value === "minute" ? 98 : trafficGrain.value === "hour" ? 70 : 0;
|
||||
trafficChartInstance.setOption({
|
||||
animationDuration: 350,
|
||||
color: ["#176b55", "#d66a57"],
|
||||
grid: { left: 52, right: 28, top: 36, bottom: rows.length > 30 ? 72 : 48 },
|
||||
tooltip: {
|
||||
trigger: "axis",
|
||||
formatter(params: Array<{ dataIndex: number; marker: string; seriesName: string; value: number }>) {
|
||||
const index = params[0]?.dataIndex ?? 0;
|
||||
const row = rows[index];
|
||||
if (!row) return "";
|
||||
return `<strong>${row.periodLabel}</strong><br/>${params.map((item) => `${item.marker}${item.seriesName}:${item.value}`).join("<br/>")}<br/><span style="color:#7b8b85">平均响应:${row.avgResponseMs} ms</span>`;
|
||||
},
|
||||
},
|
||||
legend: { top: 0, right: 0, data: ["请求数", "5xx 错误"] },
|
||||
xAxis: { type: "category", boundaryGap: false, data: rows.map((row) => row.periodLabel), axisLabel: { color: "#71827c", hideOverlap: true }, axisLine: { lineStyle: { color: "#dce6e2" } } },
|
||||
yAxis: { type: "value", minInterval: 1, name: "请求数", nameTextStyle: { color: "#71827c" }, splitLine: { lineStyle: { color: "#edf2f0" } } },
|
||||
dataZoom: rows.length > 30 ? [{ type: "inside", start: zoomStart, end: 100 }, { type: "slider", start: zoomStart, end: 100, height: 18, bottom: 8, borderColor: "#dce6e2", fillerColor: "rgba(23,107,85,.12)" }] : [],
|
||||
series: [
|
||||
{ name: "请求数", type: "line", smooth: true, showSymbol: rows.length < 80, symbolSize: 7, lineStyle: { width: 3 }, areaStyle: { color: "rgba(23,107,85,.10)" }, data: rows.map((row) => row.requestCount), markPoint: { symbolSize: 48, data: [{ type: "max", name: "峰值" }] } },
|
||||
{ name: "5xx 错误", type: "line", smooth: true, showSymbol: false, lineStyle: { width: 2, type: "dashed" }, data: rows.map((row) => row.errorCount) },
|
||||
],
|
||||
}, true);
|
||||
}
|
||||
|
||||
async function refreshStorage() {
|
||||
storageLoading.value = true;
|
||||
try { storageStats.value = await api.storageStats(true); ElMessage.success("存储统计已刷新"); }
|
||||
finally { storageLoading.value = false; }
|
||||
}
|
||||
|
||||
function formatBytes(value: unknown) {
|
||||
if (typeof value !== "number") return "无法统计";
|
||||
const units = ["B", "KB", "MB", "GB", "TB"];
|
||||
let size = value; let index = 0;
|
||||
while (size >= 1024 && index < units.length - 1) { size /= 1024; index += 1; }
|
||||
return `${size.toFixed(index ? 2 : 0)} ${units[index]}`;
|
||||
}
|
||||
|
||||
function formatMoney(value?: number | null, currency = "CNY") {
|
||||
if (typeof value !== "number") return "-";
|
||||
if (currency === "MIXED") return "多币种,见明细";
|
||||
return `${currency || "CNY"} ${value.toFixed(6)}`;
|
||||
}
|
||||
|
||||
function modelSceneLabel(scene: string) {
|
||||
return ({ background_report: "周期报告", background_summary: "摘要沉淀", fixed_info: "固定信息问答", simple_knowledge: "简单知识问答", knowledge_grounded: "知识问答", general_chat: "通用对话" } as Record<string, string>)[scene] || scene || "未分类";
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section v-loading="loading" class="feature-page dashboard-page">
|
||||
<header class="dashboard-hero">
|
||||
<div>
|
||||
<span class="dashboard-eyebrow">OPERATIONS OVERVIEW</span>
|
||||
<h2>数据看板</h2>
|
||||
<p>从业务规模、请求峰值到模型成本,快速判断系统运行状态。</p>
|
||||
</div>
|
||||
<div class="dashboard-date-range" aria-label="看板日期范围">
|
||||
<label class="dashboard-date-field"><span>开始日期</span><input v-model="filters.start" type="date" aria-label="开始日期" @change="loadDashboard" /></label>
|
||||
<span class="record-date-sep">至</span>
|
||||
<label class="dashboard-date-field"><span>结束日期</span><input v-model="filters.end" type="date" aria-label="结束日期" @change="loadDashboard" /></label>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section class="overview-grid" aria-label="核心业务指标">
|
||||
<article class="overview-card primary"><span>用户总数</span><strong>{{ stats?.userCount ?? 0 }}</strong><small>当前已沉淀用户</small></article>
|
||||
<article class="overview-card"><span>会话数</span><strong>{{ stats?.sessionCount ?? 0 }}</strong><small>选定日期范围</small></article>
|
||||
<article class="overview-card"><span>消息数</span><strong>{{ stats?.messageCount ?? 0 }}</strong><small>用户与 Agent 对话</small></article>
|
||||
<article class="overview-card"><span>AI 请求</span><strong>{{ stats?.aiRequestCount ?? 0 }}</strong><small>模型实际调用次数</small></article>
|
||||
<article class="overview-card"><span>估算成本</span><strong class="money">{{ formatMoney(stats?.estimatedCost, stats?.costCurrency || "CNY") }}</strong><small>按已配置模型单价</small></article>
|
||||
</section>
|
||||
|
||||
<section class="dashboard-panel traffic-panel" v-loading="trafficLoading">
|
||||
<div class="panel-heading">
|
||||
<div><span class="section-kicker">流量监测</span><h3>峰值流量</h3><p>折线图展示最近 7 天变化趋势,可切换时间粒度查看。</p></div>
|
||||
<div class="grain-switch" role="group" aria-label="流量统计粒度">
|
||||
<button v-for="option in trafficOptions" :key="option.value" type="button" :class="{ active: trafficGrain === option.value }" @click="loadTraffic(option.value)">{{ option.label }}</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="traffic-summary">
|
||||
<div><span>峰值时段</span><strong>{{ traffic?.peakPeriod || "暂无数据" }}</strong></div>
|
||||
<div><span>峰值请求数</span><strong>{{ traffic?.peakRequests ?? 0 }}</strong></div>
|
||||
<div><span>近 7 天总请求</span><strong>{{ traffic?.totalRequests ?? 0 }}</strong></div>
|
||||
</div>
|
||||
<div v-if="traffic?.rows.length" ref="trafficChart" class="traffic-chart" role="img" aria-label="最近七天峰值流量折线图"></div>
|
||||
<el-empty v-else description="暂无流量数据,新请求产生后会自动统计" :image-size="72" />
|
||||
</section>
|
||||
|
||||
<section class="dashboard-panel">
|
||||
<div class="panel-heading"><div><span class="section-kicker">模型资源</span><h3>模型使用与成本</h3><p>核对模型分流、Token 消耗和费用。</p></div><div class="token-summary"><span>总 Token</span><strong>{{ stats?.totalToken ?? 0 }}</strong><small>输入 {{ stats?.inputToken ?? 0 }} · 输出 {{ stats?.outputToken ?? 0 }}</small></div></div>
|
||||
<el-table :data="stats?.costBreakdown || []" empty-text="当前日期范围内暂无模型调用">
|
||||
<el-table-column label="调用场景" min-width="130"><template #default="{ row }">{{ modelSceneLabel(row.scene) }}</template></el-table-column>
|
||||
<el-table-column prop="modelName" label="实际模型" min-width="180" show-overflow-tooltip />
|
||||
<el-table-column prop="requestCount" label="请求数" width="100" /><el-table-column prop="inputToken" label="输入 Token" width="130" /><el-table-column prop="outputToken" label="输出 Token" width="130" />
|
||||
<el-table-column label="估算成本" width="170"><template #default="{ row }">{{ formatMoney(row.estimatedCost, row.currency || "CNY") }}</template></el-table-column>
|
||||
</el-table>
|
||||
</section>
|
||||
|
||||
<section class="dashboard-panel" v-loading="storageLoading">
|
||||
<div class="panel-heading"><div><span class="section-kicker">基础设施</span><h3>项目存储占用</h3><p>数据库、Redis 与附件分别统计,无法取得时明确标记。</p></div><el-button :loading="storageLoading" @click="refreshStorage">手动刷新</el-button></div>
|
||||
<div class="storage-grid"><div><span>项目总量</span><strong>{{ formatBytes(storageStats?.totalBytes) }}</strong></div><div><span>数据库</span><strong>{{ formatBytes(storageStats?.detail?.database?.bytes) }}</strong></div><div><span>Redis</span><strong>{{ formatBytes(storageStats?.detail?.redis?.bytes) }}</strong></div><div><span>附件/文件</span><strong>{{ formatBytes(storageStats?.detail?.files?.bytes) }}</strong></div><div><span>近7天增长</span><strong>{{ formatBytes(storageStats?.growth7DaysBytes) }}</strong></div><div><span>近30天增长</span><strong>{{ formatBytes(storageStats?.growth30DaysBytes) }}</strong></div></div>
|
||||
<small class="storage-updated">最后统计:{{ formatDateTime(storageStats?.createdAt, "尚未统计") }}</small>
|
||||
</section>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.dashboard-page{--dash-border:#dce6e2;--dash-muted:#687b74;--dash-green:#176b55;display:grid;gap:20px}.dashboard-hero{display:flex;align-items:flex-end;justify-content:space-between;gap:24px;padding:24px 26px;border:1px solid var(--dash-border);border-radius:14px;background:linear-gradient(135deg,#f5fbf8 0%,#fff 64%)}.dashboard-eyebrow,.section-kicker{display:block;margin-bottom:6px;color:#2a8069;font-size:11px;font-weight:700;letter-spacing:.12em}.dashboard-hero h2,.panel-heading h3{margin:0}.dashboard-hero h2{font-size:28px}.dashboard-hero p,.panel-heading p{margin:7px 0 0;color:var(--dash-muted);line-height:1.5}.dashboard-date-range{padding:8px;border:1px solid var(--dash-border);border-radius:10px;background:#fff}.dashboard-date-field{display:flex;align-items:center;gap:8px}.dashboard-date-field span{color:var(--dash-muted);font-size:12px;white-space:nowrap}.dashboard-date-field input{height:34px;border:0;outline:0;color:#283631;background:transparent}.overview-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:12px}.overview-card{min-height:126px;padding:18px;border:1px solid var(--dash-border);border-radius:12px;background:#fff}.overview-card.primary{border-color:#b9d9ce;background:#f0f8f5}.overview-card span,.traffic-summary span,.token-summary span{color:var(--dash-muted);font-size:13px}.overview-card strong{display:block;margin:12px 0 7px;color:#1d2925;font-size:28px;line-height:1}.overview-card strong.money{font-size:20px;line-height:1.3}.overview-card small,.token-summary small{color:#8a9a94}.dashboard-panel{padding:22px;border:1px solid var(--dash-border);border-radius:14px;background:#fff}.panel-heading{display:flex;align-items:flex-start;justify-content:space-between;gap:20px;margin-bottom:18px}.panel-heading h3{font-size:20px}.grain-switch{display:flex;padding:4px;border:1px solid var(--dash-border);border-radius:10px;background:#f5f8f7}.grain-switch button{min-width:56px;height:34px;padding:0 13px;border:0;border-radius:7px;color:#586b64;background:transparent;cursor:pointer}.grain-switch button:hover{color:var(--dash-green)}.grain-switch button.active{color:#fff;background:var(--dash-green);box-shadow:0 2px 6px rgba(23,107,85,.18)}.traffic-summary{display:grid;grid-template-columns:2fr 1fr 1fr;margin-bottom:16px;border:1px solid #e6ece9;border-radius:10px;background:#f8faf9}.traffic-summary>div{padding:15px 18px;border-right:1px solid #e6ece9}.traffic-summary>div:last-child{border-right:0}.traffic-summary strong{display:block;margin-top:6px;font-size:19px}.traffic-chart{width:100%;height:360px}.token-summary{min-width:220px;padding:12px 16px;border-radius:10px;background:#f5f8f7}.token-summary strong{display:block;margin:3px 0;font-size:22px}.storage-grid{margin:0}.storage-updated{display:block;margin-top:14px;color:#7d8e88}@media(max-width:900px){.dashboard-hero,.panel-heading{align-items:stretch;flex-direction:column}.dashboard-date-range{align-self:stretch;overflow:auto}.overview-grid{grid-template-columns:repeat(2,minmax(0,1fr))}.traffic-summary{grid-template-columns:1fr}.traffic-summary>div{border-right:0;border-bottom:1px solid #e6ece9}.traffic-summary>div:last-child{border-bottom:0}.grain-switch{overflow:auto}.grain-switch button{flex:1}.traffic-chart{height:320px}}@media(max-width:560px){.overview-grid{grid-template-columns:1fr}.dashboard-panel,.dashboard-hero{padding:16px}}
|
||||
</style>
|
||||
@@ -0,0 +1,557 @@
|
||||
<script setup lang="ts">
|
||||
import { ElMessage } from "element-plus";
|
||||
import { computed, nextTick, reactive, ref } from "vue";
|
||||
|
||||
import { api } from "../services/api";
|
||||
import type { EntitlementPlan } from "../types/api";
|
||||
|
||||
const props = defineProps<{
|
||||
plans: EntitlementPlan[];
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
refreshed: [];
|
||||
}>();
|
||||
|
||||
const editorRef = ref<HTMLElement | null>(null);
|
||||
const editingPlanId = ref<number | null>(null);
|
||||
const saving = ref(false);
|
||||
|
||||
const form = reactive({
|
||||
name: "",
|
||||
planType: "basic" as "basic" | "deep" | "addon",
|
||||
description: "",
|
||||
validityDays: null as number | null,
|
||||
enableGrowthProfile: 0,
|
||||
enablePeriodicReports: 0,
|
||||
allowHelpCard: 1,
|
||||
allowShareDraft: 1,
|
||||
status: 1,
|
||||
sortOrder: 10,
|
||||
});
|
||||
|
||||
const enabledCount = computed(() => props.plans.filter((plan) => plan.status === 1).length);
|
||||
|
||||
const capabilityOptions = [
|
||||
{
|
||||
key: "enableGrowthProfile" as const,
|
||||
title: "近期实修回顾",
|
||||
description: "基于近期主题保持连续性,不做长期画像和成长评价",
|
||||
},
|
||||
{
|
||||
key: "enablePeriodicReports" as const,
|
||||
title: "周期报告",
|
||||
description: "允许生成周报、月报和阶段报告",
|
||||
},
|
||||
{
|
||||
key: "allowHelpCard" as const,
|
||||
title: "求助卡片",
|
||||
description: "允许学员整理内容并生成求助卡片",
|
||||
},
|
||||
{
|
||||
key: "allowShareDraft" as const,
|
||||
title: "分享草稿",
|
||||
description: "允许将对话内容整理为可分享草稿",
|
||||
},
|
||||
];
|
||||
|
||||
function planTypeLabel(type: string) {
|
||||
return {
|
||||
basic: "基础版",
|
||||
deep: "深度陪伴版",
|
||||
addon: "高频加购包",
|
||||
}[type] || type;
|
||||
}
|
||||
|
||||
function resetForm() {
|
||||
editingPlanId.value = null;
|
||||
Object.assign(form, {
|
||||
name: "",
|
||||
planType: "basic",
|
||||
description: "",
|
||||
validityDays: null,
|
||||
enableGrowthProfile: 0,
|
||||
enablePeriodicReports: 0,
|
||||
allowHelpCard: 1,
|
||||
allowShareDraft: 1,
|
||||
status: 1,
|
||||
sortOrder: 10,
|
||||
});
|
||||
}
|
||||
|
||||
async function savePlan() {
|
||||
if (!form.name.trim()) {
|
||||
ElMessage.warning("请填写权益版本名称");
|
||||
return;
|
||||
}
|
||||
saving.value = true;
|
||||
try {
|
||||
const payload = { ...form, name: form.name.trim(), description: form.description.trim() };
|
||||
if (editingPlanId.value) {
|
||||
await api.updateEntitlementPlan(editingPlanId.value, payload);
|
||||
ElMessage.success("权益版本已更新");
|
||||
} else {
|
||||
await api.createEntitlementPlan(payload);
|
||||
ElMessage.success("权益版本已新增");
|
||||
}
|
||||
resetForm();
|
||||
emit("refreshed");
|
||||
} catch (error) {
|
||||
ElMessage.error(error instanceof Error ? error.message : "权益版本保存失败");
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function editPlan(plan: EntitlementPlan) {
|
||||
editingPlanId.value = plan.id;
|
||||
Object.assign(form, {
|
||||
name: plan.name,
|
||||
planType: plan.planType,
|
||||
description: plan.description ?? "",
|
||||
validityDays: plan.validityDays ?? null,
|
||||
enableGrowthProfile: plan.enableGrowthProfile ? 1 : 0,
|
||||
enablePeriodicReports: plan.enablePeriodicReports ? 1 : 0,
|
||||
allowHelpCard: plan.allowHelpCard ? 1 : 0,
|
||||
allowShareDraft: plan.allowShareDraft ? 1 : 0,
|
||||
status: plan.status,
|
||||
sortOrder: plan.sortOrder,
|
||||
});
|
||||
await nextTick();
|
||||
editorRef.value?.scrollIntoView({ behavior: "smooth", block: "start" });
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page-head entitlement-page-head">
|
||||
<div>
|
||||
<h2>权益管理</h2>
|
||||
<p>配置不同服务版本的有效期与可用能力,保存后可在用户管理中直接分配。</p>
|
||||
</div>
|
||||
<div class="entitlement-summary" aria-label="权益版本统计">
|
||||
<span><strong>{{ plans.length }}</strong> 个版本</span>
|
||||
<span><strong>{{ enabledCount }}</strong> 个启用</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section ref="editorRef" class="entitlement-editor">
|
||||
<div class="entitlement-editor-head">
|
||||
<div>
|
||||
<div class="entitlement-editor-title-row">
|
||||
<h3>{{ editingPlanId ? "编辑权益版本" : "新增权益版本" }}</h3>
|
||||
<span class="editor-mode">{{ editingPlanId ? "编辑中" : "新建" }}</span>
|
||||
</div>
|
||||
<p>先设置版本信息,再决定学员可使用的产品能力。</p>
|
||||
</div>
|
||||
<el-button v-if="editingPlanId" @click="resetForm">退出编辑</el-button>
|
||||
<el-button v-else @click="resetForm">重置表单</el-button>
|
||||
</div>
|
||||
|
||||
<el-form label-position="top" :model="form" @submit.prevent="savePlan">
|
||||
<div class="entitlement-section-label">
|
||||
<strong>基础配置</strong>
|
||||
<span>名称和说明仅用于后台识别,不展示给学员</span>
|
||||
</div>
|
||||
<div class="entitlement-basic-grid">
|
||||
<el-form-item class="field-name" label="版本名称" required>
|
||||
<el-input v-model="form.name" maxlength="50" placeholder="例如:五个月深度陪伴版" />
|
||||
</el-form-item>
|
||||
<el-form-item label="版本类型">
|
||||
<el-select v-model="form.planType">
|
||||
<el-option label="基础版" value="basic" />
|
||||
<el-option label="深度陪伴版" value="deep" />
|
||||
<el-option label="高频加购包" value="addon" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="版本状态">
|
||||
<div class="status-switch-field">
|
||||
<div>
|
||||
<strong>{{ form.status === 1 ? "启用" : "停用" }}</strong>
|
||||
<small>{{ form.status === 1 ? "可分配给学员" : "保留配置,不再分配" }}</small>
|
||||
</div>
|
||||
<el-switch v-model="form.status" :active-value="1" :inactive-value="0" />
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="有效天数">
|
||||
<el-input-number v-model="form.validityDays" :min="1" :max="3650" placeholder="留空即长期" />
|
||||
<div class="field-help">不填写表示长期有效</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="列表排序">
|
||||
<el-input-number v-model="form.sortOrder" :min="0" :max="9999" />
|
||||
<div class="field-help">数字越小,在列表中越靠前</div>
|
||||
</el-form-item>
|
||||
<el-form-item class="field-description" label="后台说明">
|
||||
<el-input v-model="form.description" type="textarea" :rows="3" maxlength="500" show-word-limit placeholder="补充适用人群、售卖规则或内部说明" />
|
||||
</el-form-item>
|
||||
</div>
|
||||
|
||||
<div class="entitlement-section-label capability-heading">
|
||||
<strong>能力权限</strong>
|
||||
<span>关闭后,前后端都会按权益规则限制对应能力</span>
|
||||
</div>
|
||||
<div class="entitlement-switch-grid">
|
||||
<label v-for="item in capabilityOptions" :key="item.key" class="capability-switch-card">
|
||||
<span>
|
||||
<strong>{{ item.title }}</strong>
|
||||
<small>{{ item.description }}</small>
|
||||
</span>
|
||||
<el-switch v-model="form[item.key]" :active-value="1" :inactive-value="0" />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="entitlement-form-actions">
|
||||
<el-button type="primary" native-type="submit" :loading="saving">
|
||||
{{ editingPlanId ? "保存修改" : "新增权益版本" }}
|
||||
</el-button>
|
||||
<el-button v-if="editingPlanId" @click="resetForm">取消编辑</el-button>
|
||||
</div>
|
||||
</el-form>
|
||||
</section>
|
||||
|
||||
<section class="entitlement-list-panel">
|
||||
<div class="entitlement-list-head">
|
||||
<div>
|
||||
<h3>已有权益版本</h3>
|
||||
<p>停用版本不会再出现在可分配列表中,已保存的数据仍会保留。</p>
|
||||
</div>
|
||||
</div>
|
||||
<el-table :data="plans" stripe>
|
||||
<el-table-column label="版本" min-width="210">
|
||||
<template #default="{ row }">
|
||||
<div class="plan-name-cell">
|
||||
<strong>{{ row.name }}</strong>
|
||||
<small>{{ row.description || "暂无后台说明" }}</small>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="类型" width="130">
|
||||
<template #default="{ row }">{{ planTypeLabel(row.planType) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="有效期" width="110" align="center">
|
||||
<template #default="{ row }">{{ row.validityDays ? `${row.validityDays} 天` : "长期" }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="能力" min-width="360">
|
||||
<template #default="{ row }">
|
||||
<div class="entitlement-capabilities">
|
||||
<el-tag v-if="row.enableGrowthProfile" type="success" effect="plain">近期实修回顾</el-tag>
|
||||
<el-tag v-if="row.enablePeriodicReports" type="success" effect="plain">周期报告</el-tag>
|
||||
<el-tag v-if="row.allowHelpCard" effect="plain">求助卡片</el-tag>
|
||||
<el-tag v-if="row.allowShareDraft" effect="plain">分享草稿</el-tag>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="状态" width="90" align="center">
|
||||
<template #default="{ row }"><el-tag :type="row.status === 1 ? 'success' : 'info'">{{ row.status === 1 ? "启用" : "停用" }}</el-tag></template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="112" fixed="right" align="center">
|
||||
<template #default="{ row }"><el-button size="small" @click="editPlan(row)">编辑</el-button></template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.entitlement-page-head {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: space-between;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.entitlement-summary {
|
||||
display: flex;
|
||||
flex: 0 0 auto;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.entitlement-summary span {
|
||||
padding: 8px 12px;
|
||||
border: 1px solid #dfe8e5;
|
||||
border-radius: 999px;
|
||||
background: #fff;
|
||||
color: #71817b;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.entitlement-summary strong {
|
||||
color: #1f8d68;
|
||||
}
|
||||
|
||||
.entitlement-editor,
|
||||
.entitlement-list-panel {
|
||||
border: 1px solid #dfe8e5;
|
||||
border-radius: 14px;
|
||||
background: #fff;
|
||||
box-shadow: 0 8px 26px rgb(31 65 54 / 4%);
|
||||
}
|
||||
|
||||
.entitlement-editor {
|
||||
scroll-margin-top: 20px;
|
||||
padding: 22px;
|
||||
}
|
||||
|
||||
.entitlement-editor-head,
|
||||
.entitlement-editor-title-row,
|
||||
.entitlement-list-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.entitlement-editor-head {
|
||||
justify-content: space-between;
|
||||
gap: 18px;
|
||||
padding-bottom: 18px;
|
||||
border-bottom: 1px solid #edf2f0;
|
||||
}
|
||||
|
||||
.entitlement-editor-head h3,
|
||||
.entitlement-editor-head p,
|
||||
.entitlement-list-head h3,
|
||||
.entitlement-list-head p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.entitlement-editor-head h3,
|
||||
.entitlement-list-head h3 {
|
||||
color: #263a34;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.entitlement-editor-head p,
|
||||
.entitlement-list-head p {
|
||||
margin-top: 5px;
|
||||
color: #71817b;
|
||||
font-size: 13px;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.entitlement-editor-title-row {
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.editor-mode {
|
||||
padding: 3px 8px;
|
||||
border-radius: 999px;
|
||||
background: #edf7f3;
|
||||
color: #218361;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.entitlement-section-label {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 10px;
|
||||
margin: 20px 0 12px;
|
||||
}
|
||||
|
||||
.entitlement-section-label strong {
|
||||
color: #263a34;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.entitlement-section-label span {
|
||||
color: #8a9893;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.entitlement-basic-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 0 16px;
|
||||
}
|
||||
|
||||
.field-name {
|
||||
grid-column: span 2;
|
||||
}
|
||||
|
||||
.field-description {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.entitlement-basic-grid :deep(.el-select),
|
||||
.entitlement-basic-grid :deep(.el-input-number) {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.entitlement-basic-grid :deep(.el-form-item) {
|
||||
margin-bottom: 17px;
|
||||
}
|
||||
|
||||
.status-switch-field {
|
||||
width: 100%;
|
||||
min-height: 40px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
box-sizing: border-box;
|
||||
padding: 5px 12px;
|
||||
border: 1px solid #dcdfe6;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.status-switch-field div,
|
||||
.plan-name-cell,
|
||||
.capability-switch-card > span {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.status-switch-field strong,
|
||||
.status-switch-field small,
|
||||
.plan-name-cell strong,
|
||||
.plan-name-cell small,
|
||||
.capability-switch-card strong,
|
||||
.capability-switch-card small {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.status-switch-field strong {
|
||||
color: #40524b;
|
||||
font-size: 13px;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.status-switch-field small,
|
||||
.field-help {
|
||||
color: #98a49f;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.field-help {
|
||||
margin-top: 5px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.capability-heading {
|
||||
margin-top: 5px;
|
||||
}
|
||||
|
||||
.entitlement-switch-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.capability-switch-card {
|
||||
min-height: 64px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 14px;
|
||||
box-sizing: border-box;
|
||||
padding: 11px 13px;
|
||||
border: 1px solid #dfe8e5;
|
||||
border-radius: 10px;
|
||||
background: #f8fbfa;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.2s ease, background-color 0.2s ease;
|
||||
}
|
||||
|
||||
.capability-switch-card:hover {
|
||||
border-color: #b8d9cd;
|
||||
background: #f4faf7;
|
||||
}
|
||||
|
||||
.capability-switch-card strong {
|
||||
color: #40524b;
|
||||
font-size: 13px;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.capability-switch-card small {
|
||||
margin-top: 4px;
|
||||
color: #82918c;
|
||||
font-size: 11px;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.capability-switch-card :deep(.el-switch) {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.entitlement-form-actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.entitlement-form-actions :deep(.el-button) {
|
||||
min-width: 112px;
|
||||
}
|
||||
|
||||
.entitlement-list-panel {
|
||||
margin-top: 16px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.entitlement-list-head {
|
||||
min-height: 74px;
|
||||
box-sizing: border-box;
|
||||
padding: 14px 20px;
|
||||
border-bottom: 1px solid #edf2f0;
|
||||
}
|
||||
|
||||
.plan-name-cell strong {
|
||||
overflow: hidden;
|
||||
color: #344942;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.plan-name-cell small {
|
||||
overflow: hidden;
|
||||
margin-top: 4px;
|
||||
color: #86948f;
|
||||
font-size: 12px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.entitlement-capabilities {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
@media (max-width: 1180px) {
|
||||
.entitlement-basic-grid,
|
||||
.entitlement-switch-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.field-name {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.entitlement-page-head {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.entitlement-editor {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.entitlement-editor-head {
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.entitlement-basic-grid,
|
||||
.entitlement-switch-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.field-name,
|
||||
.field-description {
|
||||
grid-column: auto;
|
||||
}
|
||||
|
||||
.entitlement-section-label {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,200 @@
|
||||
<script setup lang="ts">
|
||||
import { ElMessage, ElMessageBox } from "element-plus";
|
||||
import { nextTick, onMounted, reactive, ref } from "vue";
|
||||
|
||||
import { api } from "../services/api";
|
||||
import type { FeedbackDetail, FeedbackItem } from "../types/api";
|
||||
import AdminPagination from "./AdminPagination.vue";
|
||||
import { formatDateTime } from "../utils/dateTime";
|
||||
|
||||
defineProps<{ canDelete: boolean; canDetail: boolean; canExport: boolean }>();
|
||||
|
||||
const loading = ref(false);
|
||||
const exporting = ref(false);
|
||||
const items = ref<FeedbackItem[]>([]);
|
||||
const total = ref(0);
|
||||
const page = ref(1);
|
||||
const pageSize = ref(20);
|
||||
const readStatus = ref("all");
|
||||
const dateFrom = ref("");
|
||||
const dateTo = ref("");
|
||||
const detail = ref<FeedbackDetail | null>(null);
|
||||
const detailLoading = ref(false);
|
||||
const detailBodyRef = ref<HTMLElement | null>(null);
|
||||
const detailPager = reactive({ page: 1, pageSize: 20, total: 0 });
|
||||
|
||||
async function load() {
|
||||
loading.value = true;
|
||||
try {
|
||||
const result = await api.feedbackList({
|
||||
readStatus: readStatus.value,
|
||||
startDate: dateFrom.value || undefined,
|
||||
endDate: dateTo.value || undefined,
|
||||
page: page.value,
|
||||
pageSize: pageSize.value,
|
||||
});
|
||||
items.value = result.items;
|
||||
total.value = result.total;
|
||||
} catch (error) {
|
||||
ElMessage.error(error instanceof Error ? error.message : "反馈列表加载失败");
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function applyFilters() {
|
||||
page.value = 1;
|
||||
void load();
|
||||
}
|
||||
|
||||
function resetFilters() {
|
||||
readStatus.value = "all";
|
||||
dateFrom.value = "";
|
||||
dateTo.value = "";
|
||||
applyFilters();
|
||||
}
|
||||
|
||||
function changePage(nextPage: number, nextPageSize: number) {
|
||||
page.value = nextPage;
|
||||
pageSize.value = nextPageSize;
|
||||
void load();
|
||||
}
|
||||
|
||||
async function exportExcel() {
|
||||
if (!dateFrom.value || !dateTo.value) {
|
||||
ElMessage.warning("请先选择导出的时间范围");
|
||||
return;
|
||||
}
|
||||
exporting.value = true;
|
||||
try {
|
||||
await api.exportFeedback({
|
||||
readStatus: readStatus.value,
|
||||
startDate: dateFrom.value,
|
||||
endDate: dateTo.value,
|
||||
});
|
||||
ElMessage.success("反馈 Excel 已导出");
|
||||
} catch (error) {
|
||||
ElMessage.error(error instanceof Error ? error.message : "反馈导出失败");
|
||||
} finally {
|
||||
exporting.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function open(item: FeedbackItem) {
|
||||
detail.value = null;
|
||||
Object.assign(detailPager, { page: 1, total: 0 });
|
||||
await loadDetail(item.id);
|
||||
if (detail.value) item.isRead = true;
|
||||
}
|
||||
|
||||
async function loadDetail(feedbackId: number, messagePage?: number, messagePageSize = detailPager.pageSize) {
|
||||
detailLoading.value = true;
|
||||
try {
|
||||
detail.value = await api.feedbackDetail(feedbackId, {
|
||||
messagePage,
|
||||
messagePageSize,
|
||||
});
|
||||
Object.assign(detailPager, {
|
||||
page: detail.value.messagesPage.page,
|
||||
pageSize: detail.value.messagesPage.pageSize,
|
||||
total: detail.value.messagesPage.total,
|
||||
});
|
||||
await scrollToTarget();
|
||||
} catch (error) {
|
||||
ElMessage.error(error instanceof Error ? error.message : "反馈详情加载失败");
|
||||
} finally {
|
||||
detailLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function changeDetailPage(nextPage: number, nextPageSize: number) {
|
||||
if (!detail.value) return;
|
||||
await loadDetail(detail.value.id, nextPage, nextPageSize);
|
||||
if (!detail.value.messages.some((message) => message.isTarget)) {
|
||||
await nextTick();
|
||||
detailBodyRef.value?.querySelector("article")?.scrollIntoView({ behavior: "smooth", block: "start" });
|
||||
}
|
||||
}
|
||||
|
||||
async function scrollToTarget() {
|
||||
await nextTick();
|
||||
const target = detailBodyRef.value?.querySelector<HTMLElement>("[data-feedback-target='true']");
|
||||
target?.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||
}
|
||||
|
||||
function closeDetail() {
|
||||
detail.value = null;
|
||||
}
|
||||
|
||||
async function remove(item: FeedbackItem) {
|
||||
await ElMessageBox.confirm("确认删除这条用户反馈?删除后不可恢复。", "删除反馈", { type: "warning" });
|
||||
await api.deleteFeedback(item.id);
|
||||
if (detail.value?.id === item.id) detail.value = null;
|
||||
ElMessage.success("反馈已删除");
|
||||
await load();
|
||||
}
|
||||
|
||||
onMounted(load);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="feedback-page">
|
||||
<header>
|
||||
<div><h2>反馈管理</h2><p>查看用户针对 AI 回答提交的问题与当时对话。</p></div>
|
||||
</header>
|
||||
<section class="record-filter feedback-toolbar" aria-label="反馈筛选与导出">
|
||||
<el-select v-model="readStatus" aria-label="反馈状态" @change="applyFilters">
|
||||
<el-option label="全部反馈" value="all" />
|
||||
<el-option label="未读反馈" value="unread" />
|
||||
<el-option label="已读反馈" value="read" />
|
||||
</el-select>
|
||||
<div class="record-date-range" aria-label="反馈时间范围">
|
||||
<label class="record-date-field"><span>开始日期</span><input v-model="dateFrom" type="date" aria-label="反馈开始日期" /></label>
|
||||
<span class="record-date-sep">至</span>
|
||||
<label class="record-date-field"><span>结束日期</span><input v-model="dateTo" type="date" aria-label="反馈结束日期" /></label>
|
||||
</div>
|
||||
<div class="record-filter-actions">
|
||||
<el-button type="primary" @click="applyFilters">查询</el-button>
|
||||
<el-button @click="resetFilters">重置</el-button>
|
||||
<el-button v-if="canExport" :loading="exporting" @click="exportExcel">导出 Excel</el-button>
|
||||
</div>
|
||||
</section>
|
||||
<el-table v-loading="loading" :data="items" stripe>
|
||||
<el-table-column label="状态" width="88"><template #default="{ row }"><el-tag :type="row.isRead ? 'info' : 'danger'">{{ row.isRead ? '已读' : '未读' }}</el-tag></template></el-table-column>
|
||||
<el-table-column prop="userName" label="用户" width="130" />
|
||||
<el-table-column prop="content" label="反馈内容" min-width="240" show-overflow-tooltip />
|
||||
<el-table-column prop="messageContent" label="反馈的 AI 回答" min-width="280" show-overflow-tooltip />
|
||||
<el-table-column label="提交时间" width="180"><template #default="{ row }">{{ formatDateTime(row.createdAt) }}</template></el-table-column>
|
||||
<el-table-column label="操作" width="150" fixed="right"><template #default="{ row }"><el-button v-if="canDetail" link type="primary" @click="open(row)">查看</el-button><el-button v-if="canDelete" link type="danger" @click="remove(row)">删除</el-button></template></el-table-column>
|
||||
</el-table>
|
||||
<AdminPagination v-if="total" :page="page" :page-size="pageSize" :total="total" @change="changePage" />
|
||||
<el-drawer :model-value="Boolean(detail) || detailLoading" title="反馈详情" size="680px" @close="closeDetail">
|
||||
<template v-if="detail">
|
||||
<div ref="detailBodyRef" v-loading="detailLoading" class="feedback-detail-body">
|
||||
<section class="feedback-summary"><span>用户反馈</span><strong>{{ detail.content }}</strong><small>{{ detail.userName }} · {{ formatDateTime(detail.createdAt) }}</small></section>
|
||||
<h3>当时对话记录</h3>
|
||||
<AdminPagination class="feedback-detail-pagination" :page="detailPager.page" :page-size="detailPager.pageSize" :total="detailPager.total" @change="changeDetailPage" />
|
||||
<div class="feedback-history"><article v-for="message in detail.messages" :key="message.id" :class="[message.role, { target: message.isTarget }]" :data-feedback-target="message.isTarget ? 'true' : undefined">
|
||||
<div><b>{{ message.role === 'user' ? '用户' : 'AI' }}</b><el-tag v-if="message.isTarget" type="warning">用户反馈的这条回答</el-tag><time>{{ formatDateTime(message.createdAt) }}</time></div><p>{{ message.content }}</p>
|
||||
</article></div>
|
||||
<AdminPagination class="feedback-detail-pagination" :page="detailPager.page" :page-size="detailPager.pageSize" :total="detailPager.total" @change="changeDetailPage" />
|
||||
</div>
|
||||
</template>
|
||||
<div v-else v-loading="detailLoading" class="feedback-detail-loading"></div>
|
||||
</el-drawer>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.feedback-page header { display:flex; justify-content:space-between; align-items:flex-start; gap:24px; margin-bottom:18px; }
|
||||
.feedback-page h2 { margin:0 0 6px; }.feedback-page header p { margin:0; color:#7b8494; }
|
||||
.feedback-toolbar { align-items:center; margin-bottom:18px; padding:14px 16px; border:1px solid #e7ebf0; border-radius:12px; background:#fafbfc; }
|
||||
.feedback-toolbar > :deep(.el-select) { width:140px; }
|
||||
.feedback-summary { display:grid; gap:9px; padding:16px; border-radius:12px; background:#f7f9fc; }.feedback-summary span,.feedback-summary small { color:#87909f; }
|
||||
.feedback-detail-body { min-height:260px; }
|
||||
.feedback-detail-loading { min-height:260px; }
|
||||
.feedback-detail-pagination { margin:12px 0; }
|
||||
.feedback-history { display:grid; gap:12px; }.feedback-history article { padding:14px 16px; border:1px solid #e6eaf0; border-radius:12px; }.feedback-history article.user { margin-left:42px; background:#f5f8ff; }.feedback-history article.assistant { margin-right:42px; }.feedback-history article.target { border:2px solid #f0a020; background:#fffaf0; box-shadow:0 0 0 4px rgb(240 160 32 / 10%); animation:feedback-target-pulse 1.2s ease-out; }.feedback-history article div { display:flex; align-items:center; gap:10px; }.feedback-history article time { margin-left:auto; color:#87909f; font-size:12px; }.feedback-history p { white-space:pre-wrap; line-height:1.65; margin:8px 0 0; }
|
||||
@keyframes feedback-target-pulse { 0% { box-shadow:0 0 0 0 rgb(240 160 32 / 38%); } 65% { box-shadow:0 0 0 12px rgb(240 160 32 / 8%); } 100% { box-shadow:0 0 0 4px rgb(240 160 32 / 10%); } }
|
||||
@media (max-width: 900px) { .feedback-toolbar { align-items:stretch; }.feedback-toolbar .record-date-range { width:100%; }.feedback-toolbar .record-date-field { flex:1; width:auto; }.feedback-toolbar .record-filter-actions { width:100%; } }
|
||||
</style>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -0,0 +1,579 @@
|
||||
<script setup lang="ts">
|
||||
import { ElMessage } from "element-plus";
|
||||
import { onMounted, reactive, ref, watch } from "vue";
|
||||
import { api } from "../services/api";
|
||||
import type {
|
||||
AiLogRecord,
|
||||
ChatRecord,
|
||||
QuestionInsightSummary,
|
||||
SsoClientItem,
|
||||
} from "../types/api";
|
||||
import AdminPagination from "./AdminPagination.vue";
|
||||
import AiLogDetailDrawer from "./AiLogDetailDrawer.vue";
|
||||
import ChatDetailDrawer from "./ChatDetailDrawer.vue";
|
||||
import { formatDateTime, toUtcIsoFromBusinessDateTime } from "../utils/dateTime";
|
||||
|
||||
defineProps<{ canExport: boolean }>();
|
||||
|
||||
type RecordTab = "chats" | "questionInsights" | "aiLogs" | "operationLogs";
|
||||
const loading = ref(false);
|
||||
const insightExporting = ref(false);
|
||||
const activeTab = ref<RecordTab>("chats");
|
||||
const chats = ref<ChatRecord[]>([]);
|
||||
const ssoClients = ref<SsoClientItem[]>([]);
|
||||
const aiLogs = ref<AiLogRecord[]>([]);
|
||||
const operationLogs = ref<Record<string, unknown>[]>([]);
|
||||
const insights = ref<QuestionInsightSummary | null>(null);
|
||||
const detailSessionId = ref<number | null>(null);
|
||||
const chatDetailOpen = ref(false);
|
||||
const aiLogId = ref<number | null>(null);
|
||||
const aiLogDetailOpen = ref(false);
|
||||
const pagers = reactive({
|
||||
chats: { page: 1, pageSize: 20, total: 0 },
|
||||
aiLogs: { page: 1, pageSize: 20, total: 0 },
|
||||
operationLogs: { page: 1, pageSize: 20, total: 0 },
|
||||
questionInsights: { page: 1, pageSize: 20, total: 0 },
|
||||
});
|
||||
const chatFilters = reactive({
|
||||
keyword: "",
|
||||
userId: undefined as number | undefined,
|
||||
status: "",
|
||||
sourceType: "",
|
||||
sourceClientId: undefined as number | undefined,
|
||||
dateFrom: "",
|
||||
dateTo: "",
|
||||
});
|
||||
const insightFilters = reactive({
|
||||
dateFrom: "",
|
||||
dateTo: "",
|
||||
minCount: 2,
|
||||
maxMessages: 5000,
|
||||
});
|
||||
|
||||
onMounted(async () => {
|
||||
await Promise.all([loadTab("chats"), loadSsoClients()]);
|
||||
});
|
||||
watch(activeTab, (tab) => loadTab(tab));
|
||||
|
||||
async function loadTab(tab: RecordTab = activeTab.value) {
|
||||
loading.value = true;
|
||||
try {
|
||||
if (tab === "chats") {
|
||||
const result = await api.chats({
|
||||
...buildChatQuery(),
|
||||
page: pagers.chats.page,
|
||||
pageSize: pagers.chats.pageSize,
|
||||
});
|
||||
chats.value = result.items;
|
||||
Object.assign(pagers.chats, {
|
||||
page: result.page,
|
||||
pageSize: result.pageSize,
|
||||
total: result.total,
|
||||
});
|
||||
} else if (tab === "aiLogs") {
|
||||
const result = await api.aiLogs({
|
||||
page: pagers.aiLogs.page,
|
||||
pageSize: pagers.aiLogs.pageSize,
|
||||
});
|
||||
aiLogs.value = result.items;
|
||||
Object.assign(pagers.aiLogs, {
|
||||
page: result.page,
|
||||
pageSize: result.pageSize,
|
||||
total: result.total,
|
||||
});
|
||||
} else if (tab === "operationLogs") {
|
||||
const result = await api.operationLogs({
|
||||
page: pagers.operationLogs.page,
|
||||
pageSize: pagers.operationLogs.pageSize,
|
||||
});
|
||||
operationLogs.value = result.items;
|
||||
Object.assign(pagers.operationLogs, {
|
||||
page: result.page,
|
||||
pageSize: result.pageSize,
|
||||
total: result.total,
|
||||
});
|
||||
} else
|
||||
await loadInsights(
|
||||
pagers.questionInsights.page,
|
||||
pagers.questionInsights.pageSize,
|
||||
);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
async function loadSsoClients() {
|
||||
try {
|
||||
ssoClients.value = await api.ssoClients();
|
||||
} catch {
|
||||
ssoClients.value = [];
|
||||
}
|
||||
}
|
||||
|
||||
async function changePage(tab: RecordTab, page: number, pageSize: number) {
|
||||
Object.assign(pagers[tab], { page, pageSize });
|
||||
await loadTab(tab);
|
||||
}
|
||||
async function loadInsights(
|
||||
page = 1,
|
||||
pageSize = pagers.questionInsights.pageSize,
|
||||
) {
|
||||
insights.value = await api.questionInsights({
|
||||
dateFrom: toUtcIsoFromBusinessDateTime(insightFilters.dateFrom),
|
||||
dateTo: toUtcIsoFromBusinessDateTime(insightFilters.dateTo),
|
||||
minCount: insightFilters.minCount,
|
||||
maxMessages: insightFilters.maxMessages,
|
||||
page,
|
||||
pageSize,
|
||||
});
|
||||
Object.assign(pagers.questionInsights, {
|
||||
page: insights.value.page,
|
||||
pageSize: insights.value.pageSize,
|
||||
total: insights.value.total,
|
||||
});
|
||||
}
|
||||
async function searchChats() {
|
||||
pagers.chats.page = 1;
|
||||
await loadTab("chats");
|
||||
}
|
||||
async function resetChatFilters() {
|
||||
Object.assign(chatFilters, {
|
||||
keyword: "",
|
||||
userId: undefined,
|
||||
status: "",
|
||||
sourceType: "",
|
||||
sourceClientId: undefined,
|
||||
dateFrom: "",
|
||||
dateTo: "",
|
||||
});
|
||||
await searchChats();
|
||||
}
|
||||
async function refreshInsights() {
|
||||
pagers.questionInsights.page = 1;
|
||||
loading.value = true;
|
||||
try {
|
||||
const result = await api.refreshQuestionInsights({
|
||||
dateFrom: toUtcIsoFromBusinessDateTime(insightFilters.dateFrom),
|
||||
dateTo: toUtcIsoFromBusinessDateTime(insightFilters.dateTo),
|
||||
maxMessages: insightFilters.maxMessages,
|
||||
});
|
||||
await loadInsights(1);
|
||||
ElMessage.success(
|
||||
result.processedMessages
|
||||
? `新增清洗 ${result.processedMessages} 条消息,得到 ${result.acceptedQuestions} 个有效问题${result.hasMore ? ";仍有历史消息待清洗" : ""}`
|
||||
: "没有新增消息,已直接使用持久化清洗结果统计",
|
||||
);
|
||||
} catch (error) {
|
||||
ElMessage.error(
|
||||
error instanceof Error ? error.message : "问题洞察刷新失败",
|
||||
);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
async function resetInsightFilters() {
|
||||
Object.assign(insightFilters, {
|
||||
dateFrom: "",
|
||||
dateTo: "",
|
||||
minCount: 2,
|
||||
maxMessages: 5000,
|
||||
});
|
||||
await refreshInsights();
|
||||
}
|
||||
async function exportInsights() {
|
||||
const dateFrom = toUtcIsoFromBusinessDateTime(insightFilters.dateFrom);
|
||||
const dateTo = toUtcIsoFromBusinessDateTime(insightFilters.dateTo);
|
||||
if (dateFrom && dateTo && dateFrom > dateTo) {
|
||||
return ElMessage.warning("开始时间不能晚于结束时间");
|
||||
}
|
||||
insightExporting.value = true;
|
||||
try {
|
||||
const fromLabel = insightFilters.dateFrom.slice(0, 10).replaceAll("-", "") || "全部";
|
||||
const toLabel = insightFilters.dateTo.slice(0, 10).replaceAll("-", "") || "全部";
|
||||
await api.exportQuestionInsights(
|
||||
{
|
||||
dateFrom,
|
||||
dateTo,
|
||||
minCount: insightFilters.minCount,
|
||||
maxMessages: insightFilters.maxMessages,
|
||||
},
|
||||
`问题洞察_${fromLabel}_${toLabel}.xlsx`,
|
||||
);
|
||||
ElMessage.success("问题洞察已导出");
|
||||
} catch (error) {
|
||||
ElMessage.error(error instanceof Error ? error.message : "问题洞察导出失败");
|
||||
} finally {
|
||||
insightExporting.value = false;
|
||||
}
|
||||
}
|
||||
function openChat(sessionId: number) {
|
||||
detailSessionId.value = sessionId;
|
||||
chatDetailOpen.value = true;
|
||||
}
|
||||
function openAiLog(row: AiLogRecord) {
|
||||
aiLogId.value = row.id;
|
||||
aiLogDetailOpen.value = true;
|
||||
}
|
||||
function buildChatQuery() {
|
||||
return {
|
||||
keyword: chatFilters.keyword,
|
||||
userId: chatFilters.userId,
|
||||
status: chatFilters.status,
|
||||
sourceType: chatFilters.sourceType,
|
||||
sourceClientId: chatFilters.sourceClientId,
|
||||
dateFrom: toUtcIsoFromBusinessDateTime(chatFilters.dateFrom),
|
||||
dateTo: toUtcIsoFromBusinessDateTime(chatFilters.dateTo),
|
||||
};
|
||||
}
|
||||
function formatMoney(value?: number | null, currency = "CNY") {
|
||||
return typeof value === "number"
|
||||
? `${currency || "CNY"} ${value.toFixed(6)}`
|
||||
: "-";
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="feature-page">
|
||||
<div class="page-head">
|
||||
<h2>记录审计</h2>
|
||||
<p>聊天、AI 请求和后台操作记录。</p>
|
||||
</div>
|
||||
<el-tabs v-model="activeTab">
|
||||
<el-tab-pane label="聊天记录" name="chats">
|
||||
<div class="record-filter">
|
||||
<el-input
|
||||
v-model="chatFilters.keyword"
|
||||
placeholder="搜索标题、手机号、姓名、聊天内容"
|
||||
clearable
|
||||
/><el-input-number
|
||||
v-model="chatFilters.userId"
|
||||
:min="1"
|
||||
placeholder="用户ID"
|
||||
/><el-select
|
||||
v-model="chatFilters.status"
|
||||
placeholder="消息状态"
|
||||
clearable
|
||||
><el-option label="全部状态" value="" /><el-option
|
||||
label="已完成"
|
||||
value="FINISHED" /><el-option
|
||||
label="生成中"
|
||||
value="GENERATING" /><el-option
|
||||
label="已停止"
|
||||
value="STOPPED" /><el-option label="失败" value="FAILED"
|
||||
/></el-select>
|
||||
<el-select v-model="chatFilters.sourceType" placeholder="会话来源" clearable @change="chatFilters.sourceClientId = undefined">
|
||||
<el-option label="直接访问" value="direct" />
|
||||
<el-option label="第三方应用" value="sso" />
|
||||
</el-select>
|
||||
<el-select v-if="chatFilters.sourceType === 'sso'" v-model="chatFilters.sourceClientId" placeholder="来源应用" clearable>
|
||||
<el-option v-for="client in ssoClients" :key="client.id" :label="client.name" :value="client.id" />
|
||||
</el-select>
|
||||
<div class="record-date-range" aria-label="时间范围">
|
||||
<label class="record-date-field"
|
||||
><span>开始时间</span
|
||||
><input
|
||||
v-model="chatFilters.dateFrom"
|
||||
type="datetime-local"
|
||||
aria-label="开始时间" /></label
|
||||
><span class="record-date-sep">至</span
|
||||
><label class="record-date-field"
|
||||
><span>结束时间</span
|
||||
><input
|
||||
v-model="chatFilters.dateTo"
|
||||
type="datetime-local"
|
||||
aria-label="结束时间"
|
||||
/></label>
|
||||
</div>
|
||||
<div class="record-filter-actions">
|
||||
<el-button type="primary" @click="searchChats">搜索</el-button
|
||||
><el-button @click="resetChatFilters">重置</el-button
|
||||
><el-button v-if="canExport" @click="api.exportChats(buildChatQuery())"
|
||||
>导出</el-button
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<el-table v-loading="loading" :data="chats" stripe
|
||||
><el-table-column
|
||||
prop="id"
|
||||
label="会话ID"
|
||||
width="90"
|
||||
/><el-table-column
|
||||
prop="userPhone"
|
||||
label="手机号"
|
||||
width="140"
|
||||
/><el-table-column
|
||||
prop="userName"
|
||||
label="用户"
|
||||
width="120"
|
||||
/><el-table-column
|
||||
prop="sourceName"
|
||||
label="会话来源"
|
||||
min-width="160"
|
||||
show-overflow-tooltip
|
||||
/><el-table-column
|
||||
prop="title"
|
||||
label="会话标题"
|
||||
min-width="220"
|
||||
show-overflow-tooltip
|
||||
/><el-table-column
|
||||
prop="messageCount"
|
||||
label="消息数"
|
||||
width="90"
|
||||
/><el-table-column label="最后消息" width="180"
|
||||
><template #default="{ row }">{{ formatDateTime(row.lastMessageAt) }}</template
|
||||
></el-table-column><el-table-column label="操作" width="100" fixed="right"
|
||||
><template #default="{ row }"
|
||||
><el-button size="small" @click="openChat(row.id)"
|
||||
>详情</el-button
|
||||
></template
|
||||
></el-table-column
|
||||
></el-table
|
||||
>
|
||||
<AdminPagination
|
||||
:page="pagers.chats.page"
|
||||
:page-size="pagers.chats.pageSize"
|
||||
:total="pagers.chats.total"
|
||||
@change="(page, size) => changePage('chats', page, size)"
|
||||
/>
|
||||
</el-tab-pane>
|
||||
<el-tab-pane label="问题洞察" name="questionInsights">
|
||||
<div class="record-filter question-insight-filter">
|
||||
<div class="record-date-range" aria-label="问题时间范围">
|
||||
<label class="record-date-field"
|
||||
><span>开始时间</span
|
||||
><input
|
||||
v-model="insightFilters.dateFrom"
|
||||
type="datetime-local"
|
||||
aria-label="问题开始时间" /></label
|
||||
><span class="record-date-sep">至</span
|
||||
><label class="record-date-field"
|
||||
><span>结束时间</span
|
||||
><input
|
||||
v-model="insightFilters.dateTo"
|
||||
type="datetime-local"
|
||||
aria-label="问题结束时间"
|
||||
/></label>
|
||||
</div>
|
||||
<label class="insight-number-field"
|
||||
><span>最低频次</span
|
||||
><el-input-number
|
||||
v-model="insightFilters.minCount"
|
||||
:min="1"
|
||||
:max="50"
|
||||
controls-position="right" /></label
|
||||
><label class="insight-number-field"
|
||||
><span>单次清洗上限</span
|
||||
><el-input-number
|
||||
v-model="insightFilters.maxMessages"
|
||||
:min="100"
|
||||
:max="20000"
|
||||
:step="500"
|
||||
controls-position="right"
|
||||
/></label>
|
||||
<div class="record-filter-actions">
|
||||
<el-button
|
||||
type="primary"
|
||||
:loading="loading"
|
||||
@click="refreshInsights"
|
||||
>刷新并统计</el-button
|
||||
><el-button @click="resetInsightFilters">重置</el-button>
|
||||
<el-button v-if="canExport" :loading="insightExporting" @click="exportInsights">导出洞察</el-button>
|
||||
</div>
|
||||
</div>
|
||||
<p class="question-insight-help">
|
||||
清洗结果会持久化保存;点击“刷新并统计”只处理尚未清洗的用户消息,再按当前时间范围聚合,翻页不会重新扫描聊天原文。
|
||||
</p>
|
||||
<section v-if="insights" class="question-insight-summary">
|
||||
<div>
|
||||
<span>纳入清洗消息</span
|
||||
><strong>{{ insights.summary.scannedMessages }}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>有效问题</span
|
||||
><strong>{{ insights.summary.cleanedQuestions }}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>过滤低价值</span
|
||||
><strong>{{ insights.summary.filteredMessages }}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>高频问题组</span
|
||||
><strong>{{ insights.summary.visibleClusterCount }}</strong>
|
||||
</div>
|
||||
</section>
|
||||
<section v-loading="loading" class="question-insight-list">
|
||||
<article
|
||||
v-for="cluster in insights?.items || []"
|
||||
:key="`${cluster.rank}-${cluster.normalized}`"
|
||||
class="question-insight-card"
|
||||
>
|
||||
<header>
|
||||
<div>
|
||||
<small>TOP {{ cluster.rank }}</small>
|
||||
<h3>{{ cluster.title }}</h3>
|
||||
</div>
|
||||
<div class="question-insight-metrics">
|
||||
<span>{{ cluster.count }} 次</span
|
||||
><span>{{ cluster.userCount }} 人</span
|
||||
><span>{{ cluster.sessionCount }} 个会话</span>
|
||||
</div>
|
||||
</header>
|
||||
<div class="question-insight-tags">
|
||||
<el-tag size="small" effect="plain">{{
|
||||
cluster.categoryLabel
|
||||
}}</el-tag
|
||||
><el-tag
|
||||
v-if="cluster.needsKnowledgeFollowUp"
|
||||
size="small"
|
||||
type="warning"
|
||||
effect="plain"
|
||||
>需补知识/查召回</el-tag
|
||||
><el-tag
|
||||
v-if="cluster.noHitCount"
|
||||
size="small"
|
||||
type="danger"
|
||||
effect="plain"
|
||||
>无命中 {{ cluster.noHitCount }}</el-tag
|
||||
><el-tag
|
||||
v-if="cluster.failedCount"
|
||||
size="small"
|
||||
type="danger"
|
||||
effect="dark"
|
||||
>失败 {{ cluster.failedCount }}</el-tag
|
||||
><el-tag
|
||||
v-for="term in cluster.topTerms"
|
||||
:key="term"
|
||||
size="small"
|
||||
type="success"
|
||||
effect="plain"
|
||||
>{{ term }}</el-tag
|
||||
>
|
||||
</div>
|
||||
<p class="question-insight-action">{{ cluster.suggestedAction }}</p>
|
||||
<el-collapse
|
||||
><el-collapse-item
|
||||
title="查看相似问法和原始样例"
|
||||
:name="cluster.normalized"
|
||||
><div class="question-variants">
|
||||
<span v-for="variant in cluster.variants" :key="variant.text"
|
||||
>{{ variant.text }} × {{ variant.count }}</span
|
||||
>
|
||||
</div>
|
||||
<div class="question-samples">
|
||||
<article
|
||||
v-for="sample in cluster.samples"
|
||||
:key="sample.messageId"
|
||||
>
|
||||
<div>
|
||||
<strong>{{
|
||||
sample.userName ||
|
||||
sample.userPhone ||
|
||||
`用户 #${sample.userId}`
|
||||
}}</strong
|
||||
><span>{{ formatDateTime(sample.createdAt) }}</span>
|
||||
</div>
|
||||
<p>清洗后:{{ sample.cleaned }}</p>
|
||||
<pre>{{ sample.raw }}</pre>
|
||||
<el-button
|
||||
link
|
||||
type="primary"
|
||||
@click="openChat(sample.sessionId)"
|
||||
>查看原会话</el-button
|
||||
>
|
||||
</article>
|
||||
</div></el-collapse-item
|
||||
></el-collapse
|
||||
>
|
||||
</article>
|
||||
<el-empty
|
||||
v-if="insights && insights.items.length === 0"
|
||||
description="当前条件下暂无达到频次的问题组"
|
||||
:image-size="72"
|
||||
/><el-empty
|
||||
v-else-if="!insights && !loading"
|
||||
description="选择时间范围后点击统计问题"
|
||||
:image-size="72"
|
||||
/>
|
||||
</section>
|
||||
<AdminPagination
|
||||
:page="pagers.questionInsights.page"
|
||||
:page-size="pagers.questionInsights.pageSize"
|
||||
:total="pagers.questionInsights.total"
|
||||
@change="(page, size) => changePage('questionInsights', page, size)"
|
||||
/>
|
||||
</el-tab-pane>
|
||||
<el-tab-pane label="AI 请求" name="aiLogs"
|
||||
><el-table v-loading="loading" :data="aiLogs" stripe
|
||||
><el-table-column
|
||||
prop="sessionId"
|
||||
label="会话ID"
|
||||
width="90"
|
||||
/><el-table-column
|
||||
prop="modelName"
|
||||
label="模型"
|
||||
width="160"
|
||||
/><el-table-column
|
||||
prop="knowledgeIds"
|
||||
label="知识库"
|
||||
width="120"
|
||||
/><el-table-column
|
||||
prop="retrieveCount"
|
||||
label="命中"
|
||||
width="80"
|
||||
/><el-table-column label="片段" width="80"
|
||||
><template #default="{ row }">{{
|
||||
row.retrievedChunks?.length || 0
|
||||
}}</template></el-table-column
|
||||
><el-table-column
|
||||
prop="totalToken"
|
||||
label="Token"
|
||||
width="90"
|
||||
/><el-table-column label="估算成本" width="130"
|
||||
><template #default="{ row }">{{
|
||||
formatMoney(row.estimatedCost, row.currency || "CNY")
|
||||
}}</template></el-table-column
|
||||
><el-table-column
|
||||
prop="costMs"
|
||||
label="耗时(ms)"
|
||||
width="100"
|
||||
/><el-table-column
|
||||
prop="status"
|
||||
label="状态"
|
||||
width="100"
|
||||
/><el-table-column
|
||||
prop="errorMessage"
|
||||
label="错误"
|
||||
min-width="220"
|
||||
show-overflow-tooltip
|
||||
/><el-table-column label="操作" width="100" fixed="right"
|
||||
><template #default="{ row }"
|
||||
><el-button size="small" @click="openAiLog(row)"
|
||||
>详情</el-button
|
||||
></template
|
||||
></el-table-column
|
||||
></el-table
|
||||
><AdminPagination
|
||||
:page="pagers.aiLogs.page"
|
||||
:page-size="pagers.aiLogs.pageSize"
|
||||
:total="pagers.aiLogs.total"
|
||||
@change="(page, size) => changePage('aiLogs', page, size)"
|
||||
/></el-tab-pane>
|
||||
<el-tab-pane label="操作日志" name="operationLogs"
|
||||
><el-table v-loading="loading" :data="operationLogs" stripe
|
||||
><el-table-column prop="module" label="模块" /><el-table-column
|
||||
prop="action"
|
||||
label="动作" /><el-table-column
|
||||
prop="result"
|
||||
label="结果" /><el-table-column label="时间" width="180"
|
||||
><template #default="{ row }">{{ formatDateTime(String(row.createdAt || "")) }}</template
|
||||
></el-table-column></el-table
|
||||
><AdminPagination
|
||||
:page="pagers.operationLogs.page"
|
||||
:page-size="pagers.operationLogs.pageSize"
|
||||
:total="pagers.operationLogs.total"
|
||||
@change="(page, size) => changePage('operationLogs', page, size)"
|
||||
/></el-tab-pane>
|
||||
</el-tabs>
|
||||
<ChatDetailDrawer v-model="chatDetailOpen" :session-id="detailSessionId" :can-export="canExport" />
|
||||
<AiLogDetailDrawer v-model="aiLogDetailOpen" :log-id="aiLogId" />
|
||||
</section>
|
||||
</template>
|
||||
@@ -0,0 +1,216 @@
|
||||
<script setup lang="ts">
|
||||
import { ElMessage, ElMessageBox } from "element-plus";
|
||||
import { computed, onMounted, reactive, ref } from "vue";
|
||||
import { api } from "../services/api";
|
||||
import type { RetrievalLogItem } from "../types/api";
|
||||
import AdminPagination from "./AdminPagination.vue";
|
||||
import { formatDateTime } from "../utils/dateTime";
|
||||
|
||||
const loading = ref(false);
|
||||
const logs = ref<RetrievalLogItem[]>([]);
|
||||
const pager = reactive({ page: 1, pageSize: 20, total: 0 });
|
||||
const cleanupBefore = ref("");
|
||||
const retentionDays = ref<number | null>(90);
|
||||
const retentionChoice = computed({
|
||||
get: () => retentionDays.value ?? -1,
|
||||
set: (value: number) => {
|
||||
retentionDays.value = value === -1 ? null : value;
|
||||
},
|
||||
});
|
||||
const retentionState = ref<{
|
||||
lastRunAt: string | null;
|
||||
lastResult: string | null;
|
||||
} | null>(null);
|
||||
const selected = ref<Record<string, any> | null>(null);
|
||||
const detailOpen = ref(false);
|
||||
|
||||
onMounted(async () => {
|
||||
loading.value = true;
|
||||
try {
|
||||
await Promise.all([loadLogs(), loadRetention()]);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
});
|
||||
async function loadLogs(page = 1, pageSize = pager.pageSize) {
|
||||
const result = await api.retrievalLogs({ page, pageSize });
|
||||
logs.value = result.items;
|
||||
Object.assign(pager, {
|
||||
page: result.page,
|
||||
pageSize: result.pageSize,
|
||||
total: result.total,
|
||||
});
|
||||
}
|
||||
async function loadRetention() {
|
||||
const result = await api.retrievalRetention();
|
||||
retentionDays.value = result.retentionDays;
|
||||
retentionState.value = result;
|
||||
}
|
||||
async function cleanup() {
|
||||
if (!cleanupBefore.value) return ElMessage.warning("请选择清理日期");
|
||||
const estimate = await api.estimateRetrievalCleanup(cleanupBefore.value);
|
||||
await ElMessageBox.confirm(
|
||||
`预计清理 ${estimate.estimatedCount} 条检索日志及关联明细,操作日志不会被删除。`,
|
||||
"确认清理",
|
||||
{ type: "warning" },
|
||||
);
|
||||
const result = await api.cleanupRetrievalLogs(cleanupBefore.value);
|
||||
ElMessage.success(`已清理 ${result.deleted} 条日志`);
|
||||
await loadLogs(1);
|
||||
}
|
||||
async function saveRetention() {
|
||||
await api.saveRetrievalRetention(retentionDays.value);
|
||||
ElMessage.success(
|
||||
retentionDays.value == null
|
||||
? "已设为永久保留"
|
||||
: `已设为保留 ${retentionDays.value} 天`,
|
||||
);
|
||||
await loadRetention();
|
||||
}
|
||||
async function openDetail(row: RetrievalLogItem) {
|
||||
selected.value = await api.retrievalLogDetail(row.id);
|
||||
detailOpen.value = true;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section v-loading="loading" class="feature-page">
|
||||
<div class="page-head inline">
|
||||
<div>
|
||||
<h2>检索日志</h2>
|
||||
<p>
|
||||
查看每条回答使用的知识目录、Agent 决策、搜索候选、重排和章节回读。
|
||||
</p>
|
||||
</div>
|
||||
<div class="cleanup-controls">
|
||||
<el-date-picker
|
||||
v-model="cleanupBefore"
|
||||
value-format="YYYY-MM-DD"
|
||||
type="date"
|
||||
placeholder="清理此日期前"
|
||||
/><el-button type="danger" plain @click="cleanup">清理日志</el-button>
|
||||
</div>
|
||||
</div>
|
||||
<section class="retention-panel">
|
||||
<div>
|
||||
<strong>定期清理</strong
|
||||
><span>每日自动分批删除过期检索日志和关联候选明细</span>
|
||||
</div>
|
||||
<el-select v-model="retentionChoice" placeholder="保留时间"
|
||||
><el-option label="永久保留" :value="-1" /><el-option
|
||||
label="30天"
|
||||
:value="30" /><el-option label="90天" :value="90" /><el-option
|
||||
label="180天"
|
||||
:value="180" /></el-select
|
||||
><el-button type="primary" @click="saveRetention">保存规则</el-button
|
||||
><small>最近执行:{{ formatDateTime(retentionState?.lastRunAt, "尚未执行") }}</small>
|
||||
</section>
|
||||
<el-table :data="logs" stripe
|
||||
><el-table-column prop="id" label="ID" width="80" /><el-table-column
|
||||
prop="sessionId"
|
||||
label="会话"
|
||||
width="90"
|
||||
/><el-table-column
|
||||
prop="question"
|
||||
label="用户问题"
|
||||
min-width="280"
|
||||
show-overflow-tooltip
|
||||
/><el-table-column label="调用知识" width="100"
|
||||
><template #default="{ row }"
|
||||
><el-tag :type="row.knowledgeCalled ? 'success' : 'info'">{{
|
||||
row.knowledgeCalled ? "已调用" : "未调用"
|
||||
}}</el-tag></template
|
||||
></el-table-column
|
||||
><el-table-column
|
||||
prop="selectedKnowledgeIds"
|
||||
label="知识库"
|
||||
width="120"
|
||||
/><el-table-column
|
||||
prop="status"
|
||||
label="状态"
|
||||
width="100"
|
||||
/><el-table-column
|
||||
prop="toolCostMs"
|
||||
label="工具耗时"
|
||||
width="110"
|
||||
/><el-table-column
|
||||
prop="totalCostMs"
|
||||
label="总耗时"
|
||||
width="100"
|
||||
/><el-table-column label="时间" width="180"
|
||||
><template #default="{ row }">{{ formatDateTime(row.createdAt) }}</template
|
||||
></el-table-column><el-table-column label="操作" width="90" fixed="right"
|
||||
><template #default="{ row }"
|
||||
><el-button link type="primary" @click="openDetail(row)"
|
||||
>详情</el-button
|
||||
></template
|
||||
></el-table-column
|
||||
></el-table
|
||||
>
|
||||
<AdminPagination
|
||||
:page="pager.page"
|
||||
:page-size="pager.pageSize"
|
||||
:total="pager.total"
|
||||
@change="loadLogs"
|
||||
/>
|
||||
<el-drawer v-model="detailOpen" size="820px" title="检索日志详情"
|
||||
><template v-if="selected"
|
||||
><dl class="knowledge-meta">
|
||||
<dt>问题</dt>
|
||||
<dd>{{ selected.question }}</dd>
|
||||
<dt>安全规则</dt>
|
||||
<dd>{{ selected.safetyRuleVersion }}</dd>
|
||||
<dt>目录哈希</dt>
|
||||
<dd>{{ selected.catalogHash }}</dd>
|
||||
<dt>最终章节</dt>
|
||||
<dd>{{ selected.finalSectionIds || "未读取知识章节" }}</dd>
|
||||
</dl>
|
||||
<h3>本轮 KnowledgeList</h3>
|
||||
<pre class="trace-json">{{
|
||||
JSON.stringify(selected.catalogSnapshot, null, 2)
|
||||
}}</pre>
|
||||
<h3>Agent 工具轨迹</h3>
|
||||
<pre class="trace-json">{{
|
||||
JSON.stringify(selected.toolTrace, null, 2)
|
||||
}}</pre>
|
||||
<h3>候选切片与取舍</h3>
|
||||
<el-table :data="selected.candidates"
|
||||
><el-table-column
|
||||
prop="knowledgeId"
|
||||
label="知识库"
|
||||
width="90" /><el-table-column
|
||||
prop="knowledgeName"
|
||||
label="名称"
|
||||
min-width="150"
|
||||
show-overflow-tooltip /><el-table-column
|
||||
prop="knowledgeTypeLabel"
|
||||
label="类型优先级"
|
||||
width="170" /><el-table-column
|
||||
prop="versionId"
|
||||
label="版本"
|
||||
width="80" /><el-table-column
|
||||
prop="sectionId"
|
||||
label="章节"
|
||||
width="80" /><el-table-column
|
||||
prop="lexicalScore"
|
||||
label="词法分"
|
||||
width="100" /><el-table-column
|
||||
prop="rerankScore"
|
||||
label="重排分"
|
||||
width="100" /><el-table-column label="采用" width="80"
|
||||
><template #default="{ row }">{{
|
||||
row.selected ? "是" : "否"
|
||||
}}</template></el-table-column
|
||||
><el-table-column
|
||||
prop="discardReason"
|
||||
label="舍弃原因"
|
||||
min-width="180"
|
||||
/></el-table>
|
||||
<h3>最终回答</h3>
|
||||
<pre class="trace-json">{{
|
||||
selected.finalAnswer || "尚未生成回答"
|
||||
}}</pre>
|
||||
</template></el-drawer
|
||||
>
|
||||
</section>
|
||||
</template>
|
||||
@@ -0,0 +1,854 @@
|
||||
<script setup lang="ts">
|
||||
import { ElMessage, ElMessageBox } from "element-plus";
|
||||
import { computed, onMounted, reactive, ref } from "vue";
|
||||
|
||||
import { api } from "../services/api";
|
||||
import type { EntitlementPlan, SsoAuditItem, SsoClientItem, SsoIdentityItem } from "../types/api";
|
||||
import AdminPagination from "./AdminPagination.vue";
|
||||
import { formatDateTime } from "../utils/dateTime";
|
||||
|
||||
const loading = ref(false);
|
||||
const savingUrl = ref(false);
|
||||
const clients = ref<SsoClientItem[]>([]);
|
||||
const entitlementPlans = ref<EntitlementPlan[]>([]);
|
||||
const userClientUrl = ref("");
|
||||
const activeTab = ref("applications");
|
||||
const clientDialogOpen = ref(false);
|
||||
const editingClientId = ref<number | null>(null);
|
||||
const secretDialogOpen = ref(false);
|
||||
const revealedSecret = ref("");
|
||||
const revealedAppId = ref("");
|
||||
|
||||
const identityItems = ref<SsoIdentityItem[]>([]);
|
||||
const identityTotal = ref(0);
|
||||
const identityPage = ref(1);
|
||||
const identityPageSize = ref(10);
|
||||
const identityKeyword = ref("");
|
||||
const identityClientId = ref<number | undefined>();
|
||||
|
||||
const auditItems = ref<SsoAuditItem[]>([]);
|
||||
const auditTotal = ref(0);
|
||||
const auditPage = ref(1);
|
||||
const auditPageSize = ref(10);
|
||||
const auditClientId = ref<number | undefined>();
|
||||
const auditStatus = ref("");
|
||||
|
||||
const clientForm = reactive({
|
||||
appId: "",
|
||||
name: "",
|
||||
redirectUrisText: "",
|
||||
allowAutoRegister: false,
|
||||
defaultEntitlementPlanId: undefined as number | undefined,
|
||||
status: 1,
|
||||
});
|
||||
|
||||
const dialogTitle = computed(() => editingClientId.value ? "编辑接入应用" : "新增接入应用");
|
||||
|
||||
onMounted(loadOverview);
|
||||
|
||||
async function loadOverview() {
|
||||
loading.value = true;
|
||||
try {
|
||||
const [config, clientList, plans] = await Promise.all([
|
||||
api.ssoConfig(),
|
||||
api.ssoClients(),
|
||||
api.entitlementPlans(false),
|
||||
]);
|
||||
userClientUrl.value = config.userClientUrl;
|
||||
clients.value = clientList;
|
||||
entitlementPlans.value = plans;
|
||||
} catch (error) {
|
||||
showError(error, "应用接入配置加载失败");
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function savePublicUrl() {
|
||||
savingUrl.value = true;
|
||||
try {
|
||||
const result = await api.saveSsoConfig(userClientUrl.value.trim());
|
||||
userClientUrl.value = result.userClientUrl;
|
||||
ElMessage.success("用户端公网地址已保存");
|
||||
} catch (error) {
|
||||
showError(error, "用户端公网地址保存失败");
|
||||
} finally {
|
||||
savingUrl.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function openCreateDialog() {
|
||||
editingClientId.value = null;
|
||||
Object.assign(clientForm, {
|
||||
appId: "",
|
||||
name: "",
|
||||
redirectUrisText: "",
|
||||
allowAutoRegister: false,
|
||||
defaultEntitlementPlanId: undefined,
|
||||
status: 1,
|
||||
});
|
||||
clientDialogOpen.value = true;
|
||||
}
|
||||
|
||||
function openEditDialog(client: SsoClientItem) {
|
||||
editingClientId.value = client.id;
|
||||
Object.assign(clientForm, {
|
||||
appId: client.appId,
|
||||
name: client.name,
|
||||
redirectUrisText: client.redirectUris.join("\n"),
|
||||
allowAutoRegister: client.allowAutoRegister,
|
||||
defaultEntitlementPlanId: client.defaultEntitlementPlanId ?? undefined,
|
||||
status: client.status,
|
||||
});
|
||||
clientDialogOpen.value = true;
|
||||
}
|
||||
|
||||
async function saveClient() {
|
||||
const appId = clientForm.appId.trim();
|
||||
const name = clientForm.name.trim();
|
||||
if (!appId || !name) {
|
||||
ElMessage.warning("请填写应用ID和应用名称");
|
||||
return;
|
||||
}
|
||||
if (clientForm.allowAutoRegister && !clientForm.defaultEntitlementPlanId) {
|
||||
ElMessage.warning("允许自动注册时请选择默认权益版本");
|
||||
return;
|
||||
}
|
||||
const redirectUris = clientForm.redirectUrisText
|
||||
.split(/\r?\n/)
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean);
|
||||
try {
|
||||
if (editingClientId.value) {
|
||||
await api.updateSsoClient(editingClientId.value, {
|
||||
name,
|
||||
redirectUris,
|
||||
allowAutoRegister: clientForm.allowAutoRegister,
|
||||
defaultEntitlementPlanId: clientForm.defaultEntitlementPlanId ?? null,
|
||||
status: clientForm.status,
|
||||
});
|
||||
ElMessage.success("接入应用已更新");
|
||||
} else {
|
||||
const result = await api.createSsoClient({
|
||||
appId,
|
||||
name,
|
||||
redirectUris,
|
||||
allowAutoRegister: clientForm.allowAutoRegister,
|
||||
defaultEntitlementPlanId: clientForm.defaultEntitlementPlanId ?? null,
|
||||
status: clientForm.status,
|
||||
});
|
||||
revealedSecret.value = result.clientSecret || "";
|
||||
revealedAppId.value = result.appId;
|
||||
secretDialogOpen.value = true;
|
||||
}
|
||||
clientDialogOpen.value = false;
|
||||
await loadOverview();
|
||||
} catch (error) {
|
||||
showError(error, "接入应用保存失败");
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleClient(client: SsoClientItem) {
|
||||
const targetStatus = client.status;
|
||||
try {
|
||||
await api.updateSsoClient(client.id, {
|
||||
name: client.name,
|
||||
redirectUris: client.redirectUris,
|
||||
allowAutoRegister: client.allowAutoRegister,
|
||||
defaultEntitlementPlanId: client.defaultEntitlementPlanId ?? null,
|
||||
status: targetStatus,
|
||||
});
|
||||
ElMessage.success(targetStatus === 1 ? "应用已启用" : "应用已停用");
|
||||
} catch (error) {
|
||||
client.status = targetStatus === 1 ? 0 : 1;
|
||||
showError(error, "应用状态更新失败");
|
||||
}
|
||||
}
|
||||
|
||||
async function rotateSecret(client: SsoClientItem) {
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
`更新后,${client.name} 使用的旧密钥会立即失效。确定继续吗?`,
|
||||
"更新应用密钥",
|
||||
{ type: "warning", confirmButtonText: "确认更新", cancelButtonText: "取消" },
|
||||
);
|
||||
const result = await api.rotateSsoClientSecret(client.id);
|
||||
revealedSecret.value = result.clientSecret;
|
||||
revealedAppId.value = client.appId;
|
||||
secretDialogOpen.value = true;
|
||||
} catch (error) {
|
||||
if (error === "cancel" || error === "close") return;
|
||||
showError(error, "应用密钥更新失败");
|
||||
}
|
||||
}
|
||||
|
||||
async function copySecret() {
|
||||
await navigator.clipboard.writeText(revealedSecret.value);
|
||||
ElMessage.success("应用密钥已复制");
|
||||
}
|
||||
|
||||
async function switchTab(tabName: string | number) {
|
||||
activeTab.value = String(tabName);
|
||||
if (activeTab.value === "identities") await loadIdentities();
|
||||
if (activeTab.value === "audits") await loadAudits();
|
||||
}
|
||||
|
||||
async function loadIdentities() {
|
||||
loading.value = true;
|
||||
try {
|
||||
const page = await api.ssoIdentities({
|
||||
clientId: identityClientId.value,
|
||||
keyword: identityKeyword.value.trim(),
|
||||
page: identityPage.value,
|
||||
pageSize: identityPageSize.value,
|
||||
});
|
||||
identityItems.value = page.items;
|
||||
identityTotal.value = page.total;
|
||||
} catch (error) {
|
||||
showError(error, "账号绑定加载失败");
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function searchIdentities() {
|
||||
identityPage.value = 1;
|
||||
await loadIdentities();
|
||||
}
|
||||
|
||||
async function changeIdentityPage(page: number, pageSize: number) {
|
||||
identityPage.value = page;
|
||||
identityPageSize.value = pageSize;
|
||||
await loadIdentities();
|
||||
}
|
||||
|
||||
async function unlinkIdentity(item: SsoIdentityItem) {
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
`解除后,${item.userName} 下次从 ${item.clientName} 进入时需要重新核对手机号。`,
|
||||
"解除账号绑定",
|
||||
{ type: "warning", confirmButtonText: "解除绑定", cancelButtonText: "取消" },
|
||||
);
|
||||
await api.unlinkSsoIdentity(item.id);
|
||||
ElMessage.success("账号绑定已解除");
|
||||
await Promise.all([loadIdentities(), loadOverview()]);
|
||||
} catch (error) {
|
||||
if (error === "cancel" || error === "close") return;
|
||||
showError(error, "解除账号绑定失败");
|
||||
}
|
||||
}
|
||||
|
||||
async function loadAudits() {
|
||||
loading.value = true;
|
||||
try {
|
||||
const page = await api.ssoAudits({
|
||||
clientId: auditClientId.value,
|
||||
auditStatus: auditStatus.value,
|
||||
page: auditPage.value,
|
||||
pageSize: auditPageSize.value,
|
||||
});
|
||||
auditItems.value = page.items;
|
||||
auditTotal.value = page.total;
|
||||
} catch (error) {
|
||||
showError(error, "登录审计加载失败");
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function searchAudits() {
|
||||
auditPage.value = 1;
|
||||
await loadAudits();
|
||||
}
|
||||
|
||||
async function changeAuditPage(page: number, pageSize: number) {
|
||||
auditPage.value = page;
|
||||
auditPageSize.value = pageSize;
|
||||
await loadAudits();
|
||||
}
|
||||
|
||||
function maskPhone(phone: string) {
|
||||
return phone.replace(/^(\d{3})\d{4}(\d{4})$/, "$1****$2");
|
||||
}
|
||||
|
||||
function showError(error: unknown, fallback: string) {
|
||||
ElMessage.error(error instanceof Error ? error.message : fallback);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section v-loading="loading" class="sso-page">
|
||||
<div class="page-head inline">
|
||||
<div>
|
||||
<h2>应用接入</h2>
|
||||
<p>让已在其他可信应用登录的学员免验证码进入千问千答,并独立管理密钥、账号绑定和登录审计。</p>
|
||||
</div>
|
||||
<el-button type="primary" @click="openCreateDialog">新增接入应用</el-button>
|
||||
</div>
|
||||
|
||||
<section class="sso-public-url">
|
||||
<div>
|
||||
<strong>用户端公网地址</strong>
|
||||
<p>服务端会使用这个地址生成免登录入口,例如 https://qa.example.com。</p>
|
||||
</div>
|
||||
<el-input v-model="userClientUrl" placeholder="https://qa.example.com" clearable />
|
||||
<el-button :loading="savingUrl" @click="savePublicUrl">保存地址</el-button>
|
||||
</section>
|
||||
|
||||
<el-tabs class="sso-tabs" :model-value="activeTab" @tab-change="switchTab">
|
||||
<el-tab-pane label="接入应用" name="applications">
|
||||
<div class="sso-table-shell">
|
||||
<el-table :data="clients" stripe empty-text="还没有接入应用" table-layout="fixed" style="width: 100%">
|
||||
<el-table-column label="应用" min-width="170">
|
||||
<template #default="{ row }">
|
||||
<div class="sso-app-cell">
|
||||
<strong>{{ row.name }}</strong>
|
||||
<span>{{ row.appId }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="账号策略" min-width="170">
|
||||
<template #default="{ row }">
|
||||
<div class="sso-policy-cell">
|
||||
<span v-if="row.allowAutoRegister">自动注册 · {{ row.defaultEntitlementPlanName || "未配置权益" }}</span>
|
||||
<span v-else>仅已有学员</span>
|
||||
<small>已绑定 {{ row.identityCount }} 人</small>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="回跳地址" min-width="170" show-overflow-tooltip>
|
||||
<template #default="{ row }">{{ row.redirectUris.join("、") || "未配置" }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="运行状态" width="148">
|
||||
<template #default="{ row }">
|
||||
<div class="sso-status-cell">
|
||||
<div><el-switch v-model="row.status" :active-value="1" :inactive-value="0" @change="toggleClient(row)" /><span>{{ row.status === 1 ? "已启用" : "已停用" }}</span></div>
|
||||
<small>最近使用:{{ formatDateTime(row.lastUsedAt) }}</small>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="150" align="right">
|
||||
<template #default="{ row }">
|
||||
<div class="sso-row-actions">
|
||||
<el-button size="small" @click="openEditDialog(row)">编辑</el-button>
|
||||
<el-button size="small" @click="rotateSecret(row)">更新密钥</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
<div class="sso-app-cards">
|
||||
<el-empty v-if="!clients.length" description="还没有接入应用" :image-size="72" />
|
||||
<article v-for="client in clients" v-else :key="client.id" class="sso-app-card">
|
||||
<div class="sso-app-card-head">
|
||||
<div class="sso-app-cell"><strong>{{ client.name }}</strong><span>{{ client.appId }}</span></div>
|
||||
<div class="sso-card-status"><el-switch v-model="client.status" :active-value="1" :inactive-value="0" @change="toggleClient(client)" /><span>{{ client.status === 1 ? "已启用" : "已停用" }}</span></div>
|
||||
</div>
|
||||
<dl>
|
||||
<div><dt>账号策略</dt><dd>{{ client.allowAutoRegister ? `自动注册 · ${client.defaultEntitlementPlanName || "未配置权益"}` : "仅已有学员" }}(已绑定 {{ client.identityCount }} 人)</dd></div>
|
||||
<div><dt>回跳地址</dt><dd>{{ client.redirectUris.join("、") || "未配置" }}</dd></div>
|
||||
<div><dt>最近使用</dt><dd>{{ formatDateTime(client.lastUsedAt) }}</dd></div>
|
||||
</dl>
|
||||
<div class="sso-card-actions"><el-button size="small" @click="openEditDialog(client)">编辑</el-button><el-button size="small" @click="rotateSecret(client)">更新密钥</el-button></div>
|
||||
</article>
|
||||
</div>
|
||||
</el-tab-pane>
|
||||
|
||||
<el-tab-pane label="账号绑定" name="identities">
|
||||
<div class="sso-filters">
|
||||
<el-select v-model="identityClientId" clearable placeholder="全部应用">
|
||||
<el-option v-for="client in clients" :key="client.id" :label="client.name" :value="client.id" />
|
||||
</el-select>
|
||||
<el-input v-model="identityKeyword" clearable placeholder="搜索姓名、手机号或外部用户ID" @keyup.enter="searchIdentities" />
|
||||
<el-button type="primary" @click="searchIdentities">查询</el-button>
|
||||
</div>
|
||||
<el-table :data="identityItems" stripe empty-text="暂无账号绑定">
|
||||
<el-table-column prop="clientName" label="来源应用" min-width="150" />
|
||||
<el-table-column prop="userName" label="学员" min-width="120" />
|
||||
<el-table-column label="手机号" width="140">
|
||||
<template #default="{ row }">{{ maskPhone(row.phone) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="externalUserId" label="外部用户ID" min-width="190" />
|
||||
<el-table-column label="最近免登录" width="180">
|
||||
<template #default="{ row }">{{ formatDateTime(row.lastLoginAt) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="120" align="center">
|
||||
<template #default="{ row }"><el-button size="small" type="danger" plain @click="unlinkIdentity(row)">解除绑定</el-button></template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<AdminPagination :page="identityPage" :page-size="identityPageSize" :total="identityTotal" @change="changeIdentityPage" />
|
||||
</el-tab-pane>
|
||||
|
||||
<el-tab-pane label="登录审计" name="audits">
|
||||
<div class="sso-filters">
|
||||
<el-select v-model="auditClientId" clearable placeholder="全部应用">
|
||||
<el-option v-for="client in clients" :key="client.id" :label="client.name" :value="client.id" />
|
||||
</el-select>
|
||||
<el-select v-model="auditStatus" clearable placeholder="全部状态">
|
||||
<el-option label="成功" value="SUCCESS" />
|
||||
<el-option label="失败" value="FAILED" />
|
||||
</el-select>
|
||||
<el-button type="primary" @click="searchAudits">查询</el-button>
|
||||
</div>
|
||||
<el-table :data="auditItems" stripe empty-text="暂无登录审计">
|
||||
<el-table-column prop="clientName" label="来源应用" min-width="150" />
|
||||
<el-table-column prop="externalUserId" label="外部用户ID" min-width="190" />
|
||||
<el-table-column label="环节" width="110">
|
||||
<template #default="{ row }">{{ row.action === "ticket" ? "申请授权码" : "兑换登录态" }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="状态" width="100" align="center">
|
||||
<template #default="{ row }"><el-tag :type="row.status === 'SUCCESS' ? 'success' : 'danger'">{{ row.status === "SUCCESS" ? "成功" : "失败" }}</el-tag></template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="errorMessage" label="失败原因" min-width="220" show-overflow-tooltip />
|
||||
<el-table-column prop="ip" label="请求IP" width="140" />
|
||||
<el-table-column label="时间" width="180"><template #default="{ row }">{{ formatDateTime(row.createdAt) }}</template></el-table-column>
|
||||
</el-table>
|
||||
<AdminPagination :page="auditPage" :page-size="auditPageSize" :total="auditTotal" @change="changeAuditPage" />
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
|
||||
<el-dialog v-model="clientDialogOpen" class="sso-client-dialog" :title="dialogTitle" width="min(720px, calc(100vw - 32px))" destroy-on-close align-center>
|
||||
<el-form class="sso-client-form" label-position="top">
|
||||
<section class="sso-form-section">
|
||||
<div class="sso-section-heading">
|
||||
<strong>基本信息</strong>
|
||||
<span>用于识别接入方,应用 ID 创建后不可修改。</span>
|
||||
</div>
|
||||
<div class="sso-form-grid">
|
||||
<el-form-item label="应用名称"><el-input v-model="clientForm.name" placeholder="例如:学员服务 App" /></el-form-item>
|
||||
<el-form-item label="应用 ID"><el-input v-model="clientForm.appId" :disabled="Boolean(editingClientId)" placeholder="例如:student-app" /></el-form-item>
|
||||
</div>
|
||||
</section>
|
||||
<section class="sso-form-section">
|
||||
<div class="sso-section-heading">
|
||||
<strong>登录后回跳</strong>
|
||||
<span>每行一个完整 HTTPS 地址,不需要回跳时可以留空。</span>
|
||||
</div>
|
||||
<el-form-item class="sso-redirect-field" label="允许回跳地址">
|
||||
<el-input v-model="clientForm.redirectUrisText" type="textarea" :rows="3" placeholder="https://student.example.com/home" />
|
||||
</el-form-item>
|
||||
</section>
|
||||
<section class="sso-form-section">
|
||||
<div class="sso-section-heading">
|
||||
<strong>访问与账号策略</strong>
|
||||
<span>控制应用是否可用,以及首次进入时如何处理新学员。</span>
|
||||
</div>
|
||||
<div class="sso-setting-list">
|
||||
<div class="sso-setting-row">
|
||||
<div><strong>启用应用</strong><span>停用后,该应用将无法继续申请或使用免登录。</span></div>
|
||||
<el-switch v-model="clientForm.status" :active-value="1" :inactive-value="0" />
|
||||
</div>
|
||||
<div class="sso-setting-row">
|
||||
<div><strong>自动注册新学员</strong><span>传入已验证手机号和姓名时,可自动创建账号并分配权益。</span></div>
|
||||
<el-switch v-model="clientForm.allowAutoRegister" />
|
||||
</div>
|
||||
<div v-if="clientForm.allowAutoRegister" class="sso-entitlement-setting">
|
||||
<el-form-item label="新学员默认权益">
|
||||
<el-select v-model="clientForm.defaultEntitlementPlanId" placeholder="请选择权益版本" style="width: 100%">
|
||||
<el-option v-for="plan in entitlementPlans" :key="plan.id" :label="plan.name" :value="plan.id" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="clientDialogOpen = false">取消</el-button>
|
||||
<el-button type="primary" @click="saveClient">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog v-model="secretDialogOpen" title="请立即保存应用密钥" width="620px" :close-on-click-modal="false">
|
||||
<el-alert title="密钥只在本次显示,关闭后无法再次查看。请保存到接入应用的服务端密钥管理中,不要放入前端代码。" type="warning" :closable="false" show-icon />
|
||||
<div class="sso-secret-block">
|
||||
<label>应用ID</label><code>{{ revealedAppId }}</code>
|
||||
<label>应用密钥</label><code>{{ revealedSecret }}</code>
|
||||
</div>
|
||||
<template #footer>
|
||||
<el-button @click="copySecret">复制密钥</el-button>
|
||||
<el-button type="primary" @click="secretDialogOpen = false">我已保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.sso-page {
|
||||
container-type: inline-size;
|
||||
display: grid;
|
||||
gap: 20px;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.sso-public-url {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(230px, 1fr) minmax(280px, 1.2fr) auto;
|
||||
gap: 18px;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
padding: 20px 22px;
|
||||
border: 1px solid #dce8e4;
|
||||
border-radius: 16px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.sso-public-url > *,
|
||||
.sso-tabs,
|
||||
.sso-table-shell {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.sso-public-url p {
|
||||
margin: 6px 0 0;
|
||||
color: #71817d;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
:deep(.sso-tabs .el-tabs__content),
|
||||
:deep(.sso-tabs .el-tab-pane) {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.sso-table-shell {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
border: 1px solid #e1eae7;
|
||||
border-radius: 14px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.sso-app-cell,
|
||||
.sso-policy-cell,
|
||||
.sso-status-cell {
|
||||
display: grid;
|
||||
gap: 5px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.sso-app-cell strong,
|
||||
.sso-policy-cell span {
|
||||
overflow: hidden;
|
||||
color: #253b35;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.sso-app-cell span,
|
||||
.sso-policy-cell small,
|
||||
.sso-status-cell small {
|
||||
overflow: hidden;
|
||||
color: #80908b;
|
||||
font-size: 12px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.sso-status-cell > div,
|
||||
.sso-card-status {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
color: #4f625c;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.sso-row-actions,
|
||||
.sso-card-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.sso-row-actions :deep(.el-button + .el-button),
|
||||
.sso-card-actions :deep(.el-button + .el-button) {
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
.sso-app-cards {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.sso-filters {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.sso-filters .el-select {
|
||||
width: 190px;
|
||||
}
|
||||
|
||||
.sso-filters .el-input {
|
||||
max-width: 360px;
|
||||
}
|
||||
|
||||
.sso-client-form {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.sso-form-section {
|
||||
min-width: 0;
|
||||
padding: 18px 20px;
|
||||
border: 1px solid #e0e9e6;
|
||||
border-radius: 14px;
|
||||
background: #fbfcfc;
|
||||
}
|
||||
|
||||
.sso-section-heading {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.sso-section-heading strong {
|
||||
color: #203a33;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.sso-section-heading span {
|
||||
color: #7a8b86;
|
||||
font-size: 13px;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.sso-form-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.sso-client-form :deep(.el-form-item) {
|
||||
min-width: 0;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.sso-client-form :deep(.el-form-item__label) {
|
||||
height: auto;
|
||||
padding-bottom: 7px;
|
||||
color: #465a54;
|
||||
font-weight: 600;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.sso-client-form :deep(.el-input__wrapper) {
|
||||
min-height: 42px;
|
||||
}
|
||||
|
||||
.sso-redirect-field :deep(.el-textarea__inner) {
|
||||
min-height: 82px !important;
|
||||
padding: 11px 13px;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.sso-setting-list {
|
||||
overflow: hidden;
|
||||
border: 1px solid #e0e9e6;
|
||||
border-radius: 12px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.sso-setting-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
gap: 24px;
|
||||
align-items: center;
|
||||
min-height: 72px;
|
||||
padding: 14px 16px;
|
||||
}
|
||||
|
||||
.sso-setting-row + .sso-setting-row,
|
||||
.sso-entitlement-setting {
|
||||
border-top: 1px solid #e7eeec;
|
||||
}
|
||||
|
||||
.sso-setting-row > div {
|
||||
display: grid;
|
||||
gap: 5px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.sso-setting-row strong {
|
||||
color: #324942;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.sso-setting-row span {
|
||||
color: #7a8b86;
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.sso-entitlement-setting {
|
||||
padding: 14px 16px 16px;
|
||||
background: #f5faf8;
|
||||
}
|
||||
|
||||
:deep(.sso-client-dialog) {
|
||||
max-height: calc(100vh - 32px);
|
||||
overflow: hidden;
|
||||
border-radius: 18px;
|
||||
}
|
||||
|
||||
:deep(.sso-client-dialog .el-dialog__header) {
|
||||
margin-right: 0;
|
||||
padding: 22px 26px 17px;
|
||||
border-bottom: 1px solid #e7eeec;
|
||||
}
|
||||
|
||||
:deep(.sso-client-dialog .el-dialog__title) {
|
||||
color: #1f3932;
|
||||
font-size: 21px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
:deep(.sso-client-dialog .el-dialog__body) {
|
||||
max-height: calc(100vh - 178px);
|
||||
overflow-y: auto;
|
||||
padding: 20px 26px;
|
||||
}
|
||||
|
||||
:deep(.sso-client-dialog .el-dialog__footer) {
|
||||
padding: 16px 26px 20px;
|
||||
border-top: 1px solid #e7eeec;
|
||||
}
|
||||
|
||||
.sso-secret-block {
|
||||
display: grid;
|
||||
grid-template-columns: 90px minmax(0, 1fr);
|
||||
gap: 12px 16px;
|
||||
align-items: start;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.sso-secret-block label {
|
||||
color: #6c7d79;
|
||||
}
|
||||
|
||||
.sso-secret-block code {
|
||||
padding: 10px 12px;
|
||||
overflow-wrap: anywhere;
|
||||
border-radius: 8px;
|
||||
background: #f3f7f5;
|
||||
color: #173d34;
|
||||
}
|
||||
|
||||
@container (max-width: 940px) {
|
||||
.sso-public-url {
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
}
|
||||
|
||||
.sso-public-url > div {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.sso-table-shell {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.sso-app-cards {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.sso-app-cards > .el-empty {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.sso-app-card {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
min-width: 0;
|
||||
padding: 18px;
|
||||
border: 1px solid #dfe9e6;
|
||||
border-radius: 14px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.sso-app-card-head {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.sso-card-status {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.sso-app-card dl {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.sso-app-card dl > div {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.sso-app-card dt {
|
||||
color: #82918d;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.sso-app-card dd {
|
||||
margin: 0;
|
||||
overflow-wrap: anywhere;
|
||||
color: #41554f;
|
||||
font-size: 13px;
|
||||
line-height: 1.55;
|
||||
}
|
||||
}
|
||||
|
||||
@container (max-width: 660px) {
|
||||
.sso-app-cards,
|
||||
.sso-public-url {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.sso-public-url > div {
|
||||
grid-column: auto;
|
||||
}
|
||||
|
||||
.sso-public-url .el-button {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.sso-filters {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.sso-filters .el-select,
|
||||
.sso-filters .el-input {
|
||||
width: 100%;
|
||||
max-width: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.sso-form-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.sso-form-section {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
:deep(.sso-client-dialog .el-dialog__header),
|
||||
:deep(.sso-client-dialog .el-dialog__body),
|
||||
:deep(.sso-client-dialog .el-dialog__footer) {
|
||||
padding-right: 18px;
|
||||
padding-left: 18px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,74 @@
|
||||
<script setup lang="ts">
|
||||
import MarkdownIt from "markdown-it";
|
||||
import { computed } from "vue";
|
||||
|
||||
const props = defineProps<{
|
||||
content: string;
|
||||
reasoning?: string;
|
||||
showReasoning?: boolean;
|
||||
streaming?: boolean;
|
||||
}>();
|
||||
|
||||
const markdown = new MarkdownIt({ breaks: true, html: false, linkify: true });
|
||||
const parsed = computed(() => splitReasoning(props.content));
|
||||
const answer = computed(() => parsed.value.answer.replace(/^\s+/, ""));
|
||||
const reasoning = computed(() => props.reasoning || parsed.value.reasoning);
|
||||
const renderedContent = computed(() => answer.value.trim() ? markdown.render(normalizeStandaloneHeadings(answer.value)) : "");
|
||||
|
||||
function normalizeStandaloneHeadings(content: string) {
|
||||
return content.replace(/^([ \t]*)\*\*(.+)\*\*[ \t]*$/gm, (line, indent: string, title: string) => {
|
||||
const normalizedTitle = title.trim();
|
||||
if (!normalizedTitle || normalizedTitle.length > 48 || normalizedTitle.includes("**")) return line;
|
||||
return `${indent}### ${normalizedTitle}`;
|
||||
});
|
||||
}
|
||||
|
||||
function splitReasoning(content: string) {
|
||||
const lower = content.toLowerCase();
|
||||
let answer = "";
|
||||
let reasoning = "";
|
||||
let cursor = 0;
|
||||
let depth = 0;
|
||||
while (cursor < content.length) {
|
||||
const tagStart = content.indexOf("<", cursor);
|
||||
if (tagStart === -1) {
|
||||
if (depth) reasoning += content.slice(cursor);
|
||||
else answer += content.slice(cursor);
|
||||
break;
|
||||
}
|
||||
const text = content.slice(cursor, tagStart);
|
||||
if (depth) reasoning += text;
|
||||
else answer += text;
|
||||
const tail = lower.slice(tagStart);
|
||||
const openTag = tail.match(/^<think(?:\s[^>]*)?>/);
|
||||
if (openTag) {
|
||||
depth += 1;
|
||||
cursor = tagStart + openTag[0].length;
|
||||
continue;
|
||||
}
|
||||
const closeTag = tail.match(/^<\/think\s*>/);
|
||||
if (closeTag) {
|
||||
depth = Math.max(0, depth - 1);
|
||||
cursor = tagStart + closeTag[0].length;
|
||||
continue;
|
||||
}
|
||||
if ("<think".startsWith(tail) || "</think>".startsWith(tail)) break;
|
||||
if (depth) reasoning += "<";
|
||||
else answer += "<";
|
||||
cursor = tagStart + 1;
|
||||
}
|
||||
return { answer, reasoning };
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<details v-if="showReasoning && reasoning.trim()" class="agent-reasoning-panel" :open="streaming">
|
||||
<summary>思考过程</summary>
|
||||
<div class="agent-reasoning-content">{{ reasoning }}</div>
|
||||
</details>
|
||||
<div v-if="renderedContent" class="agent-preview-markdown" v-html="renderedContent"></div>
|
||||
<div v-else-if="streaming" class="agent-preview-generation-state">
|
||||
<span></span><span></span><span></span>
|
||||
思考中
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,887 @@
|
||||
<script setup lang="ts">
|
||||
import { Hide, View } from "@element-plus/icons-vue";
|
||||
import { ElMessage } from "element-plus";
|
||||
import { computed, reactive, ref } from "vue";
|
||||
|
||||
import { api } from "../services/api";
|
||||
import {
|
||||
systemSettingSections,
|
||||
type SystemSettingDefinition,
|
||||
type SystemSettingValue,
|
||||
} from "../config/systemSettings";
|
||||
|
||||
interface SettingGroupBlueprint {
|
||||
title: string;
|
||||
description: string;
|
||||
keys: string[];
|
||||
}
|
||||
|
||||
interface SettingGroup extends Omit<SettingGroupBlueprint, "keys"> {
|
||||
settings: SystemSettingDefinition[];
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
values: Record<string, SystemSettingValue>;
|
||||
saving: boolean;
|
||||
dirty: boolean;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
save: [];
|
||||
update: [key: string, value: SystemSettingValue];
|
||||
}>();
|
||||
|
||||
const groupBlueprints: Record<string, SettingGroupBlueprint[]> = {
|
||||
用户与登录: [
|
||||
{
|
||||
title: "学员默认配置",
|
||||
description: "新学员创建、导入时采用的默认资料和额度。",
|
||||
keys: ["daily_chat_limit", "default_user_name_prefix"],
|
||||
},
|
||||
{
|
||||
title: "登录与验证码",
|
||||
description: "控制登录状态和验证码的有效时间。",
|
||||
keys: ["access_token_expire_minutes", "sms_code_expire_minutes"],
|
||||
},
|
||||
{
|
||||
title: "短信服务",
|
||||
description: "在模拟短信与阿里云短信之间切换,并维护短信通道参数。",
|
||||
keys: [
|
||||
"mock_sms_enabled",
|
||||
"mock_sms_code",
|
||||
"aliyun_sms_access_key_id",
|
||||
"aliyun_sms_access_key_secret",
|
||||
"aliyun_sms_sign_name",
|
||||
"aliyun_sms_template_code",
|
||||
"aliyun_sms_template_param_key",
|
||||
"aliyun_sms_endpoint",
|
||||
],
|
||||
},
|
||||
],
|
||||
"AI 问答": [
|
||||
{
|
||||
title: "回答体验",
|
||||
description: "控制引用展示和模型请求的基本行为。",
|
||||
keys: [
|
||||
"ai_timeout_seconds",
|
||||
"mock_model_enabled",
|
||||
"show_reference_sources",
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "对话记忆与实修回顾",
|
||||
description: "控制会话记忆,以及成功问答达到多少轮后开始整理实修回顾资料。",
|
||||
keys: ["chat_context_message_count", "practice_review_auto_settle_successful_rounds"],
|
||||
},
|
||||
{
|
||||
title: "模型分流",
|
||||
description: "决定正式问答在不同问题类型下如何选择模型。",
|
||||
keys: ["chat_model_routing_mode"],
|
||||
},
|
||||
{
|
||||
title: "并发与排队",
|
||||
description: "控制集中提问时的执行上限、等待容量和超时保护。",
|
||||
keys: [
|
||||
"chat_max_active_requests",
|
||||
"chat_max_queue_size",
|
||||
"chat_queue_timeout_seconds",
|
||||
"chat_active_lease_seconds",
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "语音输入",
|
||||
description: "控制用户端麦克风入口、录音上限和独立的阿里云语音凭证。",
|
||||
keys: [
|
||||
"voice_input_enabled",
|
||||
"voice_max_duration_seconds",
|
||||
"aliyun_nls_app_key",
|
||||
"aliyun_nls_access_key_id",
|
||||
"aliyun_nls_access_key_secret",
|
||||
"aliyun_nls_endpoint",
|
||||
],
|
||||
},
|
||||
],
|
||||
外部服务: [
|
||||
{
|
||||
title: "飞书接入",
|
||||
description: "配置飞书知识库直读和独立检索服务。",
|
||||
keys: ["feishu_search_url", "feishu_app_id", "feishu_app_secret"],
|
||||
},
|
||||
{
|
||||
title: "稳定性策略",
|
||||
description: "控制飞书接口慢响应和临时失败时的处理方式。",
|
||||
keys: ["feishu_timeout_seconds", "feishu_retry_count"],
|
||||
},
|
||||
],
|
||||
系统开关: [
|
||||
{
|
||||
title: "前端备案信息",
|
||||
description: "维护登录页和用户主页面底部展示的备案内容及跳转地址。",
|
||||
keys: ["site_filing_text", "site_filing_url"],
|
||||
},
|
||||
{
|
||||
title: "审计与数据",
|
||||
description: "控制后台审计信息和聊天记录导出能力。",
|
||||
keys: ["show_operation_log_detail", "chat_export_enabled"],
|
||||
},
|
||||
{
|
||||
title: "运行状态",
|
||||
description: "用于系统维护期间的访问和提示策略。",
|
||||
keys: ["maintenance_mode"],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const wideSettingKeys = new Set([
|
||||
"chat_model_routing_mode",
|
||||
"chat_max_active_requests",
|
||||
"feishu_search_url",
|
||||
"site_filing_text",
|
||||
"site_filing_url",
|
||||
]);
|
||||
|
||||
const activeSectionTitle = ref(systemSettingSections[0]?.title || "");
|
||||
const revealedSecrets = reactive<Record<string, string>>({});
|
||||
const revealingSecrets = reactive<Record<string, boolean>>({});
|
||||
|
||||
const activeSection = computed(
|
||||
() =>
|
||||
systemSettingSections.find(
|
||||
(section) => section.title === activeSectionTitle.value,
|
||||
) || systemSettingSections[0],
|
||||
);
|
||||
|
||||
const activeGroups = computed<SettingGroup[]>(() => {
|
||||
const section = activeSection.value;
|
||||
if (!section) return [];
|
||||
|
||||
const byKey = new Map(section.settings.map((setting) => [setting.key, setting]));
|
||||
const configuredKeys = new Set<string>();
|
||||
const groups = (groupBlueprints[section.title] || []).map((group) => {
|
||||
const settings = group.keys
|
||||
.map((key) => byKey.get(key))
|
||||
.filter((setting): setting is SystemSettingDefinition => Boolean(setting));
|
||||
settings.forEach((setting) => configuredKeys.add(setting.key));
|
||||
return { title: group.title, description: group.description, settings };
|
||||
});
|
||||
|
||||
const ungrouped = section.settings.filter(
|
||||
(setting) => !configuredKeys.has(setting.key),
|
||||
);
|
||||
if (ungrouped.length) {
|
||||
groups.push({
|
||||
title: "其他设置",
|
||||
description: "尚未归入固定分组的新配置项。",
|
||||
settings: ungrouped,
|
||||
});
|
||||
}
|
||||
return groups.filter((group) => group.settings.length);
|
||||
});
|
||||
|
||||
const statusItems = computed(() => [
|
||||
{
|
||||
label: "短信通道",
|
||||
value: props.values.mock_sms_enabled ? "模拟验证码" : "阿里云短信",
|
||||
tone: props.values.mock_sms_enabled ? "warning" : "success",
|
||||
},
|
||||
{
|
||||
label: "AI 模式",
|
||||
value: props.values.mock_model_enabled ? "模拟模型" : "真实模型",
|
||||
tone: props.values.mock_model_enabled ? "warning" : "success",
|
||||
},
|
||||
{
|
||||
label: "会话记忆",
|
||||
value:
|
||||
Number(props.values.chat_context_message_count) > 0
|
||||
? `最近 ${props.values.chat_context_message_count} 条`
|
||||
: "已关闭",
|
||||
tone:
|
||||
Number(props.values.chat_context_message_count) > 0 ? "neutral" : "warning",
|
||||
},
|
||||
{
|
||||
label: "问答容量",
|
||||
value: `${props.values.chat_max_active_requests || 0} 并发`,
|
||||
tone: "neutral",
|
||||
},
|
||||
{
|
||||
label: "语音输入",
|
||||
value: props.values.voice_input_enabled
|
||||
? `已开启 · ${props.values.voice_max_duration_seconds || 60} 秒`
|
||||
: "已关闭",
|
||||
tone: props.values.voice_input_enabled ? "success" : "neutral",
|
||||
},
|
||||
]);
|
||||
|
||||
function updateSetting(key: string, value: unknown) {
|
||||
if (value === undefined || value === null) return;
|
||||
emit("update", key, value as SystemSettingValue);
|
||||
}
|
||||
|
||||
async function toggleSecret(setting: SystemSettingDefinition) {
|
||||
if (revealedSecrets[setting.key] !== undefined) {
|
||||
delete revealedSecrets[setting.key];
|
||||
return;
|
||||
}
|
||||
const currentValue = String(props.values[setting.key] ?? "");
|
||||
if (currentValue && currentValue !== "******") {
|
||||
revealedSecrets[setting.key] = currentValue;
|
||||
return;
|
||||
}
|
||||
if (revealingSecrets[setting.key]) return;
|
||||
revealingSecrets[setting.key] = true;
|
||||
try {
|
||||
const result = await api.revealConfigSecret(setting.key);
|
||||
revealedSecrets[setting.key] = result.configValue;
|
||||
} catch (error) {
|
||||
ElMessage.error(error instanceof Error ? error.message : "敏感配置读取失败");
|
||||
} finally {
|
||||
revealingSecrets[setting.key] = false;
|
||||
}
|
||||
}
|
||||
|
||||
function updatePasswordSetting(key: string, value: string) {
|
||||
if (revealedSecrets[key] !== undefined) revealedSecrets[key] = value;
|
||||
updateSetting(key, value);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="system-config-page">
|
||||
<header class="system-config-header">
|
||||
<div>
|
||||
<div class="system-config-title-line">
|
||||
<h2>系统配置</h2>
|
||||
<span :class="['save-state', { dirty }]">
|
||||
{{ dirty ? "有未保存修改" : "配置已同步" }}
|
||||
</span>
|
||||
</div>
|
||||
<p>集中维护学员登录、AI 问答、飞书接入和系统运行策略。</p>
|
||||
</div>
|
||||
<div class="system-config-actions">
|
||||
<small>保存后立即对后续请求生效</small>
|
||||
<el-button
|
||||
type="primary"
|
||||
:loading="saving"
|
||||
:disabled="!dirty"
|
||||
@click="emit('save')"
|
||||
>
|
||||
保存修改
|
||||
</el-button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section class="config-status-strip" aria-label="当前关键配置摘要">
|
||||
<div
|
||||
v-for="item in statusItems"
|
||||
:key="item.label"
|
||||
class="config-status-item"
|
||||
>
|
||||
<span>{{ item.label }}</span>
|
||||
<strong :class="`tone-${item.tone}`">{{ item.value }}</strong>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="config-workspace">
|
||||
<nav class="config-section-nav" aria-label="系统配置分组">
|
||||
<div class="config-nav-heading">
|
||||
<strong>配置分类</strong>
|
||||
<span>选择一个分类集中编辑</span>
|
||||
</div>
|
||||
<button
|
||||
v-for="section in systemSettingSections"
|
||||
:key="section.title"
|
||||
type="button"
|
||||
:class="{ active: activeSectionTitle === section.title }"
|
||||
@click="activeSectionTitle = section.title"
|
||||
>
|
||||
<span>
|
||||
<strong>{{ section.title }}</strong>
|
||||
<small>{{ section.description }}</small>
|
||||
</span>
|
||||
<b>{{ section.settings.length }}</b>
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
<section v-if="activeSection" class="config-editor-panel">
|
||||
<header class="config-editor-heading">
|
||||
<div>
|
||||
<span>当前分类</span>
|
||||
<h3>{{ activeSection.title }}</h3>
|
||||
<p>{{ activeSection.description }}</p>
|
||||
</div>
|
||||
<strong>{{ activeSection.settings.length }} 项配置</strong>
|
||||
</header>
|
||||
|
||||
<div class="config-groups">
|
||||
<article
|
||||
v-for="group in activeGroups"
|
||||
:key="group.title"
|
||||
class="config-group-card"
|
||||
>
|
||||
<header>
|
||||
<h4>{{ group.title }}</h4>
|
||||
<p>{{ group.description }}</p>
|
||||
</header>
|
||||
|
||||
<div class="config-fields-grid">
|
||||
<div
|
||||
v-for="setting in group.settings"
|
||||
:key="setting.key"
|
||||
:class="[
|
||||
'config-field',
|
||||
`config-field-${setting.type}`,
|
||||
{ wide: wideSettingKeys.has(setting.key) },
|
||||
]"
|
||||
>
|
||||
<template v-if="setting.type === 'switch'">
|
||||
<div class="config-switch-copy">
|
||||
<strong>{{ setting.label }}</strong>
|
||||
<small>{{ setting.description }}</small>
|
||||
</div>
|
||||
<el-switch
|
||||
:model-value="Boolean(values[setting.key])"
|
||||
@update:model-value="updateSetting(setting.key, $event)"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<label :for="`setting-${setting.key}`">
|
||||
<strong>{{ setting.label }}</strong>
|
||||
<span v-if="setting.type === 'password'">敏感配置</span>
|
||||
</label>
|
||||
<el-input-number
|
||||
v-if="setting.type === 'number'"
|
||||
:id="`setting-${setting.key}`"
|
||||
:model-value="Number(values[setting.key])"
|
||||
:min="setting.min"
|
||||
:max="setting.max"
|
||||
controls-position="right"
|
||||
@update:model-value="updateSetting(setting.key, $event)"
|
||||
/>
|
||||
<el-input
|
||||
v-else-if="setting.type === 'text'"
|
||||
:id="`setting-${setting.key}`"
|
||||
:model-value="String(values[setting.key] ?? '')"
|
||||
:placeholder="setting.placeholder"
|
||||
clearable
|
||||
@update:model-value="updateSetting(setting.key, $event)"
|
||||
/>
|
||||
<el-input
|
||||
v-else-if="setting.type === 'password'"
|
||||
:id="`setting-${setting.key}`"
|
||||
:model-value="revealedSecrets[setting.key] ?? String(values[setting.key] ?? '')"
|
||||
:placeholder="setting.placeholder"
|
||||
:type="revealedSecrets[setting.key] !== undefined ? 'text' : 'password'"
|
||||
@update:model-value="updatePasswordSetting(setting.key, $event)"
|
||||
>
|
||||
<template #suffix>
|
||||
<button
|
||||
type="button"
|
||||
class="secret-visibility-toggle"
|
||||
:class="{ loading: revealingSecrets[setting.key] }"
|
||||
:aria-label="revealedSecrets[setting.key] !== undefined ? '隐藏真实内容' : '查看真实内容'"
|
||||
@click="toggleSecret(setting)"
|
||||
>
|
||||
<el-icon>
|
||||
<Hide v-if="revealedSecrets[setting.key] !== undefined" />
|
||||
<View v-else />
|
||||
</el-icon>
|
||||
</button>
|
||||
</template>
|
||||
</el-input>
|
||||
<el-select
|
||||
v-else-if="setting.type === 'select'"
|
||||
:id="`setting-${setting.key}`"
|
||||
:model-value="String(values[setting.key] ?? '')"
|
||||
:placeholder="setting.placeholder"
|
||||
@update:model-value="updateSetting(setting.key, $event)"
|
||||
>
|
||||
<el-option
|
||||
v-for="option in setting.options || []"
|
||||
:key="option.value"
|
||||
:label="option.label"
|
||||
:value="option.value"
|
||||
/>
|
||||
</el-select>
|
||||
<small>{{ setting.description }}</small>
|
||||
<small
|
||||
v-if="setting.valueHint"
|
||||
class="config-value-hint"
|
||||
>
|
||||
{{ setting.valueHint(values[setting.key]) }}
|
||||
</small>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.system-config-page {
|
||||
display: grid;
|
||||
gap: 18px;
|
||||
min-width: 0;
|
||||
padding-bottom: 24px;
|
||||
}
|
||||
|
||||
.system-config-header {
|
||||
display: flex;
|
||||
gap: 24px;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.system-config-title-line {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.system-config-title-line h2 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.system-config-header p {
|
||||
margin: 7px 0 0;
|
||||
color: #687a75;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.save-state {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 5px 9px;
|
||||
border-radius: 999px;
|
||||
background: #eaf3f0;
|
||||
color: #55716a;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.save-state::before {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: #62a38d;
|
||||
content: "";
|
||||
}
|
||||
|
||||
.save-state.dirty {
|
||||
background: #fff4df;
|
||||
color: #95651b;
|
||||
}
|
||||
|
||||
.save-state.dirty::before {
|
||||
background: #d79a37;
|
||||
}
|
||||
|
||||
.system-config-actions {
|
||||
display: flex;
|
||||
flex: 0 0 auto;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.system-config-actions small {
|
||||
color: #83918d;
|
||||
}
|
||||
|
||||
.system-config-actions .el-button {
|
||||
min-width: 104px;
|
||||
}
|
||||
|
||||
.config-status-strip {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
overflow: hidden;
|
||||
border: 1px solid #dfe9e5;
|
||||
border-radius: 14px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.config-status-item {
|
||||
display: grid;
|
||||
gap: 7px;
|
||||
min-width: 0;
|
||||
padding: 15px 18px;
|
||||
}
|
||||
|
||||
.config-status-item + .config-status-item {
|
||||
border-left: 1px solid #e6eeeb;
|
||||
}
|
||||
|
||||
.config-status-item span {
|
||||
color: #7a8b86;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.config-status-item strong {
|
||||
overflow: hidden;
|
||||
color: #2d463f;
|
||||
font-size: 14px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.config-status-item .tone-success {
|
||||
color: #197653;
|
||||
}
|
||||
|
||||
.config-status-item .tone-warning {
|
||||
color: #a26919;
|
||||
}
|
||||
|
||||
.config-workspace {
|
||||
display: grid;
|
||||
grid-template-columns: 248px minmax(0, 1fr);
|
||||
gap: 18px;
|
||||
align-items: start;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.config-section-nav,
|
||||
.config-editor-panel {
|
||||
border: 1px solid #dfe8e5;
|
||||
border-radius: 16px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.config-section-nav {
|
||||
position: sticky;
|
||||
top: 18px;
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
.config-nav-heading {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
padding: 5px 7px 11px;
|
||||
}
|
||||
|
||||
.config-nav-heading strong {
|
||||
color: #29423b;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.config-nav-heading span {
|
||||
color: #879691;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.config-section-nav button {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
padding: 12px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 11px;
|
||||
background: transparent;
|
||||
color: #49605a;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.18s ease, background-color 0.18s ease;
|
||||
}
|
||||
|
||||
.config-section-nav button:hover {
|
||||
background: #f5f8f7;
|
||||
}
|
||||
|
||||
.config-section-nav button.active {
|
||||
border-color: #bcded2;
|
||||
background: #edf7f3;
|
||||
color: #176d53;
|
||||
}
|
||||
|
||||
.config-section-nav button > span {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.config-section-nav button strong {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.config-section-nav button small {
|
||||
overflow: hidden;
|
||||
color: #82908c;
|
||||
font-size: 11px;
|
||||
line-height: 1.45;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.config-section-nav button b {
|
||||
min-width: 26px;
|
||||
padding: 4px 7px;
|
||||
border-radius: 999px;
|
||||
background: #eef3f1;
|
||||
color: #71817c;
|
||||
font-size: 11px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.config-section-nav button.active b {
|
||||
background: #d9eee7;
|
||||
color: #176d53;
|
||||
}
|
||||
|
||||
.config-editor-panel {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.config-editor-heading {
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
padding: 22px 24px;
|
||||
border-bottom: 1px solid #e5ecea;
|
||||
background: linear-gradient(135deg, #fbfdfc, #f4f9f7);
|
||||
}
|
||||
|
||||
.config-editor-heading > div {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.config-editor-heading span {
|
||||
color: #248064;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.config-editor-heading h3 {
|
||||
margin: 6px 0 0;
|
||||
color: #203b33;
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.config-editor-heading p {
|
||||
margin: 7px 0 0;
|
||||
color: #70827d;
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.config-editor-heading > strong {
|
||||
flex: 0 0 auto;
|
||||
padding: 6px 10px;
|
||||
border-radius: 999px;
|
||||
background: #e7f2ee;
|
||||
color: #4d6d64;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.config-groups {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.config-group-card {
|
||||
overflow: hidden;
|
||||
border: 1px solid #e1eae7;
|
||||
border-radius: 13px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.config-group-card > header {
|
||||
padding: 15px 18px;
|
||||
border-bottom: 1px solid #e8efed;
|
||||
background: #fafcfb;
|
||||
}
|
||||
|
||||
.config-group-card h4 {
|
||||
margin: 0;
|
||||
color: #2c453e;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.config-group-card header p {
|
||||
margin: 5px 0 0;
|
||||
color: #7b8c87;
|
||||
font-size: 12px;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.config-fields-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 0;
|
||||
padding: 4px 18px 18px;
|
||||
}
|
||||
|
||||
.config-field {
|
||||
display: grid;
|
||||
align-content: start;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
padding: 15px 16px 2px 0;
|
||||
}
|
||||
|
||||
.config-field:nth-child(even):not(.wide) {
|
||||
padding-right: 0;
|
||||
padding-left: 16px;
|
||||
border-left: 1px solid #edf2f0;
|
||||
}
|
||||
|
||||
.config-field.wide {
|
||||
grid-column: 1 / -1;
|
||||
padding-right: 0;
|
||||
}
|
||||
|
||||
.config-field label {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.config-field label strong,
|
||||
.config-switch-copy strong {
|
||||
color: #324841;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.config-field label span {
|
||||
flex: 0 0 auto;
|
||||
padding: 3px 7px;
|
||||
border-radius: 999px;
|
||||
background: #fff1ed;
|
||||
color: #a4503f;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.config-field > small,
|
||||
.config-switch-copy small {
|
||||
color: #7b8985;
|
||||
font-size: 12px;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.config-field :deep(.el-input-number),
|
||||
.config-field :deep(.el-input),
|
||||
.config-field :deep(.el-select) {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.secret-visibility-toggle {
|
||||
display: inline-flex;
|
||||
padding: 2px;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
color: #66736f;
|
||||
}
|
||||
|
||||
.secret-visibility-toggle.loading {
|
||||
pointer-events: none;
|
||||
opacity: 0.45;
|
||||
}
|
||||
|
||||
.config-field :deep(.el-input__wrapper),
|
||||
.config-field :deep(.el-select__wrapper),
|
||||
.config-field :deep(.el-input-number) {
|
||||
min-height: 40px;
|
||||
}
|
||||
|
||||
.config-field-switch {
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
gap: 20px;
|
||||
align-items: center;
|
||||
min-height: 70px;
|
||||
padding-bottom: 12px;
|
||||
}
|
||||
|
||||
.config-switch-copy {
|
||||
display: grid;
|
||||
gap: 5px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.config-value-hint {
|
||||
padding: 10px 12px;
|
||||
border: 1px solid #cde4db;
|
||||
border-radius: 9px;
|
||||
background: #f2f9f6;
|
||||
color: #2c6e58 !important;
|
||||
}
|
||||
|
||||
@media (max-width: 1320px) {
|
||||
.config-workspace {
|
||||
grid-template-columns: 220px minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.config-status-strip {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.config-status-item:nth-child(3) {
|
||||
border-left: 0;
|
||||
border-top: 1px solid #e6eeeb;
|
||||
}
|
||||
|
||||
.config-status-item:nth-child(4) {
|
||||
border-top: 1px solid #e6eeeb;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1080px) {
|
||||
.system-config-header {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.system-config-actions {
|
||||
width: 100%;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.config-workspace {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.config-section-nav {
|
||||
position: static;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.config-nav-heading {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.config-status-strip,
|
||||
.config-section-nav,
|
||||
.config-fields-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.config-status-item + .config-status-item,
|
||||
.config-status-item:nth-child(3),
|
||||
.config-status-item:nth-child(4) {
|
||||
border-top: 1px solid #e6eeeb;
|
||||
border-left: 0;
|
||||
}
|
||||
|
||||
.config-field,
|
||||
.config-field:nth-child(even):not(.wide) {
|
||||
padding-right: 0;
|
||||
padding-left: 0;
|
||||
border-left: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,440 @@
|
||||
<script setup lang="ts">
|
||||
import { ElMessage } from "element-plus";
|
||||
import { ref, watch } from "vue";
|
||||
|
||||
import { api } from "../services/api";
|
||||
import type { AdminUserDetail } from "../types/api";
|
||||
import { formatDateTime } from "../utils/dateTime";
|
||||
|
||||
const props = defineProps<{ modelValue: boolean; userId: number | null }>();
|
||||
defineEmits<{ "update:modelValue": [value: boolean] }>();
|
||||
|
||||
const detail = ref<AdminUserDetail | null>(null);
|
||||
const loading = ref(false);
|
||||
const reportGenerating = ref("");
|
||||
const settlementRetrying = ref<number | null>(null);
|
||||
|
||||
watch(
|
||||
() => [props.modelValue, props.userId] as const,
|
||||
([open, userId]) => {
|
||||
if (open && userId) void loadDetail(userId);
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
async function loadDetail(userId = props.userId) {
|
||||
if (!userId) return;
|
||||
loading.value = true;
|
||||
try {
|
||||
detail.value = await api.userDetail(userId);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function generateReport(type: "weekly" | "monthly" | "stage") {
|
||||
if (!detail.value) return;
|
||||
reportGenerating.value = type;
|
||||
try {
|
||||
await api.generateUserReport(detail.value.user.id, { reportType: type });
|
||||
await loadDetail(detail.value.user.id);
|
||||
ElMessage.success("报告任务已进入后台队列,不会阻塞聊天服务");
|
||||
} catch (error) {
|
||||
ElMessage.error(error instanceof Error ? error.message : "报告生成失败");
|
||||
} finally {
|
||||
reportGenerating.value = "";
|
||||
}
|
||||
}
|
||||
|
||||
async function retryTopicSettlement(topicId: number) {
|
||||
if (!detail.value || settlementRetrying.value !== null) return;
|
||||
settlementRetrying.value = topicId;
|
||||
try {
|
||||
await api.retryTopicSettlement(detail.value.user.id, topicId);
|
||||
await loadDetail(detail.value.user.id);
|
||||
ElMessage.success("实修回顾整理任务已重新进入后台队列");
|
||||
} catch (error) {
|
||||
ElMessage.error(
|
||||
error instanceof Error ? error.message : "实修回顾整理重试失败",
|
||||
);
|
||||
} finally {
|
||||
settlementRetrying.value = null;
|
||||
}
|
||||
}
|
||||
|
||||
function reportStatusLabel(status: string) {
|
||||
return (
|
||||
(
|
||||
{
|
||||
pending: "等待生成",
|
||||
running: "生成中",
|
||||
success: "已完成",
|
||||
fallback: "已降级沉淀",
|
||||
empty: "暂无沉淀",
|
||||
failed: "生成失败",
|
||||
} as Record<string, string>
|
||||
)[status] || status
|
||||
);
|
||||
}
|
||||
|
||||
function formatMoney(value?: number | null, currency = "CNY") {
|
||||
if (typeof value !== "number") return "-";
|
||||
if (currency === "MIXED") return "多币种,见明细";
|
||||
return `${currency || "CNY"} ${value.toFixed(6)}`;
|
||||
}
|
||||
|
||||
function reportTriggerLabel(value: string) {
|
||||
if (value.startsWith("lazy:")) return "学员活跃自动补齐";
|
||||
if (value.startsWith("schedule:")) return "系统全局调度";
|
||||
if (value.startsWith("dependency:")) return "月报依赖自动补周报";
|
||||
if (value.startsWith("refresh:")) return "来源更新自动刷新";
|
||||
return "管理员手动";
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<el-drawer
|
||||
:model-value="modelValue"
|
||||
size="860px"
|
||||
title="用户详情"
|
||||
@update:model-value="$emit('update:modelValue', $event)"
|
||||
>
|
||||
<div v-loading="loading" class="user-detail-body">
|
||||
<template v-if="detail">
|
||||
<section class="user-detail-head">
|
||||
<div>
|
||||
<h3>
|
||||
{{
|
||||
detail.user.name || detail.user.nickname || detail.user.phone
|
||||
}}
|
||||
</h3>
|
||||
<p>
|
||||
{{ detail.user.phone }} ·
|
||||
{{ detail.user.status === 1 ? "已启用" : "已停用" }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="user-detail-tags">
|
||||
<el-tag
|
||||
v-if="detail.metrics.isHighFrequency"
|
||||
type="warning"
|
||||
effect="plain"
|
||||
>高频使用</el-tag
|
||||
>
|
||||
<el-tag v-if="detail.metrics.isInactive" type="info" effect="plain"
|
||||
>近30天低活跃</el-tag
|
||||
>
|
||||
<el-tag type="success" effect="plain">{{
|
||||
detail.user.entitlement?.name || "默认基础版"
|
||||
}}</el-tag>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="chat-summary user-metric-grid">
|
||||
<div>
|
||||
<span>近30天活跃</span
|
||||
><strong>{{ detail.metrics.recentActiveDays }} 天</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>总会话</span
|
||||
><strong>{{ detail.metrics.totalSessions }}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>总消息</span
|
||||
><strong>{{ detail.metrics.totalMessages }}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>AI 请求</span
|
||||
><strong>{{ detail.metrics.aiRequestCount }}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>总 Token</span
|
||||
><strong>{{ detail.metrics.totalToken }}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>估算成本</span
|
||||
><strong>{{
|
||||
formatMoney(
|
||||
detail.metrics.estimatedCost,
|
||||
detail.metrics.costCurrency,
|
||||
)
|
||||
}}</strong>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="audit-detail-grid">
|
||||
<div>
|
||||
<span>权益有效期</span
|
||||
><strong>{{
|
||||
detail.user.entitlement?.expiredAt ||
|
||||
detail.user.expiredAt ||
|
||||
"长期有效"
|
||||
}}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>最近登录</span
|
||||
><strong>{{ formatDateTime(detail.user.lastLoginAt) }}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>最近发言</span
|
||||
><strong>{{ formatDateTime(detail.metrics.lastMessageAt) }}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>生成记录</span
|
||||
><strong
|
||||
>求助卡 {{ detail.metrics.helpCardCount }} / 分享稿
|
||||
{{ detail.metrics.shareDraftCount }}</strong
|
||||
>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<h3 class="detail-title">近期实修回顾</h3>
|
||||
<section v-if="detail.growthProfile" class="topic-summary-card">
|
||||
<pre>{{ detail.growthProfile.reviewText || "暂无近期回顾" }}</pre>
|
||||
<div
|
||||
v-if="detail.growthProfile.currentFocus"
|
||||
class="topic-summary-fields"
|
||||
>
|
||||
<p>
|
||||
<strong>近期关注</strong>{{ detail.growthProfile.currentFocus }}
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
<el-empty
|
||||
v-else
|
||||
description="该用户暂无新版近期实修回顾,将在下一次对话回顾整理后生成"
|
||||
:image-size="64"
|
||||
/>
|
||||
|
||||
<section class="user-report-head">
|
||||
<div>
|
||||
<h3 class="detail-title">周期实修回顾</h3>
|
||||
<p>
|
||||
只基于本周期对话摘要回顾近期关注,不评价成长结果;同一周期重复生成会覆盖旧版本。
|
||||
</p>
|
||||
</div>
|
||||
<div class="user-report-actions">
|
||||
<el-button size="small" @click="loadDetail()">刷新状态</el-button>
|
||||
<el-button
|
||||
size="small"
|
||||
:loading="reportGenerating === 'weekly'"
|
||||
@click="generateReport('weekly')"
|
||||
>生成周报</el-button
|
||||
>
|
||||
<el-button
|
||||
size="small"
|
||||
:loading="reportGenerating === 'monthly'"
|
||||
@click="generateReport('monthly')"
|
||||
>生成月报</el-button
|
||||
>
|
||||
<el-button
|
||||
size="small"
|
||||
:loading="reportGenerating === 'stage'"
|
||||
@click="generateReport('stage')"
|
||||
>生成阶段回顾</el-button
|
||||
>
|
||||
</div>
|
||||
</section>
|
||||
<el-collapse
|
||||
v-if="detail.recentReports.length"
|
||||
class="topic-summary-collapse"
|
||||
>
|
||||
<el-collapse-item
|
||||
v-for="report in detail.recentReports"
|
||||
:key="report.id"
|
||||
:title="`#${report.id} ${report.title} / ${reportStatusLabel(report.status)}`"
|
||||
>
|
||||
<section class="topic-summary-card">
|
||||
<div class="topic-summary-meta">
|
||||
<span>状态:{{ reportStatusLabel(report.status) }}</span
|
||||
><span>类型:{{ report.reportTypeLabel }}</span>
|
||||
<span
|
||||
>周期:{{ report.periodStart }} 至
|
||||
{{ report.periodEnd }}</span
|
||||
>
|
||||
<span v-if="report.reportType === 'weekly'">来源聊天:{{ report.sourceMessageIds.length }}</span>
|
||||
<span v-else-if="report.reportType === 'monthly'">来源周报:{{ report.sourceReportIds.length }}</span>
|
||||
<template v-else>
|
||||
<span>来源主题:{{ report.sourceTopicIds.length }}</span>
|
||||
<span>来源摘要:{{ report.sourceSummaryIds.length }}</span>
|
||||
</template>
|
||||
<span>模型:{{ report.modelName || "-" }}</span>
|
||||
<span
|
||||
>触发方式:{{ reportTriggerLabel(report.generatedBy) }}</span
|
||||
>
|
||||
<span
|
||||
>执行次数:{{ report.attemptCount }}/{{
|
||||
report.maxAttempts
|
||||
}}</span
|
||||
>
|
||||
<span v-if="report.nextRunAt"
|
||||
>下次执行:{{ formatDateTime(report.nextRunAt) }}</span
|
||||
><span>完成时间:{{ formatDateTime(report.finishedAt) }}</span>
|
||||
</div>
|
||||
<p v-if="report.errorMessage" class="report-error">
|
||||
最近错误:{{ report.errorMessage }}
|
||||
</p>
|
||||
<pre v-if="report.content">{{ report.content }}</pre>
|
||||
<p
|
||||
v-else-if="
|
||||
report.status === 'pending' || report.status === 'running'
|
||||
"
|
||||
class="report-pending"
|
||||
>
|
||||
后台正在生成报告,可以先关闭抽屉,稍后回来查看。
|
||||
</p>
|
||||
</section>
|
||||
</el-collapse-item>
|
||||
</el-collapse>
|
||||
<el-empty v-else description="该用户暂无周期报告" :image-size="64" />
|
||||
|
||||
<h3 class="detail-title">最近对话片段</h3>
|
||||
<el-collapse
|
||||
v-if="detail.recentTopics.length"
|
||||
class="topic-summary-collapse"
|
||||
>
|
||||
<el-collapse-item
|
||||
v-for="topic in detail.recentTopics"
|
||||
:key="topic.id"
|
||||
:title="`#${topic.id} ${topic.title} / ${topic.status}`"
|
||||
>
|
||||
<section class="topic-summary-card">
|
||||
<div class="topic-summary-meta">
|
||||
<span>核心问题:{{ topic.coreQuestion }}</span
|
||||
><span>消息数:{{ topic.messageCount }}</span>
|
||||
<span
|
||||
>Token:{{ topic.tokenInput }}/{{ topic.tokenOutput }}</span
|
||||
>
|
||||
<span>开始:{{ formatDateTime(topic.startedAt) }}</span
|
||||
><span>更新:{{ formatDateTime(topic.updatedAt) }}</span>
|
||||
</div>
|
||||
<template v-if="topic.summary">
|
||||
<div class="topic-summary-meta">
|
||||
<span
|
||||
>回顾整理状态:{{
|
||||
reportStatusLabel(topic.summary.status)
|
||||
}}</span
|
||||
>
|
||||
<span
|
||||
>执行次数:{{ topic.summary.attemptCount }}/{{
|
||||
topic.summary.maxAttempts
|
||||
}}</span
|
||||
>
|
||||
<span v-if="topic.summary.lastStartedAt"
|
||||
>开始执行:{{ formatDateTime(topic.summary.lastStartedAt) }}</span
|
||||
>
|
||||
<span v-if="topic.summary.finishedAt"
|
||||
>完成:{{ formatDateTime(topic.summary.finishedAt) }}</span
|
||||
>
|
||||
</div>
|
||||
<p v-if="topic.summary.errorMessage" class="report-error">
|
||||
最近错误:{{ topic.summary.errorMessage }}
|
||||
</p>
|
||||
<pre
|
||||
v-if="
|
||||
topic.summary.status === 'success' ||
|
||||
topic.summary.status === 'fallback'
|
||||
"
|
||||
>{{ topic.summary.summary }}</pre
|
||||
>
|
||||
<p
|
||||
v-else-if="
|
||||
topic.summary.status === 'pending' ||
|
||||
topic.summary.status === 'running'
|
||||
"
|
||||
class="report-pending"
|
||||
>
|
||||
主题已结束,摘要和近期实修回顾正在后台生成。
|
||||
</p>
|
||||
<div
|
||||
v-if="
|
||||
topic.summary.status === 'success' ||
|
||||
topic.summary.status === 'fallback'
|
||||
"
|
||||
class="topic-summary-fields"
|
||||
>
|
||||
<p v-if="topic.summary.currentFocus">
|
||||
<strong>本次关注</strong>{{ topic.summary.currentFocus }}
|
||||
</p>
|
||||
<p v-if="topic.summary.nextObservation">
|
||||
<strong>可以继续留意</strong
|
||||
>{{ topic.summary.nextObservation }}
|
||||
</p>
|
||||
</div>
|
||||
<el-button
|
||||
v-if="topic.summary.status === 'failed'"
|
||||
size="small"
|
||||
type="primary"
|
||||
:loading="settlementRetrying === topic.id"
|
||||
@click="retryTopicSettlement(topic.id)"
|
||||
>重新沉淀</el-button
|
||||
>
|
||||
</template>
|
||||
<el-empty
|
||||
v-else
|
||||
description="该主题尚未沉淀摘要"
|
||||
:image-size="60"
|
||||
/>
|
||||
</section>
|
||||
</el-collapse-item>
|
||||
</el-collapse>
|
||||
<el-empty v-else description="该用户暂无主题记录" :image-size="64" />
|
||||
|
||||
<h3 class="detail-title">
|
||||
最近老师求助卡({{ detail.recentHelpCards.length }})
|
||||
</h3>
|
||||
<el-collapse
|
||||
v-if="detail.recentHelpCards.length"
|
||||
class="topic-summary-collapse"
|
||||
>
|
||||
<el-collapse-item
|
||||
v-for="card in detail.recentHelpCards"
|
||||
:key="card.id"
|
||||
:title="`#${card.id} / 主题 #${card.topicSessionId} / ${card.copied ? '用户已复制' : '未复制'}`"
|
||||
>
|
||||
<section class="topic-summary-card">
|
||||
<div class="topic-summary-meta">
|
||||
<span>生成:{{ formatDateTime(card.createdAt) }}</span
|
||||
><span>复制:{{ formatDateTime(card.copiedAt) }}</span
|
||||
><span>来源:{{ card.source }}</span>
|
||||
</div>
|
||||
<pre>{{ card.content }}</pre>
|
||||
</section>
|
||||
</el-collapse-item>
|
||||
</el-collapse>
|
||||
<el-empty
|
||||
v-else
|
||||
description="该用户暂无求助卡生成记录"
|
||||
:image-size="64"
|
||||
/>
|
||||
|
||||
<h3 class="detail-title">
|
||||
最近班级分享稿({{ detail.recentShareDrafts.length }})
|
||||
</h3>
|
||||
<el-collapse
|
||||
v-if="detail.recentShareDrafts.length"
|
||||
class="topic-summary-collapse"
|
||||
>
|
||||
<el-collapse-item
|
||||
v-for="draft in detail.recentShareDrafts"
|
||||
:key="draft.id"
|
||||
:title="`#${draft.id} / 主题 #${draft.topicSessionId} / ${draft.copied ? '用户已复制' : '未复制'}`"
|
||||
>
|
||||
<section class="topic-summary-card">
|
||||
<div class="topic-summary-meta">
|
||||
<span>生成:{{ formatDateTime(draft.createdAt) }}</span
|
||||
><span>复制:{{ formatDateTime(draft.copiedAt) }}</span
|
||||
><span>来源:{{ draft.source }}</span>
|
||||
</div>
|
||||
<pre>{{ draft.content }}</pre>
|
||||
</section>
|
||||
</el-collapse-item>
|
||||
</el-collapse>
|
||||
<el-empty
|
||||
v-else
|
||||
description="该用户暂无分享稿生成记录"
|
||||
:image-size="64"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
</el-drawer>
|
||||
</template>
|
||||
@@ -0,0 +1,60 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, reactive, watch } from "vue";
|
||||
|
||||
import type { AdminUser } from "../types/api";
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: boolean;
|
||||
users: AdminUser[];
|
||||
submitting: boolean;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
"update:modelValue": [value: boolean];
|
||||
submit: [payload: { extensionDays: number; remark: string }];
|
||||
}>();
|
||||
|
||||
const form = reactive({ extensionDays: 30, remark: "" });
|
||||
const title = computed(() => props.users.length > 1 ? `批量续期(${props.users.length} 人)` : "用户权益续期");
|
||||
const userNames = computed(() => props.users.map((user) => user.name || user.phone).join("、"));
|
||||
|
||||
watch(() => props.modelValue, (open) => {
|
||||
if (open) Object.assign(form, { extensionDays: 30, remark: "" });
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<el-dialog
|
||||
:model-value="modelValue"
|
||||
:title="title"
|
||||
width="min(520px, calc(100vw - 32px))"
|
||||
destroy-on-close
|
||||
@update:model-value="$emit('update:modelValue', $event)"
|
||||
>
|
||||
<div class="renew-summary">
|
||||
<span>续期对象</span>
|
||||
<strong>{{ userNames }}</strong>
|
||||
<small>未到期权益从原到期日顺延;已到期权益从当前时间重新生效。长期有效或未分配专属权益的用户不会被误续期。</small>
|
||||
</div>
|
||||
<el-form label-position="top" @submit.prevent>
|
||||
<el-form-item label="延长天数">
|
||||
<el-input-number v-model="form.extensionDays" :min="1" :max="3650" controls-position="right" />
|
||||
</el-form-item>
|
||||
<el-form-item label="续期备注(可选)">
|
||||
<el-input v-model="form.remark" maxlength="255" show-word-limit placeholder="例如:线下续费确认、活动补偿" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button :disabled="submitting" @click="$emit('update:modelValue', false)">取消</el-button>
|
||||
<el-button type="primary" :loading="submitting" @click="emit('submit', form)">确认续期</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.renew-summary { display: grid; gap: 6px; margin-bottom: 18px; padding: 14px; border-radius: 10px; background: #f4f8f6; }
|
||||
.renew-summary span, .renew-summary small { color: #6d7e78; font-size: 12px; }
|
||||
.renew-summary strong { color: #25483e; line-height: 1.55; }
|
||||
.renew-summary small { line-height: 1.6; }
|
||||
.el-input-number { width: 100%; }
|
||||
</style>
|
||||
@@ -0,0 +1,458 @@
|
||||
<script setup lang="ts">
|
||||
import { ElMessage } from "element-plus";
|
||||
import { onMounted, reactive, ref } from "vue";
|
||||
|
||||
import { api } from "../services/api";
|
||||
import type {
|
||||
AdminUser,
|
||||
EntitlementPlan,
|
||||
UserImportResult,
|
||||
} from "../types/api";
|
||||
import AdminPagination from "./AdminPagination.vue";
|
||||
import TableRowActions from "./TableRowActions.vue";
|
||||
import UserDetailDrawer from "./UserDetailDrawer.vue";
|
||||
import UserEntitlementRenewDialog from "./UserEntitlementRenewDialog.vue";
|
||||
import { formatDate, formatDateTime, toUtcIsoFromBusinessDate } from "../utils/dateTime";
|
||||
|
||||
const loading = ref(false);
|
||||
const users = ref<AdminUser[]>([]);
|
||||
const entitlementPlans = ref<EntitlementPlan[]>([]);
|
||||
const pager = reactive({ page: 1, pageSize: 20, total: 0 });
|
||||
const keyword = ref("");
|
||||
const filters = reactive({
|
||||
planId: undefined as number | undefined,
|
||||
status: "all",
|
||||
});
|
||||
const selectedUsers = ref<AdminUser[]>([]);
|
||||
const renewDialogOpen = ref(false);
|
||||
const renewTargets = ref<AdminUser[]>([]);
|
||||
const renewSubmitting = ref(false);
|
||||
const detailOpen = ref(false);
|
||||
const detailUserId = ref<number | null>(null);
|
||||
const studentImportFile = ref<File | null>(null);
|
||||
const studentImportInput = ref<HTMLInputElement | null>(null);
|
||||
const studentImportResult = ref<UserImportResult | null>(null);
|
||||
const studentForm = reactive({
|
||||
phone: "",
|
||||
name: "",
|
||||
nickname: "",
|
||||
status: 1,
|
||||
dailyChatLimit: 100,
|
||||
expiredAt: "",
|
||||
});
|
||||
|
||||
onMounted(async () => {
|
||||
loading.value = true;
|
||||
try {
|
||||
await Promise.all([loadEntitlementPlans(), loadUsers()]);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
});
|
||||
|
||||
async function loadUsers(page = 1, pageSize = pager.pageSize) {
|
||||
const result = await api.users({
|
||||
keyword: keyword.value,
|
||||
planId: filters.planId,
|
||||
entitlementStatus: filters.status,
|
||||
page,
|
||||
pageSize,
|
||||
});
|
||||
users.value = result.items;
|
||||
selectedUsers.value = [];
|
||||
Object.assign(pager, {
|
||||
page: result.page,
|
||||
pageSize: result.pageSize,
|
||||
total: result.total,
|
||||
});
|
||||
}
|
||||
|
||||
async function reloadCurrentPage() {
|
||||
await loadUsers(pager.page, pager.pageSize);
|
||||
}
|
||||
|
||||
async function loadEntitlementPlans() {
|
||||
entitlementPlans.value = await api.entitlementPlans(true);
|
||||
}
|
||||
|
||||
function resetFilters() {
|
||||
keyword.value = "";
|
||||
filters.planId = undefined;
|
||||
filters.status = "all";
|
||||
void loadUsers(1);
|
||||
}
|
||||
|
||||
function handleSelectionChange(rows: AdminUser[]) {
|
||||
selectedUsers.value = rows;
|
||||
}
|
||||
|
||||
function openRenewDialog(targets: AdminUser[]) {
|
||||
if (!targets.length) return ElMessage.warning("请先选择需要续期的用户");
|
||||
renewTargets.value = targets;
|
||||
renewDialogOpen.value = true;
|
||||
}
|
||||
|
||||
async function submitEntitlementRenew(payload: {
|
||||
extensionDays: number;
|
||||
remark: string;
|
||||
}) {
|
||||
if (!renewTargets.value.length || renewSubmitting.value) return;
|
||||
renewSubmitting.value = true;
|
||||
const idempotencyKey =
|
||||
typeof crypto.randomUUID === "function"
|
||||
? crypto.randomUUID()
|
||||
: `renew-${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
||||
try {
|
||||
if (renewTargets.value.length === 1) {
|
||||
await api.renewUserEntitlement(renewTargets.value[0].id, {
|
||||
...payload,
|
||||
idempotencyKey,
|
||||
});
|
||||
ElMessage.success("权益续期成功");
|
||||
} else {
|
||||
const result = await api.batchRenewUserEntitlement({
|
||||
userIds: renewTargets.value.map((user) => user.id),
|
||||
...payload,
|
||||
idempotencyKey,
|
||||
});
|
||||
if (result.failureCount) {
|
||||
const failure = result.failed[0];
|
||||
ElMessage.warning(
|
||||
`成功 ${result.successCount} 人,失败 ${result.failureCount} 人${failure ? `;${failure.name || failure.userId}:${failure.reason}` : ""}`,
|
||||
);
|
||||
} else {
|
||||
ElMessage.success(`已为 ${result.successCount} 位用户续期`);
|
||||
}
|
||||
}
|
||||
renewDialogOpen.value = false;
|
||||
await reloadCurrentPage();
|
||||
} catch (error) {
|
||||
ElMessage.error(error instanceof Error ? error.message : "权益续期失败");
|
||||
} finally {
|
||||
renewSubmitting.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function entitlementStatusLabel(row: AdminUser) {
|
||||
const status = row.entitlement?.lifecycleStatus;
|
||||
if (status === "expired_fallback") return "已到期,已降级";
|
||||
if (status === "expiring_7") return "7 天内到期";
|
||||
if (status === "expiring_30") return "30 天内到期";
|
||||
if (status === "long_term") return "长期有效";
|
||||
if (status === "default") return "默认权益";
|
||||
return "生效中";
|
||||
}
|
||||
|
||||
function entitlementStatusType(row: AdminUser) {
|
||||
const status = row.entitlement?.lifecycleStatus;
|
||||
if (status === "expired_fallback") return "danger";
|
||||
if (status === "expiring_7" || status === "expiring_30") return "warning";
|
||||
if (status === "active" || status === "long_term") return "success";
|
||||
return "info";
|
||||
}
|
||||
|
||||
async function assignUserEntitlement(row: AdminUser, planId: number) {
|
||||
row.entitlement = await api.assignUserEntitlement(row.id, { planId });
|
||||
ElMessage.success("用户权益已更新");
|
||||
}
|
||||
|
||||
function openUserDetail(row: AdminUser) {
|
||||
detailUserId.value = row.id;
|
||||
detailOpen.value = true;
|
||||
}
|
||||
|
||||
async function saveUser(row: AdminUser) {
|
||||
await api.updateUser(row.id, {
|
||||
status: row.status,
|
||||
dailyChatLimit: row.dailyChatLimit,
|
||||
});
|
||||
ElMessage.success("用户已更新");
|
||||
await reloadCurrentPage();
|
||||
}
|
||||
|
||||
async function createStudent() {
|
||||
if (!studentForm.phone || !studentForm.name)
|
||||
return ElMessage.error("请填写手机号和姓名");
|
||||
await api.createUser({
|
||||
phone: studentForm.phone,
|
||||
name: studentForm.name,
|
||||
nickname: studentForm.nickname || null,
|
||||
status: studentForm.status,
|
||||
dailyChatLimit: studentForm.dailyChatLimit,
|
||||
expiredAt: toUtcIsoFromBusinessDate(studentForm.expiredAt, "end") ?? null,
|
||||
});
|
||||
ElMessage.success("学员已添加");
|
||||
resetStudentForm();
|
||||
await loadUsers(1);
|
||||
}
|
||||
|
||||
function handleStudentImportFile(event: Event) {
|
||||
studentImportFile.value =
|
||||
(event.target as HTMLInputElement).files?.[0] ?? null;
|
||||
}
|
||||
|
||||
async function importStudents() {
|
||||
if (!studentImportFile.value) return ElMessage.error("请先选择 Excel 文件");
|
||||
studentImportResult.value = await api.importUsersExcel(
|
||||
studentImportFile.value,
|
||||
);
|
||||
ElMessage.success(
|
||||
`导入完成:新增 ${studentImportResult.value.created},更新 ${studentImportResult.value.updated},失败 ${studentImportResult.value.failed}`,
|
||||
);
|
||||
studentImportFile.value = null;
|
||||
if (studentImportInput.value) studentImportInput.value.value = "";
|
||||
await loadUsers(1);
|
||||
}
|
||||
|
||||
function resetStudentForm() {
|
||||
Object.assign(studentForm, {
|
||||
phone: "",
|
||||
name: "",
|
||||
nickname: "",
|
||||
status: 1,
|
||||
dailyChatLimit: 100,
|
||||
expiredAt: "",
|
||||
});
|
||||
}
|
||||
|
||||
async function deleteUser(row: AdminUser) {
|
||||
if (
|
||||
!window.confirm(
|
||||
`确认删除用户 ${row.phone} 吗?删除后前台将无法继续使用该账号。`,
|
||||
)
|
||||
)
|
||||
return;
|
||||
await api.deleteUser(row.id);
|
||||
ElMessage.success("用户已删除");
|
||||
await reloadCurrentPage();
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section v-loading="loading" class="feature-page">
|
||||
<div class="page-head inline">
|
||||
<div>
|
||||
<h2>用户管理</h2>
|
||||
<p>维护学员名单;只有名单内启用学员可以登录用户端。</p>
|
||||
</div>
|
||||
</div>
|
||||
<section class="student-tools">
|
||||
<div class="student-tool-panel">
|
||||
<h3>手动添加学员</h3>
|
||||
<div class="student-form-grid">
|
||||
<el-input v-model="studentForm.phone" placeholder="手机号" />
|
||||
<el-input v-model="studentForm.name" placeholder="姓名" />
|
||||
<el-input v-model="studentForm.nickname" placeholder="昵称(可选)" />
|
||||
<el-input-number v-model="studentForm.dailyChatLimit" :min="0" />
|
||||
<label class="record-date-field student-expiry-field">
|
||||
<span>有效期</span>
|
||||
<input
|
||||
v-model="studentForm.expiredAt"
|
||||
type="date"
|
||||
aria-label="学员有效期"
|
||||
title="留空表示长期有效"
|
||||
/>
|
||||
</label>
|
||||
<div class="student-status-field">
|
||||
<span>启用</span>
|
||||
<el-switch
|
||||
v-model="studentForm.status"
|
||||
:active-value="1"
|
||||
:inactive-value="0"
|
||||
aria-label="启用学员账号"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="actions compact">
|
||||
<el-button type="primary" @click="createStudent">添加学员</el-button>
|
||||
<el-button @click="resetStudentForm">清空</el-button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="student-tool-panel">
|
||||
<h3>批量导入学员</h3>
|
||||
<p class="tool-help">下载模板后填写学员信息和权益名称,再上传 .xlsx 文件导入;权益名称须与系统完全一致,留空或未匹配时不分配专属权益。</p>
|
||||
<div class="student-file-import">
|
||||
<input
|
||||
ref="studentImportInput"
|
||||
class="file-input"
|
||||
type="file"
|
||||
accept=".xlsx"
|
||||
@change="handleStudentImportFile"
|
||||
/>
|
||||
<span class="file-name">{{
|
||||
studentImportFile?.name || "未选择文件"
|
||||
}}</span>
|
||||
</div>
|
||||
<div class="actions compact">
|
||||
<el-button @click="api.downloadStudentTemplate()">下载模板</el-button>
|
||||
<el-button type="primary" @click="importStudents"
|
||||
>导入 Excel</el-button
|
||||
>
|
||||
<span v-if="studentImportResult" class="import-result"
|
||||
>新增 {{ studentImportResult.created }},更新
|
||||
{{ studentImportResult.updated }},失败
|
||||
{{ studentImportResult.failed }}</span
|
||||
>
|
||||
</div>
|
||||
<div
|
||||
v-if="studentImportResult?.failures.length"
|
||||
class="import-failures"
|
||||
>
|
||||
<span
|
||||
v-for="failure in studentImportResult.failures.slice(0, 5)"
|
||||
:key="`${failure.row}-${failure.phone}`"
|
||||
>第 {{ failure.row }} 行 {{ failure.phone || "-" }}:{{
|
||||
failure.reason
|
||||
}}</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<section class="user-list-toolbar">
|
||||
<el-input
|
||||
v-model="keyword"
|
||||
placeholder="手机号或姓名"
|
||||
clearable
|
||||
@keyup.enter="loadUsers(1)"
|
||||
/>
|
||||
<el-select v-model="filters.planId" placeholder="全部权益版本" clearable>
|
||||
<el-option
|
||||
v-for="plan in entitlementPlans.filter((item) => item.status === 1)"
|
||||
:key="plan.id"
|
||||
:label="plan.name"
|
||||
:value="plan.id"
|
||||
/>
|
||||
</el-select>
|
||||
<el-select v-model="filters.status" aria-label="权益状态">
|
||||
<el-option label="全部权益状态" value="all" /><el-option
|
||||
label="生效中"
|
||||
value="active"
|
||||
/>
|
||||
<el-option label="7 天内到期" value="expiring7" /><el-option
|
||||
label="8-30 天内到期"
|
||||
value="expiring30"
|
||||
/>
|
||||
<el-option label="已到期" value="expired" /><el-option
|
||||
label="使用默认权益"
|
||||
value="default"
|
||||
/>
|
||||
</el-select>
|
||||
<el-button type="primary" @click="loadUsers(1)">筛选</el-button>
|
||||
<el-button @click="resetFilters">重置</el-button>
|
||||
<el-button
|
||||
class="batch-renew-button"
|
||||
:disabled="!selectedUsers.length"
|
||||
@click="openRenewDialog(selectedUsers)"
|
||||
>批量续期{{
|
||||
selectedUsers.length ? `(${selectedUsers.length})` : ""
|
||||
}}</el-button
|
||||
>
|
||||
</section>
|
||||
<el-table :data="users" stripe @selection-change="handleSelectionChange">
|
||||
<el-table-column type="selection" width="48" /><el-table-column
|
||||
prop="id"
|
||||
label="ID"
|
||||
width="80"
|
||||
/>
|
||||
<el-table-column
|
||||
prop="phone"
|
||||
label="手机号"
|
||||
width="150"
|
||||
/><el-table-column prop="name" label="姓名" width="140" />
|
||||
<el-table-column prop="nickname" label="昵称" width="140" />
|
||||
<el-table-column label="注册来源" width="130">
|
||||
<template #default="{ row }">
|
||||
<el-tag v-if="row.registrationSource === 'sso'" type="info">第三方接入</el-tag>
|
||||
<el-tag v-else>直接学员</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="状态" width="130"
|
||||
><template #default="{ row }"
|
||||
><el-switch
|
||||
v-model="row.status"
|
||||
:active-value="1"
|
||||
:inactive-value="0" /></template
|
||||
></el-table-column>
|
||||
<el-table-column label="每日额度" width="150"
|
||||
><template #default="{ row }"
|
||||
><el-input-number
|
||||
v-model="row.dailyChatLimit"
|
||||
:min="0"
|
||||
size="small" /></template
|
||||
></el-table-column>
|
||||
<el-table-column prop="dailyChatUsed" label="已用" width="90" />
|
||||
<el-table-column label="权益版本" min-width="300">
|
||||
<template #default="{ row }">
|
||||
<div class="user-entitlement-cell">
|
||||
<el-select
|
||||
:model-value="row.entitlement?.planId"
|
||||
placeholder="默认基础版"
|
||||
size="small"
|
||||
@change="(planId: number) => assignUserEntitlement(row, planId)"
|
||||
>
|
||||
<el-option
|
||||
v-for="plan in entitlementPlans.filter(
|
||||
(item) => item.status === 1,
|
||||
)"
|
||||
:key="plan.id"
|
||||
:label="plan.name"
|
||||
:value="plan.id"
|
||||
/>
|
||||
</el-select>
|
||||
<small
|
||||
>{{ row.entitlement?.name || "默认基础版" }} ·
|
||||
{{
|
||||
row.entitlement?.lifecycleStatus === "expired_fallback"
|
||||
? `原 ${row.entitlement?.previousPlanName || "权益"} ${formatDate(row.entitlement?.previousExpiredAt, "长期")} 到期`
|
||||
: `有效至 ${formatDate(row.entitlement?.expiredAt, "长期")}`
|
||||
}}</small
|
||||
>
|
||||
<div class="entitlement-meta-row">
|
||||
<el-tag size="small" :type="entitlementStatusType(row)">{{
|
||||
entitlementStatusLabel(row)
|
||||
}}</el-tag>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="最近登录" width="180"><template #default="{ row }">{{ formatDateTime(row.lastLoginAt) }}</template></el-table-column>
|
||||
<el-table-column label="操作" width="210" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<TableRowActions>
|
||||
<el-button size="small" @click="openUserDetail(row)"
|
||||
>详情</el-button
|
||||
>
|
||||
<el-button size="small" type="primary" @click="saveUser(row)"
|
||||
>保存</el-button
|
||||
>
|
||||
<el-dropdown trigger="click"
|
||||
><el-button size="small">更多</el-button
|
||||
><template #dropdown
|
||||
><el-dropdown-menu
|
||||
><el-dropdown-item @click="openRenewDialog([row])"
|
||||
>权益续期</el-dropdown-item
|
||||
><el-dropdown-item @click="deleteUser(row)"
|
||||
>删除用户</el-dropdown-item
|
||||
></el-dropdown-menu
|
||||
></template
|
||||
></el-dropdown
|
||||
>
|
||||
</TableRowActions>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<AdminPagination
|
||||
:page="pager.page"
|
||||
:page-size="pager.pageSize"
|
||||
:total="pager.total"
|
||||
@change="loadUsers"
|
||||
/>
|
||||
<UserEntitlementRenewDialog
|
||||
v-model="renewDialogOpen"
|
||||
:users="renewTargets"
|
||||
:submitting="renewSubmitting"
|
||||
@submit="submitEntitlementRenew"
|
||||
/>
|
||||
<UserDetailDrawer v-model="detailOpen" :user-id="detailUserId" />
|
||||
</section>
|
||||
</template>
|
||||
@@ -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);
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 }>;
|
||||
}
|
||||
|
||||
81
ai_knowledge_base_v2/apps/admin-web/src/utils/dateTime.ts
Normal file
81
ai_knowledge_base_v2/apps/admin-web/src/utils/dateTime.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
export const BUSINESS_TIME_ZONE = "Asia/Shanghai";
|
||||
const BUSINESS_OFFSET = "+08:00";
|
||||
const DATE_ONLY_PATTERN = /^\d{4}-\d{2}-\d{2}$/;
|
||||
const EXPLICIT_OFFSET_PATTERN = /(?:Z|[+-]\d{2}:?\d{2})$/i;
|
||||
|
||||
export function parseApiDateTime(value?: string | Date | null): Date | null {
|
||||
if (!value) return null;
|
||||
if (value instanceof Date) {
|
||||
return Number.isNaN(value.getTime()) ? null : value;
|
||||
}
|
||||
const text = value.trim();
|
||||
if (!text) return null;
|
||||
const isoText = text.includes(" ") ? text.replace(" ", "T") : text;
|
||||
const normalized = DATE_ONLY_PATTERN.test(isoText)
|
||||
? `${isoText}T00:00:00${BUSINESS_OFFSET}`
|
||||
: EXPLICIT_OFFSET_PATTERN.test(isoText)
|
||||
? isoText
|
||||
: `${isoText}Z`;
|
||||
const parsed = new Date(normalized);
|
||||
return Number.isNaN(parsed.getTime()) ? null : parsed;
|
||||
}
|
||||
|
||||
function businessParts(value?: string | Date | null) {
|
||||
const parsed = parseApiDateTime(value);
|
||||
if (!parsed) return null;
|
||||
const parts = new Intl.DateTimeFormat("zh-CN", {
|
||||
timeZone: BUSINESS_TIME_ZONE,
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
hour12: false,
|
||||
hourCycle: "h23",
|
||||
}).formatToParts(parsed);
|
||||
return Object.fromEntries(parts.map((part) => [part.type, part.value]));
|
||||
}
|
||||
|
||||
export function formatDateTime(
|
||||
value?: string | Date | null,
|
||||
fallback = "-",
|
||||
): string {
|
||||
const parts = businessParts(value);
|
||||
if (!parts) return fallback;
|
||||
return `${parts.year}-${parts.month}-${parts.day} ${parts.hour}:${parts.minute}:${parts.second}`;
|
||||
}
|
||||
|
||||
export function formatDate(
|
||||
value?: string | Date | null,
|
||||
fallback = "-",
|
||||
): string {
|
||||
const parts = businessParts(value);
|
||||
if (!parts) return fallback;
|
||||
return `${parts.year}-${parts.month}-${parts.day}`;
|
||||
}
|
||||
|
||||
export function formatTime(
|
||||
value?: string | Date | null,
|
||||
fallback = "-",
|
||||
): string {
|
||||
const parts = businessParts(value);
|
||||
if (!parts) return fallback;
|
||||
return `${parts.hour}:${parts.minute}`;
|
||||
}
|
||||
|
||||
export function toUtcIsoFromBusinessDate(
|
||||
value: string,
|
||||
boundary: "start" | "end" = "start",
|
||||
): string | undefined {
|
||||
if (!DATE_ONLY_PATTERN.test(value)) return undefined;
|
||||
const localTime = boundary === "start" ? "00:00:00.000" : "23:59:59.999";
|
||||
return new Date(`${value}T${localTime}${BUSINESS_OFFSET}`).toISOString();
|
||||
}
|
||||
|
||||
export function toUtcIsoFromBusinessDateTime(value: string): string | undefined {
|
||||
if (!value) return undefined;
|
||||
const normalized = value.length === 16 ? `${value}:00` : value;
|
||||
const parsed = new Date(`${normalized}${BUSINESS_OFFSET}`);
|
||||
return Number.isNaN(parsed.getTime()) ? undefined : parsed.toISOString();
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
"""make knowledge synchronization automatically publish
|
||||
|
||||
Revision ID: 0011_knowledge_auto_publish
|
||||
Revises: 0010_prompt_history_management
|
||||
"""
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = "0011_knowledge_auto_publish"
|
||||
down_revision = "0010_prompt_history_management"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# Keep the legacy column for backward compatibility while removing manual
|
||||
# review from the active workflow and API.
|
||||
op.execute("UPDATE sys_knowledge SET review_mode = 'automatic'")
|
||||
op.alter_column(
|
||||
"sys_knowledge",
|
||||
"review_mode",
|
||||
existing_type=sa.String(length=30),
|
||||
existing_nullable=False,
|
||||
server_default="automatic",
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.alter_column(
|
||||
"sys_knowledge",
|
||||
"review_mode",
|
||||
existing_type=sa.String(length=30),
|
||||
existing_nullable=False,
|
||||
server_default="manual",
|
||||
)
|
||||
@@ -0,0 +1,55 @@
|
||||
"""keep only the latest knowledge version
|
||||
|
||||
Revision ID: 0012_knowledge_latest_only
|
||||
Revises: 0011_knowledge_auto_publish
|
||||
"""
|
||||
|
||||
from alembic import op
|
||||
|
||||
|
||||
revision = "0012_knowledge_latest_only"
|
||||
down_revision = "0011_knowledge_auto_publish"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.execute("UPDATE sys_knowledge SET pending_version_id = NULL")
|
||||
for table in (
|
||||
"sys_knowledge_review_record",
|
||||
"sys_knowledge_card",
|
||||
"sys_knowledge_chunk",
|
||||
"sys_knowledge_section",
|
||||
"sys_knowledge_manifest",
|
||||
):
|
||||
op.execute(
|
||||
f"""
|
||||
DELETE detail
|
||||
FROM {table} AS detail
|
||||
JOIN sys_knowledge AS knowledge ON knowledge.id = detail.knowledge_id
|
||||
WHERE knowledge.current_version_id IS NULL
|
||||
OR detail.version_id <> knowledge.current_version_id
|
||||
"""
|
||||
)
|
||||
op.execute(
|
||||
"""
|
||||
DELETE version
|
||||
FROM sys_knowledge_version AS version
|
||||
JOIN sys_knowledge AS knowledge ON knowledge.id = version.knowledge_id
|
||||
WHERE knowledge.current_version_id IS NULL
|
||||
OR version.id <> knowledge.current_version_id
|
||||
"""
|
||||
)
|
||||
op.execute(
|
||||
"""
|
||||
DELETE snapshot
|
||||
FROM sys_knowledge_source_snapshot AS snapshot
|
||||
LEFT JOIN sys_knowledge_version AS version ON version.snapshot_id = snapshot.id
|
||||
WHERE version.id IS NULL
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# Removed historical content cannot be reconstructed from the latest version.
|
||||
pass
|
||||
@@ -0,0 +1,34 @@
|
||||
"""add question insight message index
|
||||
|
||||
Revision ID: 0013_question_insight_indexes
|
||||
Revises: 0012_knowledge_latest_only
|
||||
"""
|
||||
|
||||
from alembic import context, op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = "0013_question_insight_indexes"
|
||||
down_revision = "0012_knowledge_latest_only"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
_create_index_if_missing("sys_chat_message", "ix_chat_message_role_created", ["role", "created_at"])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
indexes = {"ix_chat_message_role_created"} if context.is_offline_mode() else {
|
||||
item["name"] for item in sa.inspect(op.get_bind()).get_indexes("sys_chat_message")
|
||||
}
|
||||
if "ix_chat_message_role_created" in indexes:
|
||||
op.drop_index("ix_chat_message_role_created", table_name="sys_chat_message")
|
||||
|
||||
|
||||
def _create_index_if_missing(table: str, name: str, columns: list[str]) -> None:
|
||||
indexes = set() if context.is_offline_mode() else {
|
||||
item["name"] for item in sa.inspect(op.get_bind()).get_indexes(table)
|
||||
}
|
||||
if name not in indexes:
|
||||
op.create_index(name, table, columns)
|
||||
@@ -0,0 +1,217 @@
|
||||
"""add entitlement plans and topic sessions
|
||||
|
||||
Revision ID: 0014_entitlements_topics
|
||||
Revises: 0013_question_insight_indexes
|
||||
"""
|
||||
|
||||
from alembic import context, op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = "0014_entitlements_topics"
|
||||
down_revision = "0013_question_insight_indexes"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
PRIMARY_KEY_TYPE = sa.BigInteger().with_variant(sa.Integer(), "sqlite")
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
inspector = None if context.is_offline_mode() else sa.inspect(op.get_bind())
|
||||
tables = set() if inspector is None else set(inspector.get_table_names())
|
||||
if "sys_entitlement_plan" not in tables:
|
||||
op.create_table(
|
||||
"sys_entitlement_plan",
|
||||
sa.Column("id", PRIMARY_KEY_TYPE, primary_key=True, autoincrement=True),
|
||||
sa.Column("name", sa.String(80), nullable=False),
|
||||
sa.Column("plan_type", sa.String(30), nullable=False),
|
||||
sa.Column("description", sa.String(255), nullable=True),
|
||||
sa.Column("validity_days", sa.Integer(), nullable=True),
|
||||
sa.Column("monthly_topic_limit", sa.Integer(), nullable=True),
|
||||
sa.Column("enable_growth_profile", sa.Integer(), nullable=False, server_default="0"),
|
||||
sa.Column("enable_periodic_reports", sa.Integer(), nullable=False, server_default="0"),
|
||||
sa.Column("allow_help_card", sa.Integer(), nullable=False, server_default="1"),
|
||||
sa.Column("allow_share_draft", sa.Integer(), nullable=False, server_default="1"),
|
||||
sa.Column("deduct_quota", sa.Integer(), nullable=False, server_default="1"),
|
||||
sa.Column("status", sa.Integer(), nullable=False, server_default="1"),
|
||||
sa.Column("sort_order", sa.Integer(), nullable=False, server_default="0"),
|
||||
sa.Column("created_at", sa.DateTime(), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(), server_default=sa.func.now(), nullable=False),
|
||||
)
|
||||
op.create_index("ix_sys_entitlement_plan_plan_type", "sys_entitlement_plan", ["plan_type"])
|
||||
op.create_index("ix_sys_entitlement_plan_status", "sys_entitlement_plan", ["status"])
|
||||
_seed_default_plans()
|
||||
|
||||
if "sys_user_entitlement" not in tables:
|
||||
op.create_table(
|
||||
"sys_user_entitlement",
|
||||
sa.Column("id", PRIMARY_KEY_TYPE, primary_key=True, autoincrement=True),
|
||||
sa.Column("user_id", sa.BigInteger(), sa.ForeignKey("sys_user.id"), nullable=False),
|
||||
sa.Column("plan_id", sa.BigInteger(), sa.ForeignKey("sys_entitlement_plan.id"), nullable=False),
|
||||
sa.Column("status", sa.String(20), nullable=False, server_default="active"),
|
||||
sa.Column("effective_at", sa.DateTime(), nullable=True),
|
||||
sa.Column("expired_at", sa.DateTime(), nullable=True),
|
||||
sa.Column("assigned_by", sa.BigInteger(), nullable=True),
|
||||
sa.Column("remark", sa.String(255), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(), server_default=sa.func.now(), nullable=False),
|
||||
)
|
||||
op.create_index("ix_sys_user_entitlement_user_id", "sys_user_entitlement", ["user_id"])
|
||||
op.create_index("ix_sys_user_entitlement_plan_id", "sys_user_entitlement", ["plan_id"])
|
||||
op.create_index("ix_sys_user_entitlement_status", "sys_user_entitlement", ["status"])
|
||||
|
||||
if "sys_user_entitlement_log" not in tables:
|
||||
op.create_table(
|
||||
"sys_user_entitlement_log",
|
||||
sa.Column("id", PRIMARY_KEY_TYPE, primary_key=True, autoincrement=True),
|
||||
sa.Column("user_id", sa.BigInteger(), nullable=False),
|
||||
sa.Column("entitlement_id", sa.BigInteger(), nullable=True),
|
||||
sa.Column("from_plan_id", sa.BigInteger(), nullable=True),
|
||||
sa.Column("to_plan_id", sa.BigInteger(), nullable=True),
|
||||
sa.Column("action", sa.String(30), nullable=False),
|
||||
sa.Column("detail_json", sa.Text(), nullable=True),
|
||||
sa.Column("operated_by", sa.BigInteger(), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(), nullable=False),
|
||||
)
|
||||
op.create_index("ix_sys_user_entitlement_log_user_id", "sys_user_entitlement_log", ["user_id"])
|
||||
|
||||
if "sys_topic_session" not in tables:
|
||||
op.create_table(
|
||||
"sys_topic_session",
|
||||
sa.Column("id", PRIMARY_KEY_TYPE, primary_key=True, autoincrement=True),
|
||||
sa.Column("user_id", sa.BigInteger(), sa.ForeignKey("sys_user.id"), nullable=False),
|
||||
sa.Column("chat_session_id", sa.BigInteger(), sa.ForeignKey("sys_chat_session.id"), nullable=False),
|
||||
sa.Column("title", sa.String(120), nullable=False),
|
||||
sa.Column("core_question", sa.Text(), nullable=False),
|
||||
sa.Column("status", sa.String(20), nullable=False, server_default="active"),
|
||||
sa.Column("message_count", sa.Integer(), nullable=False, server_default="0"),
|
||||
sa.Column("token_input", sa.Integer(), nullable=False, server_default="0"),
|
||||
sa.Column("token_output", sa.Integer(), nullable=False, server_default="0"),
|
||||
sa.Column("quota_deducted", sa.Integer(), nullable=False, server_default="0"),
|
||||
sa.Column("recommended_homework", sa.Text(), nullable=True),
|
||||
sa.Column("help_card_generated", sa.Integer(), nullable=False, server_default="0"),
|
||||
sa.Column("share_draft_generated", sa.Integer(), nullable=False, server_default="0"),
|
||||
sa.Column("started_at", sa.DateTime(), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column("ended_at", sa.DateTime(), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(), server_default=sa.func.now(), nullable=False),
|
||||
)
|
||||
op.create_index("ix_sys_topic_session_user_id", "sys_topic_session", ["user_id"])
|
||||
op.create_index("ix_sys_topic_session_chat_session_id", "sys_topic_session", ["chat_session_id"])
|
||||
op.create_index("ix_sys_topic_session_status", "sys_topic_session", ["status"])
|
||||
|
||||
columns = set() if inspector is None else {column["name"] for column in inspector.get_columns("sys_chat_message")}
|
||||
if "topic_session_id" not in columns:
|
||||
op.add_column("sys_chat_message", sa.Column("topic_session_id", sa.BigInteger(), nullable=True))
|
||||
op.create_index("ix_sys_chat_message_topic_session_id", "sys_chat_message", ["topic_session_id"])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
inspector = None if context.is_offline_mode() else sa.inspect(op.get_bind())
|
||||
columns = {"topic_session_id"} if inspector is None else {
|
||||
column["name"] for column in inspector.get_columns("sys_chat_message")
|
||||
}
|
||||
if "topic_session_id" in columns:
|
||||
op.drop_index("ix_sys_chat_message_topic_session_id", table_name="sys_chat_message")
|
||||
op.drop_column("sys_chat_message", "topic_session_id")
|
||||
tables = {
|
||||
"sys_topic_session",
|
||||
"sys_user_entitlement_log",
|
||||
"sys_user_entitlement",
|
||||
"sys_entitlement_plan",
|
||||
} if inspector is None else set(inspector.get_table_names())
|
||||
if "sys_topic_session" in tables:
|
||||
op.drop_index("ix_sys_topic_session_status", table_name="sys_topic_session")
|
||||
op.drop_index("ix_sys_topic_session_chat_session_id", table_name="sys_topic_session")
|
||||
op.drop_index("ix_sys_topic_session_user_id", table_name="sys_topic_session")
|
||||
op.drop_table("sys_topic_session")
|
||||
if "sys_user_entitlement_log" in tables:
|
||||
op.drop_index("ix_sys_user_entitlement_log_user_id", table_name="sys_user_entitlement_log")
|
||||
op.drop_table("sys_user_entitlement_log")
|
||||
if "sys_user_entitlement" in tables:
|
||||
op.drop_index("ix_sys_user_entitlement_status", table_name="sys_user_entitlement")
|
||||
op.drop_index("ix_sys_user_entitlement_plan_id", table_name="sys_user_entitlement")
|
||||
op.drop_index("ix_sys_user_entitlement_user_id", table_name="sys_user_entitlement")
|
||||
op.drop_table("sys_user_entitlement")
|
||||
if "sys_entitlement_plan" in tables:
|
||||
op.drop_index("ix_sys_entitlement_plan_status", table_name="sys_entitlement_plan")
|
||||
op.drop_index("ix_sys_entitlement_plan_plan_type", table_name="sys_entitlement_plan")
|
||||
op.drop_table("sys_entitlement_plan")
|
||||
|
||||
|
||||
def _seed_default_plans() -> None:
|
||||
op.bulk_insert(
|
||||
sa.table(
|
||||
"sys_entitlement_plan",
|
||||
sa.column("name", sa.String(80)),
|
||||
sa.column("plan_type", sa.String(30)),
|
||||
sa.column("description", sa.String(255)),
|
||||
sa.column("validity_days", sa.Integer()),
|
||||
sa.column("monthly_topic_limit", sa.Integer()),
|
||||
sa.column("enable_growth_profile", sa.Integer()),
|
||||
sa.column("enable_periodic_reports", sa.Integer()),
|
||||
sa.column("allow_help_card", sa.Integer()),
|
||||
sa.column("allow_share_draft", sa.Integer()),
|
||||
sa.column("deduct_quota", sa.Integer()),
|
||||
sa.column("status", sa.Integer()),
|
||||
sa.column("sort_order", sa.Integer()),
|
||||
),
|
||||
[
|
||||
{
|
||||
"name": "大本营基础版",
|
||||
"plan_type": "basic",
|
||||
"description": "随大本营提供,支持基础知识查询、功课方向和求助卡生成。",
|
||||
"validity_days": None,
|
||||
"monthly_topic_limit": 30,
|
||||
"enable_growth_profile": 0,
|
||||
"enable_periodic_reports": 0,
|
||||
"allow_help_card": 1,
|
||||
"allow_share_draft": 1,
|
||||
"deduct_quota": 1,
|
||||
"status": 1,
|
||||
"sort_order": 10,
|
||||
},
|
||||
{
|
||||
"name": "五个月深度陪伴版",
|
||||
"plan_type": "deep",
|
||||
"description": "支持近期实修回顾、周期实修回顾和更高主题会话额度。",
|
||||
"validity_days": 150,
|
||||
"monthly_topic_limit": 90,
|
||||
"enable_growth_profile": 1,
|
||||
"enable_periodic_reports": 1,
|
||||
"allow_help_card": 1,
|
||||
"allow_share_draft": 1,
|
||||
"deduct_quota": 1,
|
||||
"status": 1,
|
||||
"sort_order": 20,
|
||||
},
|
||||
{
|
||||
"name": "高频加购包",
|
||||
"plan_type": "addon",
|
||||
"description": "用于少量高频用户补充主题会话额度。",
|
||||
"validity_days": 31,
|
||||
"monthly_topic_limit": 30,
|
||||
"enable_growth_profile": 0,
|
||||
"enable_periodic_reports": 0,
|
||||
"allow_help_card": 1,
|
||||
"allow_share_draft": 1,
|
||||
"deduct_quota": 1,
|
||||
"status": 1,
|
||||
"sort_order": 30,
|
||||
},
|
||||
{
|
||||
"name": "老师工作版",
|
||||
"plan_type": "teacher",
|
||||
"description": "内部老师使用,不消耗普通学员权益额度。",
|
||||
"validity_days": None,
|
||||
"monthly_topic_limit": None,
|
||||
"enable_growth_profile": 0,
|
||||
"enable_periodic_reports": 0,
|
||||
"allow_help_card": 1,
|
||||
"allow_share_draft": 1,
|
||||
"deduct_quota": 0,
|
||||
"status": 1,
|
||||
"sort_order": 40,
|
||||
},
|
||||
],
|
||||
)
|
||||
@@ -0,0 +1,106 @@
|
||||
"""add topic summaries and growth profiles
|
||||
|
||||
Revision ID: 0015_growth_profiles
|
||||
Revises: 0014_entitlements_topics
|
||||
"""
|
||||
|
||||
from alembic import context, op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = "0015_growth_profiles"
|
||||
down_revision = "0014_entitlements_topics"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
PRIMARY_KEY_TYPE = sa.BigInteger().with_variant(sa.Integer(), "sqlite")
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
inspector = None if context.is_offline_mode() else sa.inspect(op.get_bind())
|
||||
tables = set() if inspector is None else set(inspector.get_table_names())
|
||||
|
||||
if "sys_topic_summary" not in tables:
|
||||
op.create_table(
|
||||
"sys_topic_summary",
|
||||
sa.Column("id", PRIMARY_KEY_TYPE, primary_key=True, autoincrement=True),
|
||||
sa.Column("topic_session_id", sa.BigInteger(), sa.ForeignKey("sys_topic_session.id"), nullable=False),
|
||||
sa.Column("user_id", sa.BigInteger(), sa.ForeignKey("sys_user.id"), nullable=False),
|
||||
sa.Column("summary", sa.Text(), nullable=False),
|
||||
sa.Column("main_events", sa.Text(), nullable=True),
|
||||
sa.Column("emotions", sa.Text(), nullable=True),
|
||||
sa.Column("body_feelings", sa.Text(), nullable=True),
|
||||
sa.Column("beliefs", sa.Text(), nullable=True),
|
||||
sa.Column("recommended_homework", sa.Text(), nullable=True),
|
||||
sa.Column("insights", sa.Text(), nullable=True),
|
||||
sa.Column("next_observation", sa.Text(), nullable=True),
|
||||
sa.Column("model_name", sa.String(100), nullable=True),
|
||||
sa.Column("status", sa.String(20), nullable=False, server_default="success"),
|
||||
sa.Column("error_message", sa.Text(), nullable=True),
|
||||
sa.Column("generated_at", sa.DateTime(), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(), server_default=sa.func.now(), nullable=False),
|
||||
sa.UniqueConstraint("topic_session_id", name="uq_sys_topic_summary_topic"),
|
||||
)
|
||||
op.create_index("ix_sys_topic_summary_topic_session_id", "sys_topic_summary", ["topic_session_id"])
|
||||
op.create_index("ix_sys_topic_summary_user_id", "sys_topic_summary", ["user_id"])
|
||||
op.create_index("ix_sys_topic_summary_status", "sys_topic_summary", ["status"])
|
||||
|
||||
if "sys_user_growth_profile" not in tables:
|
||||
op.create_table(
|
||||
"sys_user_growth_profile",
|
||||
sa.Column("id", PRIMARY_KEY_TYPE, primary_key=True, autoincrement=True),
|
||||
sa.Column("user_id", sa.BigInteger(), sa.ForeignKey("sys_user.id"), nullable=False),
|
||||
sa.Column("profile_text", sa.Text(), nullable=False),
|
||||
sa.Column("recurring_topics", sa.Text(), nullable=True),
|
||||
sa.Column("common_emotions", sa.Text(), nullable=True),
|
||||
sa.Column("body_patterns", sa.Text(), nullable=True),
|
||||
sa.Column("relation_patterns", sa.Text(), nullable=True),
|
||||
sa.Column("homework_done", sa.Text(), nullable=True),
|
||||
sa.Column("effective_homework", sa.Text(), nullable=True),
|
||||
sa.Column("recent_progress", sa.Text(), nullable=True),
|
||||
sa.Column("last_topic_summary_id", sa.BigInteger(), nullable=True),
|
||||
sa.Column("updated_by", sa.String(30), nullable=False, server_default="system"),
|
||||
sa.Column("created_at", sa.DateTime(), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(), server_default=sa.func.now(), nullable=False),
|
||||
sa.UniqueConstraint("user_id", name="uq_sys_user_growth_profile_user"),
|
||||
)
|
||||
op.create_index("ix_sys_user_growth_profile_user_id", "sys_user_growth_profile", ["user_id"])
|
||||
|
||||
if "sys_growth_profile_revision" not in tables:
|
||||
op.create_table(
|
||||
"sys_growth_profile_revision",
|
||||
sa.Column("id", PRIMARY_KEY_TYPE, primary_key=True, autoincrement=True),
|
||||
sa.Column("user_id", sa.BigInteger(), sa.ForeignKey("sys_user.id"), nullable=False),
|
||||
sa.Column("profile_id", sa.BigInteger(), sa.ForeignKey("sys_user_growth_profile.id"), nullable=False),
|
||||
sa.Column("topic_summary_id", sa.BigInteger(), nullable=True),
|
||||
sa.Column("before_json", sa.Text(), nullable=True),
|
||||
sa.Column("after_json", sa.Text(), nullable=False),
|
||||
sa.Column("reason", sa.String(50), nullable=False, server_default="topic_summary"),
|
||||
sa.Column("created_at", sa.DateTime(), server_default=sa.func.now(), nullable=False),
|
||||
)
|
||||
op.create_index("ix_sys_growth_profile_revision_user_id", "sys_growth_profile_revision", ["user_id"])
|
||||
op.create_index("ix_sys_growth_profile_revision_profile_id", "sys_growth_profile_revision", ["profile_id"])
|
||||
op.create_index("ix_sys_growth_profile_revision_topic_summary_id", "sys_growth_profile_revision", ["topic_summary_id"])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
inspector = None if context.is_offline_mode() else sa.inspect(op.get_bind())
|
||||
tables = {
|
||||
"sys_growth_profile_revision",
|
||||
"sys_user_growth_profile",
|
||||
"sys_topic_summary",
|
||||
} if inspector is None else set(inspector.get_table_names())
|
||||
if "sys_growth_profile_revision" in tables:
|
||||
op.drop_index("ix_sys_growth_profile_revision_topic_summary_id", table_name="sys_growth_profile_revision")
|
||||
op.drop_index("ix_sys_growth_profile_revision_profile_id", table_name="sys_growth_profile_revision")
|
||||
op.drop_index("ix_sys_growth_profile_revision_user_id", table_name="sys_growth_profile_revision")
|
||||
op.drop_table("sys_growth_profile_revision")
|
||||
if "sys_user_growth_profile" in tables:
|
||||
op.drop_index("ix_sys_user_growth_profile_user_id", table_name="sys_user_growth_profile")
|
||||
op.drop_table("sys_user_growth_profile")
|
||||
if "sys_topic_summary" in tables:
|
||||
op.drop_index("ix_sys_topic_summary_status", table_name="sys_topic_summary")
|
||||
op.drop_index("ix_sys_topic_summary_user_id", table_name="sys_topic_summary")
|
||||
op.drop_index("ix_sys_topic_summary_topic_session_id", table_name="sys_topic_summary")
|
||||
op.drop_table("sys_topic_summary")
|
||||
@@ -0,0 +1,51 @@
|
||||
"""add teacher help cards
|
||||
|
||||
Revision ID: 0016_teacher_help_cards
|
||||
Revises: 0015_growth_profiles
|
||||
"""
|
||||
|
||||
from alembic import context, op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = "0016_teacher_help_cards"
|
||||
down_revision = "0015_growth_profiles"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
PRIMARY_KEY_TYPE = sa.BigInteger().with_variant(sa.Integer(), "sqlite")
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
inspector = None if context.is_offline_mode() else sa.inspect(op.get_bind())
|
||||
tables = set() if inspector is None else set(inspector.get_table_names())
|
||||
if "sys_teacher_help_card" in tables:
|
||||
return
|
||||
op.create_table(
|
||||
"sys_teacher_help_card",
|
||||
sa.Column("id", PRIMARY_KEY_TYPE, primary_key=True, autoincrement=True),
|
||||
sa.Column("user_id", sa.BigInteger(), sa.ForeignKey("sys_user.id"), nullable=False),
|
||||
sa.Column("topic_session_id", sa.BigInteger(), sa.ForeignKey("sys_topic_session.id"), nullable=False),
|
||||
sa.Column("summary_id", sa.BigInteger(), nullable=True),
|
||||
sa.Column("content", sa.Text(), nullable=False),
|
||||
sa.Column("source", sa.String(30), nullable=False, server_default="topic_summary"),
|
||||
sa.Column("copied", sa.Integer(), nullable=False, server_default="0"),
|
||||
sa.Column("copied_at", sa.DateTime(), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(), server_default=sa.func.now(), nullable=False),
|
||||
)
|
||||
op.create_index("ix_sys_teacher_help_card_user_id", "sys_teacher_help_card", ["user_id"])
|
||||
op.create_index("ix_sys_teacher_help_card_summary_id", "sys_teacher_help_card", ["summary_id"])
|
||||
op.create_index("ix_sys_teacher_help_card_user_created", "sys_teacher_help_card", ["user_id", "created_at"])
|
||||
op.create_index("ix_sys_teacher_help_card_topic_created", "sys_teacher_help_card", ["topic_session_id", "created_at"])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
inspector = None if context.is_offline_mode() else sa.inspect(op.get_bind())
|
||||
tables = {"sys_teacher_help_card"} if inspector is None else set(inspector.get_table_names())
|
||||
if "sys_teacher_help_card" not in tables:
|
||||
return
|
||||
op.drop_index("ix_sys_teacher_help_card_topic_created", table_name="sys_teacher_help_card")
|
||||
op.drop_index("ix_sys_teacher_help_card_user_created", table_name="sys_teacher_help_card")
|
||||
op.drop_index("ix_sys_teacher_help_card_summary_id", table_name="sys_teacher_help_card")
|
||||
op.drop_index("ix_sys_teacher_help_card_user_id", table_name="sys_teacher_help_card")
|
||||
op.drop_table("sys_teacher_help_card")
|
||||
@@ -0,0 +1,51 @@
|
||||
"""add share drafts
|
||||
|
||||
Revision ID: 0017_share_drafts
|
||||
Revises: 0016_teacher_help_cards
|
||||
"""
|
||||
|
||||
from alembic import context, op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = "0017_share_drafts"
|
||||
down_revision = "0016_teacher_help_cards"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
PRIMARY_KEY_TYPE = sa.BigInteger().with_variant(sa.Integer(), "sqlite")
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
inspector = None if context.is_offline_mode() else sa.inspect(op.get_bind())
|
||||
tables = set() if inspector is None else set(inspector.get_table_names())
|
||||
if "sys_share_draft" in tables:
|
||||
return
|
||||
op.create_table(
|
||||
"sys_share_draft",
|
||||
sa.Column("id", PRIMARY_KEY_TYPE, primary_key=True, autoincrement=True),
|
||||
sa.Column("user_id", sa.BigInteger(), sa.ForeignKey("sys_user.id"), nullable=False),
|
||||
sa.Column("topic_session_id", sa.BigInteger(), sa.ForeignKey("sys_topic_session.id"), nullable=False),
|
||||
sa.Column("summary_id", sa.BigInteger(), nullable=True),
|
||||
sa.Column("content", sa.Text(), nullable=False),
|
||||
sa.Column("source", sa.String(30), nullable=False, server_default="topic_summary"),
|
||||
sa.Column("copied", sa.Integer(), nullable=False, server_default="0"),
|
||||
sa.Column("copied_at", sa.DateTime(), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(), server_default=sa.func.now(), nullable=False),
|
||||
)
|
||||
op.create_index("ix_sys_share_draft_user_id", "sys_share_draft", ["user_id"])
|
||||
op.create_index("ix_sys_share_draft_summary_id", "sys_share_draft", ["summary_id"])
|
||||
op.create_index("ix_sys_share_draft_user_created", "sys_share_draft", ["user_id", "created_at"])
|
||||
op.create_index("ix_sys_share_draft_topic_created", "sys_share_draft", ["topic_session_id", "created_at"])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
inspector = None if context.is_offline_mode() else sa.inspect(op.get_bind())
|
||||
tables = {"sys_share_draft"} if inspector is None else set(inspector.get_table_names())
|
||||
if "sys_share_draft" not in tables:
|
||||
return
|
||||
op.drop_index("ix_sys_share_draft_topic_created", table_name="sys_share_draft")
|
||||
op.drop_index("ix_sys_share_draft_user_created", table_name="sys_share_draft")
|
||||
op.drop_index("ix_sys_share_draft_summary_id", table_name="sys_share_draft")
|
||||
op.drop_index("ix_sys_share_draft_user_id", table_name="sys_share_draft")
|
||||
op.drop_table("sys_share_draft")
|
||||
@@ -0,0 +1,71 @@
|
||||
"""add ai cost tracking fields
|
||||
|
||||
Revision ID: 0018_ai_cost_tracking
|
||||
Revises: 0017_share_drafts
|
||||
"""
|
||||
|
||||
from alembic import context, op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = "0018_ai_cost_tracking"
|
||||
down_revision = "0017_share_drafts"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
inspector = None if context.is_offline_mode() else sa.inspect(op.get_bind())
|
||||
model_columns = set() if inspector is None else {column["name"] for column in inspector.get_columns("sys_model")}
|
||||
_add_column_if_missing(model_columns, "sys_model", sa.Column("input_price_per_1k", sa.Numeric(12, 6), nullable=True))
|
||||
_add_column_if_missing(model_columns, "sys_model", sa.Column("output_price_per_1k", sa.Numeric(12, 6), nullable=True))
|
||||
_add_column_if_missing(model_columns, "sys_model", sa.Column("currency", sa.String(10), nullable=False, server_default="CNY"))
|
||||
_add_column_if_missing(model_columns, "sys_model", sa.Column("usage_scenarios", sa.String(255), nullable=True))
|
||||
_add_column_if_missing(model_columns, "sys_model", sa.Column("allow_summary", sa.Integer(), nullable=False, server_default="1"))
|
||||
_add_column_if_missing(model_columns, "sys_model", sa.Column("allow_report", sa.Integer(), nullable=False, server_default="1"))
|
||||
_add_column_if_missing(model_columns, "sys_model", sa.Column("allow_fixed_info", sa.Integer(), nullable=False, server_default="1"))
|
||||
_add_column_if_missing(model_columns, "sys_model", sa.Column("allow_deep_chat", sa.Integer(), nullable=False, server_default="1"))
|
||||
|
||||
log_columns = set() if inspector is None else {column["name"] for column in inspector.get_columns("sys_ai_request_log")}
|
||||
_add_column_if_missing(log_columns, "sys_ai_request_log", sa.Column("model_id", sa.BigInteger(), nullable=True))
|
||||
_add_column_if_missing(log_columns, "sys_ai_request_log", sa.Column("estimated_cost", sa.Numeric(18, 6), nullable=True))
|
||||
_add_column_if_missing(log_columns, "sys_ai_request_log", sa.Column("currency", sa.String(10), nullable=True))
|
||||
_add_column_if_missing(log_columns, "sys_ai_request_log", sa.Column("route_reason", sa.String(255), nullable=True))
|
||||
_add_column_if_missing(log_columns, "sys_ai_request_log", sa.Column("question_type", sa.String(50), nullable=True))
|
||||
_add_column_if_missing(log_columns, "sys_ai_request_log", sa.Column("knowledge_hit", sa.Integer(), nullable=False, server_default="0"))
|
||||
if "model_id" not in log_columns:
|
||||
op.create_index("ix_sys_ai_request_log_model_id", "sys_ai_request_log", ["model_id"])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
inspector = None if context.is_offline_mode() else sa.inspect(op.get_bind())
|
||||
log_columns = {
|
||||
"model_id", "estimated_cost", "currency", "route_reason", "question_type", "knowledge_hit"
|
||||
} if inspector is None else {column["name"] for column in inspector.get_columns("sys_ai_request_log")}
|
||||
if "model_id" in log_columns:
|
||||
op.drop_index("ix_sys_ai_request_log_model_id", table_name="sys_ai_request_log")
|
||||
for column in ["knowledge_hit", "question_type", "route_reason", "currency", "estimated_cost", "model_id"]:
|
||||
if column in log_columns:
|
||||
op.drop_column("sys_ai_request_log", column)
|
||||
|
||||
model_columns = {
|
||||
"input_price_per_1k", "output_price_per_1k", "currency", "usage_scenarios",
|
||||
"allow_summary", "allow_report", "allow_fixed_info", "allow_deep_chat",
|
||||
} if inspector is None else {column["name"] for column in inspector.get_columns("sys_model")}
|
||||
for column in [
|
||||
"allow_deep_chat",
|
||||
"allow_fixed_info",
|
||||
"allow_report",
|
||||
"allow_summary",
|
||||
"usage_scenarios",
|
||||
"currency",
|
||||
"output_price_per_1k",
|
||||
"input_price_per_1k",
|
||||
]:
|
||||
if column in model_columns:
|
||||
op.drop_column("sys_model", column)
|
||||
|
||||
|
||||
def _add_column_if_missing(columns: set[str], table: str, column: sa.Column) -> None:
|
||||
if column.name not in columns:
|
||||
op.add_column(table, column)
|
||||
@@ -0,0 +1,54 @@
|
||||
"""periodic reports
|
||||
|
||||
Revision ID: 0019_periodic_reports
|
||||
Revises: 0018_ai_cost_tracking
|
||||
Create Date: 2026-07-31 00:00:00.000000
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision = "0019_periodic_reports"
|
||||
down_revision = "0018_ai_cost_tracking"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"sys_periodic_report",
|
||||
sa.Column("id", sa.BigInteger().with_variant(sa.Integer(), "sqlite"), primary_key=True, autoincrement=True),
|
||||
sa.Column("user_id", sa.BigInteger().with_variant(sa.Integer(), "sqlite"), nullable=False),
|
||||
sa.Column("report_type", sa.String(length=30), nullable=False),
|
||||
sa.Column("period_start", sa.DateTime(), nullable=False),
|
||||
sa.Column("period_end", sa.DateTime(), nullable=False),
|
||||
sa.Column("title", sa.String(length=160), nullable=False),
|
||||
# MySQL 8.4 rejects literal defaults on TEXT columns. The ORM already
|
||||
# supplies an empty string while a report job is pending.
|
||||
sa.Column("content", sa.Text(), nullable=False),
|
||||
sa.Column("source_summary_ids", sa.Text(), nullable=True),
|
||||
sa.Column("source_topic_ids", sa.Text(), nullable=True),
|
||||
sa.Column("model_name", sa.String(length=100), nullable=True),
|
||||
sa.Column("status", sa.String(length=20), nullable=False, server_default="success"),
|
||||
sa.Column("error_message", sa.Text(), nullable=True),
|
||||
sa.Column("generated_by", sa.String(length=30), nullable=False, server_default="manual"),
|
||||
sa.Column("generated_at", sa.DateTime(), nullable=False, server_default=sa.func.now()),
|
||||
sa.Column("created_at", sa.DateTime(), nullable=False, server_default=sa.func.now()),
|
||||
sa.Column("updated_at", sa.DateTime(), nullable=False, server_default=sa.func.now()),
|
||||
sa.ForeignKeyConstraint(["user_id"], ["sys_user.id"]),
|
||||
sa.UniqueConstraint("user_id", "report_type", "period_start", "period_end", name="uq_sys_periodic_report_period"),
|
||||
)
|
||||
op.create_index("ix_sys_periodic_report_user_id", "sys_periodic_report", ["user_id"])
|
||||
op.create_index("ix_sys_periodic_report_status", "sys_periodic_report", ["status"])
|
||||
op.create_index("ix_sys_periodic_report_user_type_created", "sys_periodic_report", ["user_id", "report_type", "created_at"])
|
||||
op.create_index("ix_sys_periodic_report_status_created", "sys_periodic_report", ["status", "created_at"])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("ix_sys_periodic_report_status_created", table_name="sys_periodic_report")
|
||||
op.drop_index("ix_sys_periodic_report_user_type_created", table_name="sys_periodic_report")
|
||||
op.drop_index("ix_sys_periodic_report_status", table_name="sys_periodic_report")
|
||||
op.drop_index("ix_sys_periodic_report_user_id", table_name="sys_periodic_report")
|
||||
op.drop_table("sys_periodic_report")
|
||||
@@ -0,0 +1,100 @@
|
||||
"""persist cleaned question insights
|
||||
|
||||
Revision ID: 0020_question_insight_store
|
||||
Revises: 0019_periodic_reports
|
||||
Create Date: 2026-07-31 00:00:00.000000
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from alembic import context, op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = "0020_question_insight_store"
|
||||
down_revision = "0019_periodic_reports"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
table_name = "sys_question_insight_cleaned_question"
|
||||
table_exists = False
|
||||
existing_indexes: set[str] = set()
|
||||
if not context.is_offline_mode():
|
||||
inspector = sa.inspect(op.get_bind())
|
||||
table_exists = inspector.has_table(table_name)
|
||||
if table_exists:
|
||||
existing_indexes = {item["name"] for item in inspector.get_indexes(table_name)}
|
||||
|
||||
if not table_exists:
|
||||
op.create_table(
|
||||
table_name,
|
||||
sa.Column(
|
||||
"id",
|
||||
sa.BigInteger().with_variant(sa.Integer(), "sqlite"),
|
||||
primary_key=True,
|
||||
autoincrement=True,
|
||||
),
|
||||
sa.Column("message_id", sa.BigInteger(), nullable=False),
|
||||
sa.Column("session_id", sa.BigInteger(), nullable=False),
|
||||
sa.Column("user_id", sa.BigInteger(), nullable=False),
|
||||
sa.Column("part_index", sa.Integer(), nullable=False),
|
||||
sa.Column("cleaner_version", sa.String(length=20), nullable=False),
|
||||
sa.Column("source_hash", sa.String(length=64), nullable=False),
|
||||
sa.Column("cleaned_text", sa.Text(), nullable=False),
|
||||
sa.Column("normalized_text", sa.Text(), nullable=False),
|
||||
sa.Column("category", sa.String(length=50), nullable=False, server_default="other"),
|
||||
sa.Column("tokens_json", sa.Text(), nullable=False),
|
||||
sa.Column("accepted", sa.Integer(), nullable=False, server_default="1"),
|
||||
sa.Column("filtered_reason", sa.String(length=100), nullable=True),
|
||||
sa.Column("source_created_at", sa.DateTime(), nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(), nullable=False, server_default=sa.func.now()),
|
||||
sa.Column("updated_at", sa.DateTime(), nullable=False, server_default=sa.func.now()),
|
||||
sa.ForeignKeyConstraint(
|
||||
["message_id"],
|
||||
["sys_chat_message.id"],
|
||||
ondelete="CASCADE",
|
||||
),
|
||||
sa.UniqueConstraint(
|
||||
"message_id",
|
||||
"cleaner_version",
|
||||
"part_index",
|
||||
name="uq_question_insight_message_version_part",
|
||||
),
|
||||
)
|
||||
|
||||
indexes = {
|
||||
"ix_sys_question_insight_cleaned_question_session_id": ["session_id"],
|
||||
"ix_sys_question_insight_cleaned_question_user_id": ["user_id"],
|
||||
"ix_question_insight_version_accepted_created": ["cleaner_version", "accepted", "source_created_at"],
|
||||
"ix_question_insight_category_created": ["category", "source_created_at"],
|
||||
"ix_question_insight_session_created": ["session_id", "source_created_at"],
|
||||
}
|
||||
for index_name, columns in indexes.items():
|
||||
if index_name not in existing_indexes:
|
||||
op.create_index(index_name, table_name, columns)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index(
|
||||
"ix_question_insight_session_created",
|
||||
table_name="sys_question_insight_cleaned_question",
|
||||
)
|
||||
op.drop_index(
|
||||
"ix_question_insight_category_created",
|
||||
table_name="sys_question_insight_cleaned_question",
|
||||
)
|
||||
op.drop_index(
|
||||
"ix_question_insight_version_accepted_created",
|
||||
table_name="sys_question_insight_cleaned_question",
|
||||
)
|
||||
op.drop_index(
|
||||
"ix_sys_question_insight_cleaned_question_user_id",
|
||||
table_name="sys_question_insight_cleaned_question",
|
||||
)
|
||||
op.drop_index(
|
||||
"ix_sys_question_insight_cleaned_question_session_id",
|
||||
table_name="sys_question_insight_cleaned_question",
|
||||
)
|
||||
op.drop_table("sys_question_insight_cleaned_question")
|
||||
@@ -0,0 +1,62 @@
|
||||
"""add durable periodic report job fields
|
||||
|
||||
Revision ID: 0021_periodic_report_async_jobs
|
||||
Revises: 0020_question_insight_store
|
||||
Create Date: 2026-07-31 00:00:00.000000
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = "0021_periodic_report_async_jobs"
|
||||
down_revision = "0020_question_insight_store"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
"sys_periodic_report",
|
||||
sa.Column("attempt_count", sa.Integer(), nullable=False, server_default="0"),
|
||||
)
|
||||
op.add_column(
|
||||
"sys_periodic_report",
|
||||
sa.Column("max_attempts", sa.Integer(), nullable=False, server_default="3"),
|
||||
)
|
||||
op.add_column("sys_periodic_report", sa.Column("next_run_at", sa.DateTime(), nullable=True))
|
||||
op.add_column("sys_periodic_report", sa.Column("locked_at", sa.DateTime(), nullable=True))
|
||||
op.add_column("sys_periodic_report", sa.Column("locked_by", sa.String(length=120), nullable=True))
|
||||
op.add_column("sys_periodic_report", sa.Column("last_started_at", sa.DateTime(), nullable=True))
|
||||
op.add_column("sys_periodic_report", sa.Column("finished_at", sa.DateTime(), nullable=True))
|
||||
op.execute(
|
||||
"UPDATE sys_periodic_report "
|
||||
"SET finished_at = generated_at "
|
||||
"WHERE status IN ('success', 'empty', 'failed')"
|
||||
)
|
||||
op.execute("UPDATE sys_entitlement_plan SET status = 0 WHERE plan_type = 'teacher'")
|
||||
op.create_index(
|
||||
"ix_periodic_report_job_due",
|
||||
"sys_periodic_report",
|
||||
["status", "next_run_at", "id"],
|
||||
)
|
||||
op.create_index(
|
||||
"ix_periodic_report_job_stale",
|
||||
"sys_periodic_report",
|
||||
["status", "locked_at"],
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.execute("UPDATE sys_entitlement_plan SET status = 1 WHERE plan_type = 'teacher'")
|
||||
op.drop_index("ix_periodic_report_job_stale", table_name="sys_periodic_report")
|
||||
op.drop_index("ix_periodic_report_job_due", table_name="sys_periodic_report")
|
||||
op.drop_column("sys_periodic_report", "finished_at")
|
||||
op.drop_column("sys_periodic_report", "last_started_at")
|
||||
op.drop_column("sys_periodic_report", "locked_by")
|
||||
op.drop_column("sys_periodic_report", "locked_at")
|
||||
op.drop_column("sys_periodic_report", "next_run_at")
|
||||
op.drop_column("sys_periodic_report", "max_attempts")
|
||||
op.drop_column("sys_periodic_report", "attempt_count")
|
||||
@@ -0,0 +1,52 @@
|
||||
"""add multi-model routing support
|
||||
|
||||
Revision ID: 0022_model_routing
|
||||
Revises: 0021_periodic_report_async_jobs
|
||||
"""
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = "0022_model_routing"
|
||||
down_revision = "0021_periodic_report_async_jobs"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
"sys_model",
|
||||
sa.Column("is_default", sa.Integer(), nullable=False, server_default="0"),
|
||||
)
|
||||
op.create_index(
|
||||
"ix_sys_model_available_default",
|
||||
"sys_model",
|
||||
["enabled", "is_default", "id"],
|
||||
unique=False,
|
||||
)
|
||||
# 兼容旧数据:原来唯一启用的模型直接成为默认主模型。
|
||||
op.execute(
|
||||
"""
|
||||
UPDATE sys_model
|
||||
SET is_default = 1
|
||||
WHERE enabled = 1
|
||||
AND id = (
|
||||
SELECT selected.id
|
||||
FROM (
|
||||
SELECT id
|
||||
FROM sys_model
|
||||
WHERE enabled = 1
|
||||
ORDER BY id DESC
|
||||
LIMIT 1
|
||||
) AS selected
|
||||
)
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# 回滚到旧语义前只保留默认模型为启用,避免旧代码随机选到分流模型。
|
||||
op.execute("UPDATE sys_model SET enabled = CASE WHEN is_default = 1 THEN 1 ELSE 0 END")
|
||||
op.drop_index("ix_sys_model_available_default", table_name="sys_model")
|
||||
op.drop_column("sys_model", "is_default")
|
||||
@@ -0,0 +1,71 @@
|
||||
"""add simple knowledge model capability
|
||||
|
||||
Revision ID: 0023_simple_knowledge_route
|
||||
Revises: 0022_model_routing
|
||||
"""
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = "0023_simple_knowledge_route"
|
||||
down_revision = "0022_model_routing"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# Existing models retain the capability so deployment behavior stays on the
|
||||
# current default model until an administrator explicitly reassigns it.
|
||||
op.add_column(
|
||||
"sys_model",
|
||||
sa.Column("allow_simple_knowledge", sa.Integer(), nullable=False, server_default="1"),
|
||||
)
|
||||
# Materialize the new three-state setting so the admin UI and runtime show
|
||||
# the same value even when the legacy emergency switch had been disabled.
|
||||
op.execute(
|
||||
"""
|
||||
INSERT IGNORE INTO sys_system_config (config_key, config_value, description)
|
||||
SELECT
|
||||
'chat_model_routing_mode',
|
||||
CASE
|
||||
WHEN EXISTS (
|
||||
SELECT 1
|
||||
FROM sys_system_config
|
||||
WHERE config_key = 'fixed_info_model_routing_enabled'
|
||||
AND LOWER(TRIM(config_value)) NOT IN ('1', 'true', 'yes', 'on', '启用')
|
||||
) THEN 'off'
|
||||
ELSE 'fixed_only'
|
||||
END,
|
||||
'正式问答模型分流规则:off / fixed_only / conservative'
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# Preserve the closest legacy behavior before removing the new setting.
|
||||
op.execute(
|
||||
"""
|
||||
UPDATE sys_system_config AS legacy
|
||||
JOIN sys_system_config AS current_mode
|
||||
ON current_mode.config_key = 'chat_model_routing_mode'
|
||||
SET legacy.config_value = CASE
|
||||
WHEN current_mode.config_value = 'off' THEN 'false'
|
||||
ELSE 'true'
|
||||
END
|
||||
WHERE legacy.config_key = 'fixed_info_model_routing_enabled'
|
||||
"""
|
||||
)
|
||||
op.execute(
|
||||
"""
|
||||
INSERT IGNORE INTO sys_system_config (config_key, config_value, description)
|
||||
SELECT
|
||||
'fixed_info_model_routing_enabled',
|
||||
CASE WHEN config_value = 'off' THEN 'false' ELSE 'true' END,
|
||||
'固定信息模型分流'
|
||||
FROM sys_system_config
|
||||
WHERE config_key = 'chat_model_routing_mode'
|
||||
"""
|
||||
)
|
||||
op.execute("DELETE FROM sys_system_config WHERE config_key = 'chat_model_routing_mode'")
|
||||
op.drop_column("sys_model", "allow_simple_knowledge")
|
||||
@@ -0,0 +1,59 @@
|
||||
"""add durable topic settlement job fields
|
||||
|
||||
Revision ID: 0024_topic_settlement_jobs
|
||||
Revises: 0023_simple_knowledge_route
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = "0024_topic_settlement_jobs"
|
||||
down_revision = "0023_simple_knowledge_route"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
"sys_topic_summary",
|
||||
sa.Column("attempt_count", sa.Integer(), nullable=False, server_default="0"),
|
||||
)
|
||||
op.add_column(
|
||||
"sys_topic_summary",
|
||||
sa.Column("max_attempts", sa.Integer(), nullable=False, server_default="3"),
|
||||
)
|
||||
op.add_column("sys_topic_summary", sa.Column("next_run_at", sa.DateTime(), nullable=True))
|
||||
op.add_column("sys_topic_summary", sa.Column("locked_at", sa.DateTime(), nullable=True))
|
||||
op.add_column("sys_topic_summary", sa.Column("locked_by", sa.String(length=120), nullable=True))
|
||||
op.add_column("sys_topic_summary", sa.Column("last_started_at", sa.DateTime(), nullable=True))
|
||||
op.add_column("sys_topic_summary", sa.Column("finished_at", sa.DateTime(), nullable=True))
|
||||
op.execute(
|
||||
"UPDATE sys_topic_summary "
|
||||
"SET finished_at = generated_at "
|
||||
"WHERE status IN ('success', 'fallback', 'failed')"
|
||||
)
|
||||
op.create_index(
|
||||
"ix_topic_summary_job_due",
|
||||
"sys_topic_summary",
|
||||
["status", "next_run_at", "id"],
|
||||
)
|
||||
op.create_index(
|
||||
"ix_topic_summary_job_stale",
|
||||
"sys_topic_summary",
|
||||
["status", "locked_at"],
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("ix_topic_summary_job_stale", table_name="sys_topic_summary")
|
||||
op.drop_index("ix_topic_summary_job_due", table_name="sys_topic_summary")
|
||||
op.drop_column("sys_topic_summary", "finished_at")
|
||||
op.drop_column("sys_topic_summary", "last_started_at")
|
||||
op.drop_column("sys_topic_summary", "locked_by")
|
||||
op.drop_column("sys_topic_summary", "locked_at")
|
||||
op.drop_column("sys_topic_summary", "next_run_at")
|
||||
op.drop_column("sys_topic_summary", "max_attempts")
|
||||
op.drop_column("sys_topic_summary", "attempt_count")
|
||||
@@ -0,0 +1,70 @@
|
||||
"""replace long-term growth profiling with recent practice review
|
||||
|
||||
Revision ID: 0025_recent_practice_review
|
||||
Revises: 0024_topic_settlement_jobs
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = "0025_recent_practice_review"
|
||||
down_revision = "0024_topic_settlement_jobs"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
"sys_topic_summary",
|
||||
sa.Column("schema_version", sa.Integer(), nullable=False, server_default="1"),
|
||||
)
|
||||
op.alter_column("sys_topic_summary", "schema_version", server_default="2")
|
||||
|
||||
op.add_column(
|
||||
"sys_user_growth_profile",
|
||||
sa.Column("schema_version", sa.Integer(), nullable=False, server_default="1"),
|
||||
)
|
||||
op.add_column("sys_user_growth_profile", sa.Column("recent_review", sa.Text(), nullable=True))
|
||||
op.add_column("sys_user_growth_profile", sa.Column("current_focus", sa.Text(), nullable=True))
|
||||
op.add_column("sys_user_growth_profile", sa.Column("source_summary_ids", sa.Text(), nullable=True))
|
||||
op.alter_column("sys_user_growth_profile", "schema_version", server_default="2")
|
||||
|
||||
op.add_column(
|
||||
"sys_periodic_report",
|
||||
sa.Column("schema_version", sa.Integer(), nullable=False, server_default="1"),
|
||||
)
|
||||
op.alter_column("sys_periodic_report", "schema_version", server_default="2")
|
||||
|
||||
# 仅替换系统初始深度版的旧说明,避免覆盖管理员已经自定义的文案。
|
||||
op.execute(
|
||||
sa.text(
|
||||
"UPDATE sys_entitlement_plan "
|
||||
"SET description = :new_description "
|
||||
"WHERE plan_type = 'deep' AND description = :old_description"
|
||||
).bindparams(
|
||||
new_description="支持近期实修回顾、周期实修回顾和更高主题会话额度。",
|
||||
old_description="支持长期成长档案、阶段报告和更高主题会话额度。",
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.execute(
|
||||
sa.text(
|
||||
"UPDATE sys_entitlement_plan "
|
||||
"SET description = :old_description "
|
||||
"WHERE plan_type = 'deep' AND description = :new_description"
|
||||
).bindparams(
|
||||
old_description="支持长期成长档案、阶段报告和更高主题会话额度。",
|
||||
new_description="支持近期实修回顾、周期实修回顾和更高主题会话额度。",
|
||||
)
|
||||
)
|
||||
op.drop_column("sys_periodic_report", "schema_version")
|
||||
op.drop_column("sys_user_growth_profile", "source_summary_ids")
|
||||
op.drop_column("sys_user_growth_profile", "current_focus")
|
||||
op.drop_column("sys_user_growth_profile", "recent_review")
|
||||
op.drop_column("sys_user_growth_profile", "schema_version")
|
||||
op.drop_column("sys_topic_summary", "schema_version")
|
||||
@@ -0,0 +1,52 @@
|
||||
"""add entitlement renewal idempotency and expiry index
|
||||
|
||||
Revision ID: 0026_entitlement_renewal
|
||||
Revises: 0025_recent_practice_review
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from alembic import context, op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = "0026_entitlement_renewal"
|
||||
down_revision = "0025_recent_practice_review"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
inspector = None if context.is_offline_mode() else sa.inspect(op.get_bind())
|
||||
log_columns = set() if inspector is None else {
|
||||
column["name"] for column in inspector.get_columns("sys_user_entitlement_log")
|
||||
}
|
||||
if "request_key" not in log_columns:
|
||||
op.add_column("sys_user_entitlement_log", sa.Column("request_key", sa.String(160), nullable=True))
|
||||
|
||||
log_indexes = set() if inspector is None else {
|
||||
index["name"] for index in inspector.get_indexes("sys_user_entitlement_log")
|
||||
}
|
||||
if "ux_entitlement_log_request_key" not in log_indexes:
|
||||
op.create_index(
|
||||
"ux_entitlement_log_request_key",
|
||||
"sys_user_entitlement_log",
|
||||
["request_key"],
|
||||
unique=True,
|
||||
)
|
||||
|
||||
entitlement_indexes = set() if inspector is None else {
|
||||
index["name"] for index in inspector.get_indexes("sys_user_entitlement")
|
||||
}
|
||||
if "ix_entitlement_status_expiry" not in entitlement_indexes:
|
||||
op.create_index(
|
||||
"ix_entitlement_status_expiry",
|
||||
"sys_user_entitlement",
|
||||
["status", "expired_at", "user_id"],
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("ix_entitlement_status_expiry", table_name="sys_user_entitlement")
|
||||
op.drop_index("ux_entitlement_log_request_key", table_name="sys_user_entitlement_log")
|
||||
op.drop_column("sys_user_entitlement_log", "request_key")
|
||||
@@ -0,0 +1,49 @@
|
||||
"""add versioned content generation configs
|
||||
|
||||
Revision ID: 0027_content_generation
|
||||
Revises: 0026_entitlement_renewal
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from alembic import context, op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = "0027_content_generation"
|
||||
down_revision = "0026_entitlement_renewal"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
PRIMARY_KEY_TYPE = sa.BigInteger().with_variant(sa.Integer(), "sqlite")
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
inspector = None if context.is_offline_mode() else sa.inspect(op.get_bind())
|
||||
tables = set() if inspector is None else set(inspector.get_table_names())
|
||||
if "sys_content_generation_config" not in tables:
|
||||
op.create_table(
|
||||
"sys_content_generation_config",
|
||||
sa.Column("id", PRIMARY_KEY_TYPE, primary_key=True, autoincrement=True),
|
||||
sa.Column("config_type", sa.String(length=30), nullable=False),
|
||||
sa.Column("template_content", sa.Text(), nullable=False),
|
||||
sa.Column("instruction_content", sa.Text(), nullable=False),
|
||||
sa.Column("change_type", sa.String(length=20), nullable=False, server_default="save"),
|
||||
sa.Column("source_config_id", sa.BigInteger(), nullable=True),
|
||||
sa.Column("updated_by", sa.BigInteger(), nullable=True),
|
||||
sa.Column("updated_at", sa.DateTime(), server_default=sa.func.now(), nullable=False),
|
||||
)
|
||||
op.create_index(
|
||||
"ix_content_generation_type_updated",
|
||||
"sys_content_generation_config",
|
||||
["config_type", "updated_at", "id"],
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
inspector = None if context.is_offline_mode() else sa.inspect(op.get_bind())
|
||||
tables = {"sys_content_generation_config"} if inspector is None else set(inspector.get_table_names())
|
||||
if "sys_content_generation_config" not in tables:
|
||||
return
|
||||
op.drop_index("ix_content_generation_type_updated", table_name="sys_content_generation_config")
|
||||
op.drop_table("sys_content_generation_config")
|
||||
@@ -0,0 +1,84 @@
|
||||
"""raise the default chat concurrency to thirty
|
||||
|
||||
Revision ID: 0028_chat_concurrency
|
||||
Revises: 0027_content_generation
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = "0028_chat_concurrency"
|
||||
down_revision = "0027_content_generation"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
config = sa.table(
|
||||
"sys_system_config",
|
||||
sa.column("config_key", sa.String(length=100)),
|
||||
sa.column("config_value", sa.Text()),
|
||||
sa.column("description", sa.String(length=255)),
|
||||
)
|
||||
connection = op.get_bind()
|
||||
_set_default(
|
||||
connection,
|
||||
config,
|
||||
key="chat_max_active_requests",
|
||||
old_default="2",
|
||||
new_default="30",
|
||||
description="用户正式问答的全局执行上限;Redis 可用时由所有后端实例共享,超过后进入排队。",
|
||||
)
|
||||
_set_default(
|
||||
connection,
|
||||
config,
|
||||
key="chat_max_queue_size",
|
||||
old_default="20",
|
||||
new_default="90",
|
||||
description="超过最大并发后允许等待的请求数量。默认按并发数的 3 倍预留。",
|
||||
)
|
||||
_set_default(
|
||||
connection,
|
||||
config,
|
||||
key="chat_queue_timeout_seconds",
|
||||
old_default="60",
|
||||
new_default="90",
|
||||
description="请求在排队中超过该时间后自动结束。默认可覆盖约三轮请求释放。",
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# 运行时配置可能已经被管理员调整,降级代码版本时不覆盖现场值。
|
||||
pass
|
||||
|
||||
|
||||
def _set_default(
|
||||
connection,
|
||||
config,
|
||||
*,
|
||||
key: str,
|
||||
old_default: str,
|
||||
new_default: str,
|
||||
description: str,
|
||||
) -> None:
|
||||
current = connection.execute(
|
||||
sa.select(config.c.config_value).where(config.c.config_key == key)
|
||||
).scalar_one_or_none()
|
||||
if current is None:
|
||||
connection.execute(
|
||||
config.insert().values(
|
||||
config_key=key,
|
||||
config_value=new_default,
|
||||
description=description,
|
||||
)
|
||||
)
|
||||
return
|
||||
if str(current).strip() == old_default:
|
||||
connection.execute(
|
||||
config.update()
|
||||
.where(config.c.config_key == key)
|
||||
.values(config_value=new_default, description=description)
|
||||
)
|
||||
@@ -0,0 +1,77 @@
|
||||
"""add trusted application sso integration
|
||||
|
||||
Revision ID: 0029_sso_integration
|
||||
Revises: 0028_chat_concurrency
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = "0029_sso_integration"
|
||||
down_revision = "0028_chat_concurrency"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
PRIMARY_KEY_TYPE = sa.BigInteger().with_variant(sa.Integer(), "sqlite")
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"sys_sso_client",
|
||||
sa.Column("id", PRIMARY_KEY_TYPE, primary_key=True, autoincrement=True),
|
||||
sa.Column("app_id", sa.String(length=80), nullable=False),
|
||||
sa.Column("name", sa.String(length=100), nullable=False),
|
||||
sa.Column("client_secret", sa.Text(), nullable=False),
|
||||
sa.Column("redirect_uris", sa.Text(), nullable=False),
|
||||
sa.Column("status", sa.Integer(), nullable=False, server_default="1"),
|
||||
sa.Column("created_by", sa.BigInteger(), nullable=True),
|
||||
sa.Column("last_used_at", sa.DateTime(), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(), server_default=sa.func.now(), nullable=False),
|
||||
)
|
||||
op.create_index("ix_sys_sso_client_app_id", "sys_sso_client", ["app_id"], unique=True)
|
||||
op.create_index("ix_sys_sso_client_status", "sys_sso_client", ["status"])
|
||||
|
||||
op.create_table(
|
||||
"sys_user_external_identity",
|
||||
sa.Column("id", PRIMARY_KEY_TYPE, primary_key=True, autoincrement=True),
|
||||
sa.Column("client_id", sa.BigInteger(), nullable=False),
|
||||
sa.Column("user_id", sa.BigInteger(), nullable=False),
|
||||
sa.Column("external_user_id", sa.String(length=120), nullable=False),
|
||||
sa.Column("phone_snapshot", sa.String(length=20), nullable=True),
|
||||
sa.Column("display_name_snapshot", sa.String(length=100), nullable=True),
|
||||
sa.Column("last_login_at", sa.DateTime(), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(), server_default=sa.func.now(), nullable=False),
|
||||
sa.ForeignKeyConstraint(["client_id"], ["sys_sso_client.id"]),
|
||||
sa.ForeignKeyConstraint(["user_id"], ["sys_user.id"]),
|
||||
sa.UniqueConstraint("client_id", "external_user_id", name="uq_sso_identity_client_external"),
|
||||
sa.UniqueConstraint("client_id", "user_id", name="uq_sso_identity_client_user"),
|
||||
)
|
||||
op.create_index("ix_sys_user_external_identity_client_id", "sys_user_external_identity", ["client_id"])
|
||||
op.create_index("ix_sso_identity_user", "sys_user_external_identity", ["user_id"])
|
||||
|
||||
op.create_table(
|
||||
"sys_sso_login_audit",
|
||||
sa.Column("id", PRIMARY_KEY_TYPE, primary_key=True, autoincrement=True),
|
||||
sa.Column("client_id", sa.BigInteger(), nullable=True),
|
||||
sa.Column("user_id", sa.BigInteger(), nullable=True),
|
||||
sa.Column("external_user_id", sa.String(length=120), nullable=True),
|
||||
sa.Column("action", sa.String(length=30), nullable=False),
|
||||
sa.Column("status", sa.String(length=20), nullable=False),
|
||||
sa.Column("error_message", sa.String(length=500), nullable=True),
|
||||
sa.Column("ip", sa.String(length=50), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(), server_default=sa.func.now(), nullable=False),
|
||||
)
|
||||
op.create_index("ix_sso_audit_client_created", "sys_sso_login_audit", ["client_id", "created_at"])
|
||||
op.create_index("ix_sso_audit_user_created", "sys_sso_login_audit", ["user_id", "created_at"])
|
||||
op.create_index("ix_sso_audit_status_created", "sys_sso_login_audit", ["status", "created_at"])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table("sys_sso_login_audit")
|
||||
op.drop_table("sys_user_external_identity")
|
||||
op.drop_table("sys_sso_client")
|
||||
@@ -0,0 +1,56 @@
|
||||
"""add sso auto registration and chat source isolation
|
||||
|
||||
Revision ID: 0030_sso_chat_source
|
||||
Revises: 0029_sso_integration
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = "0030_sso_chat_source"
|
||||
down_revision = "0029_sso_integration"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
"sys_sso_client",
|
||||
sa.Column("allow_auto_register", sa.Integer(), nullable=False, server_default="0"),
|
||||
)
|
||||
op.add_column(
|
||||
"sys_sso_client",
|
||||
sa.Column("default_entitlement_plan_id", sa.BigInteger(), nullable=True),
|
||||
)
|
||||
|
||||
op.add_column(
|
||||
"sys_user",
|
||||
sa.Column("registration_source", sa.String(length=30), nullable=False, server_default="direct"),
|
||||
)
|
||||
op.add_column("sys_user", sa.Column("registration_client_id", sa.BigInteger(), nullable=True))
|
||||
op.create_index("ix_sys_user_registration_client_id", "sys_user", ["registration_client_id"])
|
||||
|
||||
op.add_column(
|
||||
"sys_chat_session",
|
||||
sa.Column("source_type", sa.String(length=20), nullable=False, server_default="direct"),
|
||||
)
|
||||
op.add_column("sys_chat_session", sa.Column("source_client_id", sa.BigInteger(), nullable=True))
|
||||
op.create_index(
|
||||
"ix_chat_session_user_source_active_updated",
|
||||
"sys_chat_session",
|
||||
["user_id", "source_type", "source_client_id", "is_deleted", "updated_at"],
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("ix_chat_session_user_source_active_updated", table_name="sys_chat_session")
|
||||
op.drop_column("sys_chat_session", "source_client_id")
|
||||
op.drop_column("sys_chat_session", "source_type")
|
||||
op.drop_index("ix_sys_user_registration_client_id", table_name="sys_user")
|
||||
op.drop_column("sys_user", "registration_client_id")
|
||||
op.drop_column("sys_user", "registration_source")
|
||||
op.drop_column("sys_sso_client", "default_entitlement_plan_id")
|
||||
op.drop_column("sys_sso_client", "allow_auto_register")
|
||||
@@ -0,0 +1,30 @@
|
||||
"""add administrator permission management
|
||||
|
||||
Revision ID: 0031_admin_permissions
|
||||
Revises: 0030_sso_chat_source
|
||||
"""
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision = "0031_admin_permissions"
|
||||
down_revision = "0030_sso_chat_source"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# MySQL does not permit a DEFAULT on TEXT on all supported versions.
|
||||
op.add_column("sys_role", sa.Column("permissions", sa.Text(), nullable=True))
|
||||
op.execute("UPDATE sys_role SET permissions = '[]' WHERE permissions IS NULL")
|
||||
op.alter_column("sys_role", "permissions", existing_type=sa.Text(), nullable=False)
|
||||
op.add_column("sys_admin", sa.Column("must_change_password", sa.Integer(), nullable=False, server_default="1"))
|
||||
op.add_column("sys_admin", sa.Column("is_super_admin", sa.Integer(), nullable=False, server_default="0"))
|
||||
# Existing installations have one bootstrap administrator. Preserve it as the immutable super administrator.
|
||||
op.execute("UPDATE sys_admin SET is_super_admin = 1, must_change_password = 0 WHERE id = (SELECT id FROM (SELECT MIN(id) AS id FROM sys_admin) AS first_admin)")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("sys_admin", "is_super_admin")
|
||||
op.drop_column("sys_admin", "must_change_password")
|
||||
op.drop_column("sys_role", "permissions")
|
||||
@@ -0,0 +1,36 @@
|
||||
"""add message feedback management
|
||||
|
||||
Revision ID: 0032_message_feedback
|
||||
Revises: 0031_admin_permissions
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision = "0032_message_feedback"
|
||||
down_revision = "0031_admin_permissions"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"sys_message_feedback",
|
||||
sa.Column("id", sa.BigInteger(), primary_key=True, autoincrement=True),
|
||||
sa.Column("user_id", sa.BigInteger(), sa.ForeignKey("sys_user.id"), nullable=False),
|
||||
sa.Column("session_id", sa.BigInteger(), sa.ForeignKey("sys_chat_session.id"), nullable=False),
|
||||
sa.Column("message_id", sa.BigInteger(), sa.ForeignKey("sys_chat_message.id"), nullable=False),
|
||||
sa.Column("content", sa.String(200), nullable=False),
|
||||
sa.Column("is_read", sa.Integer(), nullable=False, server_default="0"),
|
||||
sa.Column("read_by", sa.BigInteger(), sa.ForeignKey("sys_admin.id"), nullable=True),
|
||||
sa.Column("read_at", sa.DateTime(), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(), nullable=False, server_default=sa.func.now()),
|
||||
sa.UniqueConstraint("user_id", "message_id", name="uq_message_feedback_user_message"),
|
||||
)
|
||||
op.create_index("ix_message_feedback_user_id", "sys_message_feedback", ["user_id"])
|
||||
op.create_index("ix_message_feedback_session_id", "sys_message_feedback", ["session_id"])
|
||||
op.create_index("ix_message_feedback_message_id", "sys_message_feedback", ["message_id"])
|
||||
op.create_index("ix_message_feedback_read_created", "sys_message_feedback", ["is_read", "created_at"])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table("sys_message_feedback")
|
||||
@@ -0,0 +1,74 @@
|
||||
"""add persistent Agent batch tests
|
||||
|
||||
Revision ID: 0033_agent_batch_tests
|
||||
Revises: 0032_message_feedback
|
||||
"""
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = "0033_agent_batch_tests"
|
||||
down_revision = "0032_message_feedback"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"sys_agent_batch_test",
|
||||
sa.Column("id", sa.BigInteger(), primary_key=True, autoincrement=True),
|
||||
sa.Column("name", sa.String(160), nullable=False),
|
||||
sa.Column("original_filename", sa.String(255), nullable=False),
|
||||
sa.Column("status", sa.String(20), nullable=False, server_default="pending"),
|
||||
sa.Column("total_count", sa.Integer(), nullable=False, server_default="0"),
|
||||
sa.Column("success_count", sa.Integer(), nullable=False, server_default="0"),
|
||||
sa.Column("failed_count", sa.Integer(), nullable=False, server_default="0"),
|
||||
sa.Column("processed_count", sa.Integer(), nullable=False, server_default="0"),
|
||||
sa.Column("prompt_content", sa.Text(), nullable=False),
|
||||
sa.Column("model_id", sa.BigInteger(), sa.ForeignKey("sys_model.id"), nullable=False),
|
||||
sa.Column("model_name", sa.String(100), nullable=False),
|
||||
sa.Column("knowledge_ids", sa.Text(), nullable=False),
|
||||
sa.Column("knowledge_names", sa.Text(), nullable=False),
|
||||
sa.Column("generation_config", sa.Text(), nullable=False),
|
||||
sa.Column("created_by", sa.BigInteger(), sa.ForeignKey("sys_admin.id"), nullable=False),
|
||||
sa.Column("started_at", sa.DateTime(), nullable=True),
|
||||
sa.Column("finished_at", sa.DateTime(), nullable=True),
|
||||
sa.Column("cancelled_at", sa.DateTime(), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(), nullable=False, server_default=sa.func.now()),
|
||||
sa.Column("updated_at", sa.DateTime(), nullable=False, server_default=sa.func.now()),
|
||||
)
|
||||
op.create_index("ix_agent_batch_test_creator_created", "sys_agent_batch_test", ["created_by", "created_at"])
|
||||
op.create_index("ix_agent_batch_test_status_created", "sys_agent_batch_test", ["status", "created_at"])
|
||||
op.create_table(
|
||||
"sys_agent_batch_test_item",
|
||||
sa.Column("id", sa.BigInteger(), primary_key=True, autoincrement=True),
|
||||
sa.Column("batch_id", sa.BigInteger(), sa.ForeignKey("sys_agent_batch_test.id", ondelete="CASCADE"), nullable=False),
|
||||
sa.Column("row_number", sa.Integer(), nullable=False),
|
||||
sa.Column("external_no", sa.String(100), nullable=True),
|
||||
sa.Column("question", sa.Text(), nullable=False),
|
||||
sa.Column("answer", sa.Text(), nullable=True),
|
||||
sa.Column("status", sa.String(20), nullable=False, server_default="pending"),
|
||||
sa.Column("error_message", sa.Text(), nullable=True),
|
||||
sa.Column("model_name", sa.String(100), nullable=True),
|
||||
sa.Column("knowledge_ids", sa.Text(), nullable=True),
|
||||
sa.Column("retrieve_count", sa.Integer(), nullable=True),
|
||||
sa.Column("duration_ms", sa.Integer(), nullable=True),
|
||||
sa.Column("attempt_count", sa.Integer(), nullable=False, server_default="0"),
|
||||
sa.Column("max_attempts", sa.Integer(), nullable=False, server_default="2"),
|
||||
sa.Column("next_run_at", sa.DateTime(), nullable=True),
|
||||
sa.Column("locked_at", sa.DateTime(), nullable=True),
|
||||
sa.Column("locked_by", sa.String(120), nullable=True),
|
||||
sa.Column("started_at", sa.DateTime(), nullable=True),
|
||||
sa.Column("finished_at", sa.DateTime(), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(), nullable=False, server_default=sa.func.now()),
|
||||
sa.Column("updated_at", sa.DateTime(), nullable=False, server_default=sa.func.now()),
|
||||
)
|
||||
op.create_index("ix_agent_batch_item_job_order", "sys_agent_batch_test_item", ["batch_id", "row_number"])
|
||||
op.create_index("ix_agent_batch_item_claim", "sys_agent_batch_test_item", ["status", "next_run_at", "id"])
|
||||
op.create_index("ix_agent_batch_item_stale", "sys_agent_batch_test_item", ["status", "locked_at"])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table("sys_agent_batch_test_item")
|
||||
op.drop_table("sys_agent_batch_test")
|
||||
@@ -0,0 +1,30 @@
|
||||
"""add Agent batch execution mode and concurrency limit
|
||||
|
||||
Revision ID: 0034_agent_batch_concurrency
|
||||
Revises: 0033_agent_batch_tests
|
||||
"""
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = "0034_agent_batch_concurrency"
|
||||
down_revision = "0033_agent_batch_tests"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
"sys_agent_batch_test",
|
||||
sa.Column("execution_mode", sa.String(20), nullable=False, server_default="sequential"),
|
||||
)
|
||||
op.add_column(
|
||||
"sys_agent_batch_test",
|
||||
sa.Column("concurrency_limit", sa.Integer(), nullable=False, server_default="1"),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("sys_agent_batch_test", "concurrency_limit")
|
||||
op.drop_column("sys_agent_batch_test", "execution_mode")
|
||||
@@ -0,0 +1,24 @@
|
||||
"""add configurable variables to content generation configs
|
||||
|
||||
Revision ID: 0035_content_gen_variables
|
||||
Revises: 0034_agent_batch_concurrency
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
|
||||
revision = "0035_content_gen_variables"
|
||||
down_revision = "0034_agent_batch_concurrency"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column("sys_content_generation_config", sa.Column("variables_json", sa.Text(), nullable=True))
|
||||
op.execute("UPDATE sys_content_generation_config SET variables_json = '[]' WHERE variables_json IS NULL")
|
||||
op.alter_column("sys_content_generation_config", "variables_json", existing_type=sa.Text(), nullable=False)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("sys_content_generation_config", "variables_json")
|
||||
@@ -0,0 +1,65 @@
|
||||
"""remove monthly topic quota while preserving practice review data
|
||||
|
||||
Revision ID: 0036_remove_topic_quota
|
||||
Revises: 0035_content_gen_variables
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
|
||||
revision = "0036_remove_topic_quota"
|
||||
down_revision = "0035_content_gen_variables"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
OLD_REVIEW_KEY = "topic_auto_settle_successful_rounds"
|
||||
NEW_REVIEW_KEY = "practice_review_auto_settle_successful_rounds"
|
||||
|
||||
|
||||
def _rename_config_key(old_key: str, new_key: str) -> None:
|
||||
connection = op.get_bind()
|
||||
old_exists = connection.execute(
|
||||
sa.text("SELECT COUNT(*) FROM sys_system_config WHERE config_key = :key"),
|
||||
{"key": old_key},
|
||||
).scalar()
|
||||
if not old_exists:
|
||||
return
|
||||
new_exists = connection.execute(
|
||||
sa.text("SELECT COUNT(*) FROM sys_system_config WHERE config_key = :key"),
|
||||
{"key": new_key},
|
||||
).scalar()
|
||||
if new_exists:
|
||||
connection.execute(
|
||||
sa.text("DELETE FROM sys_system_config WHERE config_key = :key"),
|
||||
{"key": old_key},
|
||||
)
|
||||
return
|
||||
connection.execute(
|
||||
sa.text("UPDATE sys_system_config SET config_key = :new_key WHERE config_key = :old_key"),
|
||||
{"new_key": new_key, "old_key": old_key},
|
||||
)
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
_rename_config_key(OLD_REVIEW_KEY, NEW_REVIEW_KEY)
|
||||
op.drop_column("sys_entitlement_plan", "deduct_quota")
|
||||
op.drop_column("sys_entitlement_plan", "monthly_topic_limit")
|
||||
op.drop_column("sys_topic_session", "quota_deducted")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.add_column(
|
||||
"sys_topic_session",
|
||||
sa.Column("quota_deducted", sa.Integer(), nullable=False, server_default="0"),
|
||||
)
|
||||
op.add_column(
|
||||
"sys_entitlement_plan",
|
||||
sa.Column("monthly_topic_limit", sa.Integer(), nullable=True),
|
||||
)
|
||||
op.add_column(
|
||||
"sys_entitlement_plan",
|
||||
sa.Column("deduct_quota", sa.Integer(), nullable=False, server_default="1"),
|
||||
)
|
||||
_rename_config_key(NEW_REVIEW_KEY, OLD_REVIEW_KEY)
|
||||
@@ -0,0 +1,38 @@
|
||||
"""track chat and weekly-report sources for configurable periodic reports
|
||||
|
||||
Revision ID: 0037_report_sources
|
||||
Revises: 0036_remove_topic_quota
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
|
||||
revision = "0037_report_sources"
|
||||
down_revision = "0036_remove_topic_quota"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column("sys_periodic_report", sa.Column("source_message_ids", sa.Text(), nullable=True))
|
||||
op.add_column("sys_periodic_report", sa.Column("source_report_ids", sa.Text(), nullable=True))
|
||||
op.alter_column(
|
||||
"sys_periodic_report",
|
||||
"schema_version",
|
||||
server_default="3",
|
||||
existing_type=sa.Integer(),
|
||||
existing_nullable=False,
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.alter_column(
|
||||
"sys_periodic_report",
|
||||
"schema_version",
|
||||
server_default="2",
|
||||
existing_type=sa.Integer(),
|
||||
existing_nullable=False,
|
||||
)
|
||||
op.drop_column("sys_periodic_report", "source_report_ids")
|
||||
op.drop_column("sys_periodic_report", "source_message_ids")
|
||||
@@ -0,0 +1,42 @@
|
||||
"""add user behavior analytics
|
||||
|
||||
Revision ID: 0038_user_behavior
|
||||
Revises: 0037_report_sources
|
||||
"""
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = "0038_user_behavior"
|
||||
down_revision = "0037_report_sources"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"sys_user_behavior_event",
|
||||
sa.Column("id", sa.BigInteger(), autoincrement=True, nullable=False),
|
||||
sa.Column("client_event_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("user_id", sa.BigInteger(), nullable=False),
|
||||
sa.Column("event_code", sa.String(length=64), nullable=False),
|
||||
sa.Column("event_name", sa.String(length=100), nullable=False),
|
||||
sa.Column("event_type", sa.String(length=20), nullable=False),
|
||||
sa.Column("target_type", sa.String(length=30), nullable=True),
|
||||
sa.Column("target_id", sa.BigInteger(), nullable=True),
|
||||
sa.Column("occurred_at", sa.DateTime(), nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(), server_default=sa.text("CURRENT_TIMESTAMP"), nullable=False),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.UniqueConstraint("client_event_id", name="uq_user_behavior_client_event"),
|
||||
)
|
||||
op.create_index("ix_user_behavior_occurred", "sys_user_behavior_event", ["occurred_at", "id"])
|
||||
op.create_index("ix_user_behavior_user_occurred", "sys_user_behavior_event", ["user_id", "occurred_at", "id"])
|
||||
op.create_index("ix_user_behavior_code_occurred", "sys_user_behavior_event", ["event_code", "occurred_at", "id"])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("ix_user_behavior_code_occurred", table_name="sys_user_behavior_event")
|
||||
op.drop_index("ix_user_behavior_user_occurred", table_name="sys_user_behavior_event")
|
||||
op.drop_index("ix_user_behavior_occurred", table_name="sys_user_behavior_event")
|
||||
op.drop_table("sys_user_behavior_event")
|
||||
@@ -0,0 +1,52 @@
|
||||
"""add durable human attention screening jobs
|
||||
|
||||
Revision ID: 0039_attention_config
|
||||
Revises: 0038_user_behavior
|
||||
"""
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = "0039_attention_config"
|
||||
down_revision = "0038_user_behavior"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"sys_human_attention_job",
|
||||
sa.Column("id", sa.BigInteger(), autoincrement=True, nullable=False),
|
||||
sa.Column("session_id", sa.BigInteger(), nullable=False),
|
||||
sa.Column("message_id", sa.BigInteger(), nullable=False),
|
||||
sa.Column("retrieval_log_id", sa.BigInteger(), nullable=True),
|
||||
sa.Column("user_id", sa.BigInteger(), nullable=False),
|
||||
sa.Column("question", sa.Text(), nullable=False),
|
||||
sa.Column("answer", sa.Text(), nullable=False),
|
||||
sa.Column("knowledge_missing", sa.Integer(), server_default="0", nullable=False),
|
||||
sa.Column("config_snapshot", sa.Text(), nullable=False),
|
||||
sa.Column("status", sa.String(length=20), server_default="pending", nullable=False),
|
||||
sa.Column("attempt_count", sa.Integer(), server_default="0", nullable=False),
|
||||
sa.Column("max_attempts", sa.Integer(), server_default="3", nullable=False),
|
||||
sa.Column("next_run_at", sa.DateTime(), nullable=True),
|
||||
sa.Column("locked_at", sa.DateTime(), nullable=True),
|
||||
sa.Column("locked_by", sa.String(length=100), nullable=True),
|
||||
sa.Column("error_message", sa.Text(), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(), server_default=sa.text("CURRENT_TIMESTAMP"), nullable=False),
|
||||
sa.Column("finished_at", sa.DateTime(), nullable=True),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.UniqueConstraint("message_id", name="uq_human_attention_job_message"),
|
||||
)
|
||||
op.create_index("ix_human_attention_job_status_next", "sys_human_attention_job", ["status", "next_run_at", "id"])
|
||||
op.create_index("ix_human_attention_job_user", "sys_human_attention_job", ["user_id"])
|
||||
op.create_index("ix_human_attention_job_session", "sys_human_attention_job", ["session_id"])
|
||||
op.create_index("ix_human_attention_job_retrieval", "sys_human_attention_job", ["retrieval_log_id"])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("ix_human_attention_job_retrieval", table_name="sys_human_attention_job")
|
||||
op.drop_index("ix_human_attention_job_session", table_name="sys_human_attention_job")
|
||||
op.drop_index("ix_human_attention_job_user", table_name="sys_human_attention_job")
|
||||
op.drop_index("ix_human_attention_job_status_next", table_name="sys_human_attention_job")
|
||||
op.drop_table("sys_human_attention_job")
|
||||
@@ -0,0 +1,25 @@
|
||||
"""add human attention time filter index
|
||||
|
||||
Revision ID: 0040_attention_filters
|
||||
Revises: 0039_attention_config
|
||||
"""
|
||||
|
||||
from alembic import op
|
||||
|
||||
|
||||
revision = "0040_attention_filters"
|
||||
down_revision = "0039_attention_config"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_index(
|
||||
"ix_human_attention_created",
|
||||
"sys_human_attention_record",
|
||||
["created_at", "id"],
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("ix_human_attention_created", table_name="sys_human_attention_record")
|
||||
@@ -0,0 +1,87 @@
|
||||
"""normalize legacy server timestamps to UTC
|
||||
|
||||
Revision ID: 0041_utc_time_contract
|
||||
Revises: 0040_attention_filters
|
||||
"""
|
||||
|
||||
from alembic import op
|
||||
from sqlalchemy import text
|
||||
|
||||
|
||||
revision = "0041_utc_time_contract"
|
||||
down_revision = "0040_attention_filters"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
# Columns populated by MySQL CURRENT_TIMESTAMP. Fields that application
|
||||
# services already wrote explicitly in UTC are intentionally excluded.
|
||||
SERVER_TIMESTAMP_COLUMNS: dict[str, tuple[str, ...]] = {
|
||||
"sys_admin": ("created_at", "updated_at"),
|
||||
"sys_agent_batch_test": ("created_at", "updated_at"),
|
||||
"sys_agent_batch_test_item": ("created_at", "updated_at"),
|
||||
"sys_ai_request_log": ("created_at",),
|
||||
"sys_chat_session": ("created_at", "updated_at"),
|
||||
"sys_content_generation_config": ("updated_at",),
|
||||
"sys_entitlement_plan": ("created_at", "updated_at"),
|
||||
"sys_growth_profile_revision": ("created_at",),
|
||||
"sys_human_attention_history": ("created_at",),
|
||||
"sys_human_attention_job": ("created_at",),
|
||||
"sys_human_attention_record": ("created_at", "updated_at"),
|
||||
"sys_knowledge": ("created_at", "updated_at"),
|
||||
"sys_knowledge_publish_log": ("created_at",),
|
||||
"sys_knowledge_retrieval_log": ("created_at",),
|
||||
"sys_knowledge_review_record": ("created_at",),
|
||||
"sys_knowledge_sync_job": ("created_at",),
|
||||
"sys_knowledge_version": ("created_at",),
|
||||
"sys_log_retention_policy": ("updated_at",),
|
||||
"sys_message_feedback": ("created_at",),
|
||||
"sys_operation_log": ("created_at",),
|
||||
"sys_periodic_report": ("created_at", "updated_at"),
|
||||
"sys_prompt": ("updated_at",),
|
||||
"sys_question_insight_cleaned_question": ("created_at", "updated_at"),
|
||||
"sys_role": ("created_at", "updated_at"),
|
||||
"sys_share_draft": ("created_at",),
|
||||
"sys_sso_client": ("created_at", "updated_at"),
|
||||
"sys_sso_login_audit": ("created_at",),
|
||||
"sys_storage_snapshot": ("created_at",),
|
||||
"sys_system_config": ("updated_at",),
|
||||
"sys_teacher_help_card": ("created_at",),
|
||||
"sys_topic_session": ("created_at", "updated_at"),
|
||||
"sys_topic_summary": ("created_at", "updated_at"),
|
||||
"sys_user": ("created_at", "updated_at"),
|
||||
"sys_user_behavior_event": ("created_at",),
|
||||
"sys_user_entitlement": ("created_at", "updated_at"),
|
||||
"sys_user_external_identity": ("created_at", "updated_at"),
|
||||
"sys_user_growth_profile": ("created_at", "updated_at"),
|
||||
"sys_user_kb": ("created_at", "updated_at"),
|
||||
}
|
||||
|
||||
|
||||
def _server_offset_seconds() -> int:
|
||||
connection = op.get_bind()
|
||||
if connection.dialect.name != "mysql":
|
||||
return 0
|
||||
return int(connection.scalar(text("SELECT TIMESTAMPDIFF(SECOND, UTC_TIMESTAMP(), NOW())")) or 0)
|
||||
|
||||
|
||||
def _shift_server_timestamps(offset_seconds: int) -> None:
|
||||
if not offset_seconds:
|
||||
return
|
||||
connection = op.get_bind()
|
||||
operation = "SUB" if offset_seconds > 0 else "ADD"
|
||||
seconds = abs(offset_seconds)
|
||||
for table_name, columns in SERVER_TIMESTAMP_COLUMNS.items():
|
||||
assignments = ", ".join(
|
||||
f"`{column}` = DATE_{operation}(`{column}`, INTERVAL {seconds} SECOND)"
|
||||
for column in columns
|
||||
)
|
||||
connection.execute(text(f"UPDATE `{table_name}` SET {assignments}"))
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
_shift_server_timestamps(_server_offset_seconds())
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
_shift_server_timestamps(-_server_offset_seconds())
|
||||
@@ -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)
|
||||
@@ -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")
|
||||
217
ai_knowledge_base_v2/apps/backend/app/api/admin_agent_batch.py
Normal file
217
ai_knowledge_base_v2/apps/backend/app/api/admin_agent_batch.py
Normal 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
|
||||
@@ -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):
|
||||
|
||||
174
ai_knowledge_base_v2/apps/backend/app/api/admin_attention.py
Normal file
174
ai_knowledge_base_v2/apps/backend/app/api/admin_attention.py
Normal 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
|
||||
@@ -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")
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
179
ai_knowledge_base_v2/apps/backend/app/api/admin_entitlements.py
Normal file
179
ai_knowledge_base_v2/apps/backend/app/api/admin_entitlements.py
Normal 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
|
||||
@@ -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,
|
||||
|
||||
@@ -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}"
|
||||
|
||||
120
ai_knowledge_base_v2/apps/backend/app/api/admin_management.py
Normal file
120
ai_knowledge_base_v2/apps/backend/app/api/admin_management.py
Normal 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()
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
|
||||
325
ai_knowledge_base_v2/apps/backend/app/api/admin_sso.py
Normal file
325
ai_knowledge_base_v2/apps/backend/app/api/admin_sso.py
Normal 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,
|
||||
}
|
||||
107
ai_knowledge_base_v2/apps/backend/app/api/admin_user_behavior.py
Normal file
107
ai_knowledge_base_v2/apps/backend/app/api/admin_user_behavior.py
Normal 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)
|
||||
@@ -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")
|
||||
|
||||
@@ -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"))
|
||||
|
||||
|
||||
|
||||
24
ai_knowledge_base_v2/apps/backend/app/api/behavior.py
Normal file
24
ai_knowledge_base_v2/apps/backend/app/api/behavior.py
Normal 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})
|
||||
@@ -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:
|
||||
|
||||
231
ai_knowledge_base_v2/apps/backend/app/api/feedback.py
Normal file
231
ai_knowledge_base_v2/apps/backend/app/api/feedback.py
Normal 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
|
||||
43
ai_knowledge_base_v2/apps/backend/app/api/integration_sso.py
Normal file
43
ai_knowledge_base_v2/apps/backend/app/api/integration_sso.py
Normal 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),
|
||||
)
|
||||
)
|
||||
16
ai_knowledge_base_v2/apps/backend/app/api/public_config.py
Normal file
16
ai_knowledge_base_v2/apps/backend/app/api/public_config.py
Normal 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))
|
||||
@@ -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)
|
||||
|
||||
@@ -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])
|
||||
|
||||
27
ai_knowledge_base_v2/apps/backend/app/api/voice.py
Normal file
27
ai_knowledge_base_v2/apps/backend/app/api/voice.py
Normal 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))
|
||||
@@ -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}"
|
||||
21
ai_knowledge_base_v2/apps/backend/app/core/auth_context.py
Normal file
21
ai_knowledge_base_v2/apps/backend/app/core/auth_context.py
Normal 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
|
||||
@@ -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 = "系统管理员"
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user