初始化
This commit is contained in:
11
index.html
11
index.html
@@ -1,10 +1,15 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<html lang="zh-CN" data-theme="dark">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<!-- 内联 favicon:避免浏览器请求 /favicon.ico 产生 401 噪音(与旧面板同款方案) -->
|
||||
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'%3E%3Ctext y='.9em' font-size='90'%3E%F0%9F%8C%BF%3C/text%3E%3C/svg%3E" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Vite + Vue</title>
|
||||
<title>TCM Agent 控制台</title>
|
||||
<style>
|
||||
/* 首屏防白闪:JS 加载前先铺暖黑底 */
|
||||
html[data-theme='dark'] body { background: #16100a; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
|
||||
15
package.json
15
package.json
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "spa-view",
|
||||
"name": "nl-tcm-agent-admin",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
@@ -9,23 +9,18 @@
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"tailwindcss": "^4.1.4",
|
||||
"vue": "^3.5.13",
|
||||
"@ant-design-vue/pro-layout": "^3.2.5",
|
||||
"@ant-design/icons-vue": "^7.0.1",
|
||||
"@fortawesome/fontawesome-free": "^6.7.2",
|
||||
"@tailwindcss/vite": "^4.1.4",
|
||||
"@vueuse/head": "^2.0.0",
|
||||
"ant-design-vue": "^4.1.1",
|
||||
"axios": "^1.6.2",
|
||||
"lucide-vue-next": "^0.507.0",
|
||||
"echarts": "^5.5.0",
|
||||
"pinia": "^2.1.7",
|
||||
"swiper": "^11.0.5",
|
||||
"tailwindcss": "^4.1.4",
|
||||
"vue": "^3.5.13",
|
||||
"vue-router": "^4.2.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "^5.2.2",
|
||||
"sass": "^1.71.0",
|
||||
"vite": "^6.3.1"
|
||||
}
|
||||
}
|
||||
|
||||
192
pnpm-lock.yaml
generated
192
pnpm-lock.yaml
generated
@@ -8,36 +8,24 @@ importers:
|
||||
|
||||
.:
|
||||
dependencies:
|
||||
'@ant-design-vue/pro-layout':
|
||||
specifier: ^3.2.5
|
||||
version: 3.2.5(ant-design-vue@4.2.6(vue@3.5.13))(vue@3.5.13)
|
||||
'@ant-design/icons-vue':
|
||||
specifier: ^7.0.1
|
||||
version: 7.0.1(vue@3.5.13)
|
||||
'@tailwindcss/vite':
|
||||
specifier: ^4.1.4
|
||||
version: 4.1.4(vite@6.3.3(jiti@2.4.2)(lightningcss@1.29.2)(sass@1.88.0))
|
||||
'@vueuse/head':
|
||||
specifier: ^2.0.0
|
||||
version: 2.0.0(vue@3.5.13)
|
||||
ant-design-vue:
|
||||
specifier: ^4.1.1
|
||||
version: 4.2.6(vue@3.5.13)
|
||||
aos:
|
||||
specifier: ^2.3.4
|
||||
version: 2.3.4
|
||||
axios:
|
||||
specifier: ^1.6.2
|
||||
version: 1.9.0
|
||||
lucide-vue-next:
|
||||
specifier: ^0.507.0
|
||||
version: 0.507.0(vue@3.5.13)
|
||||
echarts:
|
||||
specifier: ^5.5.0
|
||||
version: 5.6.0
|
||||
pinia:
|
||||
specifier: ^2.1.7
|
||||
version: 2.3.1(vue@3.5.13)
|
||||
swiper:
|
||||
specifier: ^11.0.5
|
||||
version: 11.2.6
|
||||
tailwindcss:
|
||||
specifier: ^4.1.4
|
||||
version: 4.1.4
|
||||
@@ -51,32 +39,18 @@ importers:
|
||||
'@vitejs/plugin-vue':
|
||||
specifier: ^5.2.2
|
||||
version: 5.2.3(vite@6.3.3(jiti@2.4.2)(lightningcss@1.29.2)(sass@1.88.0))(vue@3.5.13)
|
||||
sass:
|
||||
specifier: ^1.71.0
|
||||
version: 1.88.0
|
||||
vite:
|
||||
specifier: ^6.3.1
|
||||
version: 6.3.3(jiti@2.4.2)(lightningcss@1.29.2)(sass@1.88.0)
|
||||
|
||||
packages:
|
||||
|
||||
'@ant-design-vue/pro-layout@3.2.5':
|
||||
resolution: {integrity: sha512-M87KEnnt3h5zLlYXPST4fTGHkuwnJac6xbBGOUTjNjMraOyyO9afx++UNdeOc8gtjtasOBcp9VYGFrGUQyJxVg==}
|
||||
peerDependencies:
|
||||
ant-design-vue: '>=3.2.0'
|
||||
vue: '>=3.2.0'
|
||||
|
||||
'@ant-design/colors@6.0.0':
|
||||
resolution: {integrity: sha512-qAZRvPzfdWHtfameEGP2Qvuf838NhergR35o+EuVyB5XvSA98xod5r4utvi4TJ3ywmevm290g9nsCG5MryrdWQ==}
|
||||
|
||||
'@ant-design/icons-svg@4.4.2':
|
||||
resolution: {integrity: sha512-vHbT+zJEVzllwP+CM+ul7reTEfBR0vgxFe7+lREAsAA7YGsYpboiq2sQNeQeRvh09GfQgs/GyFEvZpJ9cLXpXA==}
|
||||
|
||||
'@ant-design/icons-vue@6.1.0':
|
||||
resolution: {integrity: sha512-EX6bYm56V+ZrKN7+3MT/ubDkvJ5rK/O2t380WFRflDcVFgsvl3NLH7Wxeau6R8DbrO5jWR6DSTC3B6gYFp77AA==}
|
||||
peerDependencies:
|
||||
vue: '>=3.0.3'
|
||||
|
||||
'@ant-design/icons-vue@7.0.1':
|
||||
resolution: {integrity: sha512-eCqY2unfZK6Fe02AwFlDHLfoyEFreP6rBwAZMIJ1LugmfMiVgwWDYlp1YsRugaPtICYOabV1iWxXdP12u9U43Q==}
|
||||
peerDependencies:
|
||||
@@ -565,23 +539,6 @@ packages:
|
||||
'@types/estree@1.0.7':
|
||||
resolution: {integrity: sha512-w28IoSUCJpidD/TGviZwwMJckNESJZXFu7NBZ5YJ4mEUnNraUn9Pm8HSZm/jDF1pDWYKspWE7oVphigUPRakIQ==}
|
||||
|
||||
'@unhead/dom@1.11.20':
|
||||
resolution: {integrity: sha512-jgfGYdOH+xHJF/j8gudjsYu3oIjFyXhCWcgKaw3vQnT616gSqyqnGQGOItL+BQtQZACKNISwIfx5PuOtztMKLA==}
|
||||
|
||||
'@unhead/schema@1.11.20':
|
||||
resolution: {integrity: sha512-0zWykKAaJdm+/Y7yi/Yds20PrUK7XabLe9c3IRcjnwYmSWY6z0Cr19VIs3ozCj8P+GhR+/TI2mwtGlueCEYouA==}
|
||||
|
||||
'@unhead/shared@1.11.20':
|
||||
resolution: {integrity: sha512-1MOrBkGgkUXS+sOKz/DBh4U20DNoITlJwpmvSInxEUNhghSNb56S0RnaHRq0iHkhrO/cDgz2zvfdlRpoPLGI3w==}
|
||||
|
||||
'@unhead/ssr@1.11.20':
|
||||
resolution: {integrity: sha512-j6ehzmdWGAvv0TEZyLE3WBnG1ULnsbKQcLqBDh3fvKS6b3xutcVZB7mjvrVE7ckSZt6WwOtG0ED3NJDS7IjzBA==}
|
||||
|
||||
'@unhead/vue@1.11.20':
|
||||
resolution: {integrity: sha512-sqQaLbwqY9TvLEGeq8Fd7+F2TIuV3nZ5ihVISHjWpAM3y7DwNWRU7NmT9+yYT+2/jw1Vjwdkv5/HvDnvCLrgmg==}
|
||||
peerDependencies:
|
||||
vue: '>=2.7 || >=3'
|
||||
|
||||
'@vitejs/plugin-vue@5.2.3':
|
||||
resolution: {integrity: sha512-IYSLEQj4LgZZuoVpdSUCw3dIynTWQgPlaRP6iAvMle4My0HdYwr5g5wQAfwOeHQBmYwEkqF70nRpSilr6PoUDg==}
|
||||
engines: {node: ^18.0.0 || >=20.0.0}
|
||||
@@ -621,20 +578,12 @@ packages:
|
||||
'@vue/shared@3.5.13':
|
||||
resolution: {integrity: sha512-/hnE/qP5ZoGpol0a5mDi45bOd7t3tjYJBjsgCsivow7D48cJeV5l05RD82lPqi7gRiphZM37rnhW1l6ZoCNNnQ==}
|
||||
|
||||
'@vueuse/head@2.0.0':
|
||||
resolution: {integrity: sha512-ykdOxTGs95xjD4WXE4na/umxZea2Itl0GWBILas+O4oqS7eXIods38INvk3XkJKjqMdWPcpCyLX/DioLQxU1KA==}
|
||||
peerDependencies:
|
||||
vue: '>=2.7 || >=3'
|
||||
|
||||
ant-design-vue@4.2.6:
|
||||
resolution: {integrity: sha512-t7eX13Yj3i9+i5g9lqFyYneoIb3OzTvQjq9Tts1i+eiOd3Eva/6GagxBSXM1fOCjqemIu0FYVE1ByZ/38epR3Q==}
|
||||
engines: {node: '>=12.22.0'}
|
||||
peerDependencies:
|
||||
vue: '>=3.2.0'
|
||||
|
||||
aos@2.3.4:
|
||||
resolution: {integrity: sha512-zh/ahtR2yME4I51z8IttIt4lC1Nw0ktsFtmeDzID1m9naJnWXhCoARaCgNOGXb5CLy3zm+wqmRAEgMYB5E2HUw==}
|
||||
|
||||
array-tree-filter@2.1.0:
|
||||
resolution: {integrity: sha512-4ROwICNlNw/Hqa9v+rk5h22KjmzB1JGTMVKP2AKJBOCgb0yL0ASf0+YvCcLNNwquOHNX48jkeZIJ3a+oOQqKcw==}
|
||||
|
||||
@@ -659,9 +608,6 @@ packages:
|
||||
resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==}
|
||||
engines: {node: '>= 14.16.0'}
|
||||
|
||||
classlist-polyfill@1.2.0:
|
||||
resolution: {integrity: sha512-GzIjNdcEtH4ieA2S8NmrSxv7DfEV5fmixQeyTmqmRmRJPGpRBaSnA2a0VrCjyT8iW8JjEdMbKzDotAJf+ajgaQ==}
|
||||
|
||||
combined-stream@1.0.8:
|
||||
resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==}
|
||||
engines: {node: '>= 0.8'}
|
||||
@@ -701,6 +647,9 @@ packages:
|
||||
resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
echarts@5.6.0:
|
||||
resolution: {integrity: sha512-oTbVTsXfKuEhxftHqL5xprgLoc0k7uScAwtryCgWF6hPYFLRwOUHiFmHGCBKP5NPFNkDVopOieyUqYGH8Fa3kA==}
|
||||
|
||||
enhanced-resolve@5.18.1:
|
||||
resolution: {integrity: sha512-ZSW3ma5GkcQBIpwZTSRAI8N71Uuwgs93IezB7mf7R60tC8ZbJideoDNKjHn2O9KIlx6rkGTTEk1xUCK2E1Y2Yg==}
|
||||
engines: {node: '>=10.13.0'}
|
||||
@@ -793,9 +742,6 @@ packages:
|
||||
resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
hookable@5.5.3:
|
||||
resolution: {integrity: sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==}
|
||||
|
||||
immutable@5.1.2:
|
||||
resolution: {integrity: sha512-qHKXW1q6liAk1Oys6umoaZbDRqjcjgSrbnrifHsfsttza7zcvRAsL7mMV6xWcyhwQy7Xj5v4hhbr6b+iDYwlmQ==}
|
||||
|
||||
@@ -893,12 +839,6 @@ packages:
|
||||
lodash-es@4.17.21:
|
||||
resolution: {integrity: sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==}
|
||||
|
||||
lodash.debounce@4.0.8:
|
||||
resolution: {integrity: sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==}
|
||||
|
||||
lodash.throttle@4.1.1:
|
||||
resolution: {integrity: sha512-wIkUCfVKpVsWo3JSZlc+8MB5it+2AN5W8J7YVMST30UrvcQNZ1Okbj+rbVniijTWE6FGYy4XJq/rHkas8qJMLQ==}
|
||||
|
||||
lodash@4.17.21:
|
||||
resolution: {integrity: sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==}
|
||||
|
||||
@@ -906,11 +846,6 @@ packages:
|
||||
resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==}
|
||||
hasBin: true
|
||||
|
||||
lucide-vue-next@0.507.0:
|
||||
resolution: {integrity: sha512-n0AZRmez4xq5vu5ZOfnrhC5wKBpu5tpwDHA6Ue2+sKyJv3USAPxqIerXLbdYbkHRW1tVZ3U9GE1MACsu6X/Z7Q==}
|
||||
peerDependencies:
|
||||
vue: '>=3.0.1'
|
||||
|
||||
magic-string@0.30.17:
|
||||
resolution: {integrity: sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA==}
|
||||
|
||||
@@ -941,12 +876,6 @@ packages:
|
||||
node-addon-api@7.1.1:
|
||||
resolution: {integrity: sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==}
|
||||
|
||||
omit.js@2.0.2:
|
||||
resolution: {integrity: sha512-hJmu9D+bNB40YpL9jYebQl4lsTW6yEHRTroJzNLqQJYHm7c+NQnJGfZmIWh8S3q3KoaxV1aLhV6B3+0N0/kyJg==}
|
||||
|
||||
packrup@0.1.2:
|
||||
resolution: {integrity: sha512-ZcKU7zrr5GlonoS9cxxrb5HVswGnyj6jQvwFBa6p5VFw7G71VAHcUKL5wyZSU/ECtPM/9gacWxy2KFQKt1gMNA==}
|
||||
|
||||
picocolors@1.1.1:
|
||||
resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
|
||||
|
||||
@@ -1004,10 +933,6 @@ packages:
|
||||
stylis@4.3.6:
|
||||
resolution: {integrity: sha512-yQ3rwFWRfwNUY7H5vpU0wfdkNSnvnJinhF9830Swlaxl03zsOjCfmX0ugac+3LtK0lYSgwL/KXc8oYL3mG4YFQ==}
|
||||
|
||||
swiper@11.2.6:
|
||||
resolution: {integrity: sha512-8aXpYKtjy3DjcbzZfz+/OX/GhcU5h+looA6PbAzHMZT6ESSycSp9nAjPCenczgJyslV+rUGse64LMGpWE3PX9Q==}
|
||||
engines: {node: '>= 4.7.0'}
|
||||
|
||||
tailwindcss@4.1.4:
|
||||
resolution: {integrity: sha512-1ZIUqtPITFbv/DxRmDr5/agPqJwF69d24m9qmM1939TJehgY539CtzeZRjbLt5G6fSy/7YqqYsfvoTEw9xUI2A==}
|
||||
|
||||
@@ -1027,8 +952,8 @@ packages:
|
||||
resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==}
|
||||
engines: {node: '>=8.0'}
|
||||
|
||||
unhead@1.11.20:
|
||||
resolution: {integrity: sha512-3AsNQC0pjwlLqEYHLjtichGWankK8yqmocReITecmpB1H0aOabeESueyy+8X1gyJx4ftZVwo9hqQ4O3fPWffCA==}
|
||||
tslib@2.3.0:
|
||||
resolution: {integrity: sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==}
|
||||
|
||||
vite@6.3.3:
|
||||
resolution: {integrity: sha512-5nXH+QsELbFKhsEfWLkHrvgRpTdGJzqOZ+utSdmPTvwHmvU6ITTm3xx+mRusihkcI8GeC7lCDyn3kDtiki9scw==}
|
||||
@@ -1103,30 +1028,17 @@ packages:
|
||||
warning@4.0.3:
|
||||
resolution: {integrity: sha512-rpJyN222KWIvHJ/F53XSZv0Zl/accqHR8et1kpaMTD/fLCRxtV8iX8czMzY7sVZupTI3zcUTg8eycS2kNF9l6w==}
|
||||
|
||||
zhead@2.2.4:
|
||||
resolution: {integrity: sha512-8F0OI5dpWIA5IGG5NHUg9staDwz/ZPxZtvGVf01j7vHqSyZ0raHY+78atOVxRqb73AotX22uV1pXt3gYSstGag==}
|
||||
zrender@5.6.1:
|
||||
resolution: {integrity: sha512-OFXkDJKcrlx5su2XbzJvj/34Q3m6PvyCZkVPHGYpcCJ52ek4U/ymZyfuV1nKE23AyBJ51E/6Yr0mhZ7xGTO4ag==}
|
||||
|
||||
snapshots:
|
||||
|
||||
'@ant-design-vue/pro-layout@3.2.5(ant-design-vue@4.2.6(vue@3.5.13))(vue@3.5.13)':
|
||||
dependencies:
|
||||
'@ant-design/icons-vue': 6.1.0(vue@3.5.13)
|
||||
ant-design-vue: 4.2.6(vue@3.5.13)
|
||||
omit.js: 2.0.2
|
||||
vue: 3.5.13
|
||||
|
||||
'@ant-design/colors@6.0.0':
|
||||
dependencies:
|
||||
'@ctrl/tinycolor': 3.6.1
|
||||
|
||||
'@ant-design/icons-svg@4.4.2': {}
|
||||
|
||||
'@ant-design/icons-vue@6.1.0(vue@3.5.13)':
|
||||
dependencies:
|
||||
'@ant-design/colors': 6.0.0
|
||||
'@ant-design/icons-svg': 4.4.2
|
||||
vue: 3.5.13
|
||||
|
||||
'@ant-design/icons-vue@7.0.1(vue@3.5.13)':
|
||||
dependencies:
|
||||
'@ant-design/colors': 6.0.0
|
||||
@@ -1424,34 +1336,6 @@ snapshots:
|
||||
|
||||
'@types/estree@1.0.7': {}
|
||||
|
||||
'@unhead/dom@1.11.20':
|
||||
dependencies:
|
||||
'@unhead/schema': 1.11.20
|
||||
'@unhead/shared': 1.11.20
|
||||
|
||||
'@unhead/schema@1.11.20':
|
||||
dependencies:
|
||||
hookable: 5.5.3
|
||||
zhead: 2.2.4
|
||||
|
||||
'@unhead/shared@1.11.20':
|
||||
dependencies:
|
||||
'@unhead/schema': 1.11.20
|
||||
packrup: 0.1.2
|
||||
|
||||
'@unhead/ssr@1.11.20':
|
||||
dependencies:
|
||||
'@unhead/schema': 1.11.20
|
||||
'@unhead/shared': 1.11.20
|
||||
|
||||
'@unhead/vue@1.11.20(vue@3.5.13)':
|
||||
dependencies:
|
||||
'@unhead/schema': 1.11.20
|
||||
'@unhead/shared': 1.11.20
|
||||
hookable: 5.5.3
|
||||
unhead: 1.11.20
|
||||
vue: 3.5.13
|
||||
|
||||
'@vitejs/plugin-vue@5.2.3(vite@6.3.3(jiti@2.4.2)(lightningcss@1.29.2)(sass@1.88.0))(vue@3.5.13)':
|
||||
dependencies:
|
||||
vite: 6.3.3(jiti@2.4.2)(lightningcss@1.29.2)(sass@1.88.0)
|
||||
@@ -1513,14 +1397,6 @@ snapshots:
|
||||
|
||||
'@vue/shared@3.5.13': {}
|
||||
|
||||
'@vueuse/head@2.0.0(vue@3.5.13)':
|
||||
dependencies:
|
||||
'@unhead/dom': 1.11.20
|
||||
'@unhead/schema': 1.11.20
|
||||
'@unhead/ssr': 1.11.20
|
||||
'@unhead/vue': 1.11.20(vue@3.5.13)
|
||||
vue: 3.5.13
|
||||
|
||||
ant-design-vue@4.2.6(vue@3.5.13):
|
||||
dependencies:
|
||||
'@ant-design/colors': 6.0.0
|
||||
@@ -1547,12 +1423,6 @@ snapshots:
|
||||
vue-types: 3.0.2(vue@3.5.13)
|
||||
warning: 4.0.3
|
||||
|
||||
aos@2.3.4:
|
||||
dependencies:
|
||||
classlist-polyfill: 1.2.0
|
||||
lodash.debounce: 4.0.8
|
||||
lodash.throttle: 4.1.1
|
||||
|
||||
array-tree-filter@2.1.0: {}
|
||||
|
||||
async-validator@4.2.5: {}
|
||||
@@ -1580,8 +1450,7 @@ snapshots:
|
||||
chokidar@4.0.3:
|
||||
dependencies:
|
||||
readdirp: 4.1.2
|
||||
|
||||
classlist-polyfill@1.2.0: {}
|
||||
optional: true
|
||||
|
||||
combined-stream@1.0.8:
|
||||
dependencies:
|
||||
@@ -1612,6 +1481,11 @@ snapshots:
|
||||
es-errors: 1.3.0
|
||||
gopd: 1.2.0
|
||||
|
||||
echarts@5.6.0:
|
||||
dependencies:
|
||||
tslib: 2.3.0
|
||||
zrender: 5.6.1
|
||||
|
||||
enhanced-resolve@5.18.1:
|
||||
dependencies:
|
||||
graceful-fs: 4.2.11
|
||||
@@ -1719,9 +1593,8 @@ snapshots:
|
||||
dependencies:
|
||||
function-bind: 1.1.2
|
||||
|
||||
hookable@5.5.3: {}
|
||||
|
||||
immutable@5.1.2: {}
|
||||
immutable@5.1.2:
|
||||
optional: true
|
||||
|
||||
is-extglob@2.1.1:
|
||||
optional: true
|
||||
@@ -1787,20 +1660,12 @@ snapshots:
|
||||
|
||||
lodash-es@4.17.21: {}
|
||||
|
||||
lodash.debounce@4.0.8: {}
|
||||
|
||||
lodash.throttle@4.1.1: {}
|
||||
|
||||
lodash@4.17.21: {}
|
||||
|
||||
loose-envify@1.4.0:
|
||||
dependencies:
|
||||
js-tokens: 4.0.0
|
||||
|
||||
lucide-vue-next@0.507.0(vue@3.5.13):
|
||||
dependencies:
|
||||
vue: 3.5.13
|
||||
|
||||
magic-string@0.30.17:
|
||||
dependencies:
|
||||
'@jridgewell/sourcemap-codec': 1.5.0
|
||||
@@ -1826,10 +1691,6 @@ snapshots:
|
||||
node-addon-api@7.1.1:
|
||||
optional: true
|
||||
|
||||
omit.js@2.0.2: {}
|
||||
|
||||
packrup@0.1.2: {}
|
||||
|
||||
picocolors@1.1.1: {}
|
||||
|
||||
picomatch@2.3.1:
|
||||
@@ -1853,7 +1714,8 @@ snapshots:
|
||||
|
||||
proxy-from-env@1.1.0: {}
|
||||
|
||||
readdirp@4.1.2: {}
|
||||
readdirp@4.1.2:
|
||||
optional: true
|
||||
|
||||
resize-observer-polyfill@1.5.1: {}
|
||||
|
||||
@@ -1890,6 +1752,7 @@ snapshots:
|
||||
source-map-js: 1.2.1
|
||||
optionalDependencies:
|
||||
'@parcel/watcher': 2.5.1
|
||||
optional: true
|
||||
|
||||
scroll-into-view-if-needed@2.2.31:
|
||||
dependencies:
|
||||
@@ -1901,8 +1764,6 @@ snapshots:
|
||||
|
||||
stylis@4.3.6: {}
|
||||
|
||||
swiper@11.2.6: {}
|
||||
|
||||
tailwindcss@4.1.4: {}
|
||||
|
||||
tapable@2.2.1: {}
|
||||
@@ -1919,12 +1780,7 @@ snapshots:
|
||||
is-number: 7.0.0
|
||||
optional: true
|
||||
|
||||
unhead@1.11.20:
|
||||
dependencies:
|
||||
'@unhead/dom': 1.11.20
|
||||
'@unhead/schema': 1.11.20
|
||||
'@unhead/shared': 1.11.20
|
||||
hookable: 5.5.3
|
||||
tslib@2.3.0: {}
|
||||
|
||||
vite@6.3.3(jiti@2.4.2)(lightningcss@1.29.2)(sass@1.88.0):
|
||||
dependencies:
|
||||
@@ -1966,4 +1822,6 @@ snapshots:
|
||||
dependencies:
|
||||
loose-envify: 1.4.0
|
||||
|
||||
zhead@2.2.4: {}
|
||||
zrender@5.6.1:
|
||||
dependencies:
|
||||
tslib: 2.3.0
|
||||
|
||||
53
src/App.vue
53
src/App.vue
@@ -1,10 +1,53 @@
|
||||
<script setup>
|
||||
// 应用根组件:
|
||||
// - ConfigProvider 注入双主题 token(暗色暖调优先,antd 组件跟随切换)
|
||||
// - 全局运行详情抽屉 / 命令面板挂在这里(任何页面可通过 app store 打开)
|
||||
import { computed } from 'vue'
|
||||
import { theme as antdTheme } from 'ant-design-vue'
|
||||
import zhCN from 'ant-design-vue/es/locale/zh_CN'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import RunDetailDrawer from '@/components/RunDetailDrawer.vue'
|
||||
import CommandPalette from '@/components/CommandPalette.vue'
|
||||
|
||||
const app = useAppStore()
|
||||
|
||||
// antd 主题 token:暗色用暖黑 + 琥珀金;亮色用宝蓝(与旧面板同源)
|
||||
const themeConfig = computed(() => {
|
||||
const dark = app.theme === 'dark'
|
||||
return {
|
||||
algorithm: dark ? antdTheme.darkAlgorithm : antdTheme.defaultAlgorithm,
|
||||
token: {
|
||||
colorPrimary: dark ? '#f59e0b' : '#4361ee',
|
||||
colorInfo: dark ? '#f59e0b' : '#4361ee',
|
||||
colorSuccess: '#22c55e',
|
||||
colorError: '#ef4444',
|
||||
colorWarning: '#f59e0b',
|
||||
borderRadius: 8,
|
||||
// 容器带透明度:配合 backdrop-blur 呈现玻璃感
|
||||
colorBgContainer: dark ? 'rgba(43,32,19,0.55)' : 'rgba(255,255,255,0.8)',
|
||||
colorBgElevated: dark ? '#2b2013' : '#ffffff',
|
||||
colorBgLayout: 'transparent',
|
||||
colorBorder: dark ? 'rgba(245,158,11,0.18)' : 'rgba(67,97,238,0.16)',
|
||||
colorBorderSecondary: dark ? 'rgba(245,158,11,0.10)' : 'rgba(67,97,238,0.08)',
|
||||
colorText: dark ? '#f3e9d6' : '#1e2433',
|
||||
colorTextSecondary: dark ? '#bfae8d' : '#5a6478',
|
||||
colorTextTertiary: dark ? '#8d7c60' : '#98a1b3',
|
||||
fontSize: 13
|
||||
},
|
||||
components: {
|
||||
Table: { cellPaddingBlockSM: 6 },
|
||||
Card: { paddingLG: 16 }
|
||||
}
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<router-view />
|
||||
<a-config-provider :theme="themeConfig" :locale="zhCN">
|
||||
<router-view />
|
||||
<!-- 全局运行详情抽屉:仪表盘/运行记录/日志/命令面板共用同一个实例 -->
|
||||
<RunDetailDrawer />
|
||||
<!-- 全局命令面板 Ctrl+K -->
|
||||
<CommandPalette />
|
||||
</a-config-provider>
|
||||
</template>
|
||||
|
||||
<style lang='scss' scoped>
|
||||
|
||||
</style>
|
||||
|
||||
21
src/api/agent.js
Normal file
21
src/api/agent.js
Normal file
@@ -0,0 +1,21 @@
|
||||
import { get, post } from './request'
|
||||
|
||||
// ---------------- 运行观测(内存环形缓冲,最近 200 条) ----------------
|
||||
export const getRuns = (params, opts) => get('/agent/runs', params, opts) // {limit, scene, status}
|
||||
export const getRunDetail = (id, opts) => get(`/agent/runs/${id}`, {}, opts)
|
||||
export const getStats = (opts) => get('/agent/stats', {}, opts)
|
||||
|
||||
// ---------------- 实时日志 / 系统状态 / 配置 ----------------
|
||||
export const getLogs = (params, opts) => get('/agent/logs', params, opts) // {since_id, limit}
|
||||
export const getSystem = (opts) => get('/agent/system', {}, opts)
|
||||
export const getAgentConfig = (opts) => get('/agent/config', {}, opts)
|
||||
|
||||
// ---------------- 调试工具 ----------------
|
||||
export const guardTest = (text) => post('/agent/guard-test', { text })
|
||||
export const kbTest = (data) => post('/agent/kb-test', data) // {query, top_k}
|
||||
export const enhanceTest = (data) => post('/agent/enhance', data) // 全链路测试
|
||||
|
||||
// ---------------- AI 生成历史(DB 长期数据,只读) ----------------
|
||||
export const getHistory = (params) => get('/agent/history', params)
|
||||
export const getHistoryDetail = (id) => get(`/agent/history/${id}`)
|
||||
export const getHistoryScenes = () => get('/agent/history/scenes', {}, { silent: true })
|
||||
6
src/api/auth.js
Normal file
6
src/api/auth.js
Normal file
@@ -0,0 +1,6 @@
|
||||
import { get, post } from './request'
|
||||
|
||||
// 登录鉴权相关接口
|
||||
export const loginApi = (data) => post('/auth/login', data) // {username, password, captcha}
|
||||
export const refreshApi = () => post('/auth/refresh') // 带旧 token 换新 token
|
||||
export const profileApi = () => get('/auth/profile', {}, { silent: true }) // 会话有效性校验
|
||||
35
src/api/kb.js
Normal file
35
src/api/kb.js
Normal file
@@ -0,0 +1,35 @@
|
||||
import { get, post, put, del, upload } from './request'
|
||||
|
||||
// ---------------- 知识库:库管理 ----------------
|
||||
export const getLibraries = (opts) => get('/kb/admin/libraries', {}, opts)
|
||||
export const getLibrary = (id) => get(`/kb/admin/libraries/${id}`)
|
||||
export const createLibrary = (data) => post('/kb/admin/libraries', data)
|
||||
export const deleteLibrary = (id) => del(`/kb/admin/libraries/${id}`)
|
||||
|
||||
// ---------------- 知识库:文档管理 ----------------
|
||||
export const getDocs = (libraryId) => get(`/kb/admin/libraries/${libraryId}/docs`)
|
||||
export const getDoc = (id) => get(`/kb/admin/docs/${id}`)
|
||||
export const deleteDoc = (id) => del(`/kb/admin/docs/${id}`)
|
||||
// 导入:formData 需含 library_id / file,可选 title / max_len / overlap
|
||||
// opts 可传 { silent: true }(批量导入逐文件报错由队列 UI 展示,不弹全局 message)
|
||||
export const importDoc = (formData, opts) => upload('/kb/admin/docs/import', formData, { timeout: 300000, ...opts })
|
||||
|
||||
// ---------------- 知识库:药品抓取任务 ----------------
|
||||
export const getCrawlSources = () => get('/kb/admin/crawl/sources')
|
||||
export const getCrawlTasks = (opts) => get('/kb/admin/crawl/tasks', {}, opts)
|
||||
export const createCrawlTask = (data) => post('/kb/admin/crawl/tasks', data)
|
||||
export const updateCrawlTask = (id, data) => put(`/kb/admin/crawl/tasks/${id}`, data)
|
||||
export const deleteCrawlTask = (id) => del(`/kb/admin/crawl/tasks/${id}`)
|
||||
export const runCrawlTask = (id) => post(`/kb/admin/crawl/tasks/${id}/run`)
|
||||
export const getCrawlLogs = (id, limit = 20) => get(`/kb/admin/crawl/tasks/${id}/logs`, { limit })
|
||||
// 重新分段:用新参数对已导入文档重切(旧分段软删)
|
||||
export const rechunkDoc = (id, data) => post(`/kb/admin/docs/${id}/rechunk`, data)
|
||||
|
||||
// ---------------- 知识库:分段管理 ----------------
|
||||
export const getChunks = (docId) => get(`/kb/admin/docs/${docId}/chunks`)
|
||||
export const updateChunk = (id, data) => put(`/kb/admin/chunks/${id}`, data)
|
||||
// 批量启用/禁用/删除:{ids: [], action: 'enable'|'disable'|'delete'}
|
||||
export const batchChunks = (data) => put('/kb/admin/chunks/batch', data)
|
||||
|
||||
// ---------------- 知识库:检索 ----------------
|
||||
export const kbSearch = (data) => post('/kb/admin/search', data) // {library_id, query, top_k}
|
||||
8
src/api/models.js
Normal file
8
src/api/models.js
Normal file
@@ -0,0 +1,8 @@
|
||||
import { get, post } from './request'
|
||||
|
||||
// 模型管理接口
|
||||
export const getModelRoutes = (opts) => get('/models/routes', {}, opts) // 场景路由 + fallback 链
|
||||
export const registerModelRoute = (data) => post('/models/route', data) // {scene, provider} 热更新
|
||||
export const modelTest = (data) => post('/models/test', data) // {provider?, message?}
|
||||
export const invalidateModelCache = () => post('/models/invalidate-cache') // 失效 LLM 配置缓存
|
||||
export const getActiveConfig = (opts) => get('/models/active-config', {}, opts) // 生效配置(脱敏)
|
||||
@@ -1,75 +1,99 @@
|
||||
import axios from 'axios';
|
||||
import {message as msg} from "ant-design-vue";
|
||||
import axios from 'axios'
|
||||
import { message as msg } from 'ant-design-vue'
|
||||
|
||||
// ========================================================================
|
||||
// HTTP 客户端 —— 适配 Go 端约定
|
||||
// ========================================================================
|
||||
// Go 端所有接口 HTTP 状态基本是 200,业务状态在 body 的 code 字段:
|
||||
// { code: 200, data: ... } 成功
|
||||
// { code: 4xx/5xx, message: '' } 业务失败
|
||||
// 个别接口把数据放顶层(如 /models/routes 的 routes 字段),
|
||||
// 所以这里统一返回完整 body,由调用方取 res.data / res.routes。
|
||||
//
|
||||
// 鉴权:登录后 token 存 localStorage,每个请求带 Bearer JWT;
|
||||
// 401 统一清会话跳登录页(带 redirect 回跳)。
|
||||
// ========================================================================
|
||||
|
||||
export const TOKEN_KEY = 'agent_admin_token'
|
||||
|
||||
// 创建 Axios 实例
|
||||
const service = axios.create({
|
||||
baseURL: '/api/', // 这里可以设置你的 API 基础地址
|
||||
timeout: 5000 // 请求超时时间
|
||||
});
|
||||
baseURL: '/api/v1',
|
||||
// enhance 全链路测试可能跑 2 分钟,超时放宽
|
||||
timeout: 150000
|
||||
})
|
||||
|
||||
// 请求拦截器
|
||||
service.interceptors.request.use(
|
||||
config => {
|
||||
// 从本地存储中获取 token
|
||||
const token = localStorage.getItem('token');
|
||||
if (token) {
|
||||
// 设置请求头中的 Authorization
|
||||
config.headers['Authorization'] = `Bearer ${token}`;
|
||||
}
|
||||
return config;
|
||||
},
|
||||
error => {
|
||||
console.log(error); // 打印错误信息
|
||||
return Promise.reject(error);
|
||||
// 请求拦截:带上登录 token
|
||||
service.interceptors.request.use(config => {
|
||||
const token = localStorage.getItem(TOKEN_KEY)
|
||||
if (token) {
|
||||
config.headers['Authorization'] = `Bearer ${token}`
|
||||
}
|
||||
);
|
||||
return config
|
||||
})
|
||||
|
||||
// 响应拦截器
|
||||
// 跳登录页(保留当前路径用于登录后回跳)
|
||||
function gotoLogin() {
|
||||
localStorage.removeItem(TOKEN_KEY)
|
||||
localStorage.removeItem('agent_admin_user')
|
||||
const base = import.meta.env.BASE_URL || '/'
|
||||
// 当前已在登录页就不再跳(防循环)
|
||||
if (window.location.pathname.startsWith(base + 'login')) return
|
||||
const redirect = encodeURIComponent(window.location.pathname + window.location.search)
|
||||
window.location.href = `${base}login?redirect=${redirect}`
|
||||
}
|
||||
|
||||
// 响应拦截:统一处理业务 code
|
||||
service.interceptors.response.use(
|
||||
response => {
|
||||
const {code, result, message} = response.data;
|
||||
if (code === 0) {
|
||||
return result;
|
||||
} else {
|
||||
if (code === 401) {
|
||||
msg.error(message).then(r => {
|
||||
localStorage.removeItem('token');
|
||||
localStorage.removeItem('user');
|
||||
window.location.href = '/'
|
||||
msg.destroy()
|
||||
})
|
||||
return;
|
||||
const body = response.data
|
||||
// 非 JSON(如导出下载)原样返回
|
||||
if (typeof body !== 'object' || body === null) return body
|
||||
const { code, message } = body
|
||||
if (code === undefined || code === 200) return body
|
||||
if (code === 401) {
|
||||
// 登录接口自己的 401(密码错误)不跳转,由登录页展示
|
||||
if ((response.config.url || '').includes('/auth/login')) {
|
||||
return Promise.reject(new Error(message || '登录失败'))
|
||||
}
|
||||
msg.error(message).then(r => {
|
||||
msg.destroy()
|
||||
})
|
||||
gotoLogin()
|
||||
return Promise.reject(new Error(message || '会话失效'))
|
||||
}
|
||||
// silent 标记的请求(轮询类)不弹全局报错
|
||||
if (!response.config.silentError) {
|
||||
msg.error(message || `请求失败(${code})`)
|
||||
}
|
||||
return Promise.reject(new Error(message || `请求失败(${code})`))
|
||||
},
|
||||
error => {
|
||||
console.log('err' + error); // 打印错误信息
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
// 封装 get 请求
|
||||
const get = (url, params = {}) => {
|
||||
return service.get(url, { params });
|
||||
};
|
||||
|
||||
// 封装 post 请求
|
||||
const post = (url, data = {}) => {
|
||||
return service.post(url, data);
|
||||
};
|
||||
|
||||
// 封装上传文件请求
|
||||
const upload = (url, file) => {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
return service.post(url, formData, {
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data'
|
||||
// HTTP 层错误(网络断开 / 503 限流 / 超时)
|
||||
if (error.response && error.response.status === 401) {
|
||||
gotoLogin()
|
||||
} else if (error.response && error.response.data && error.response.data.message) {
|
||||
// Go 端 503 并发限流等场景带 message
|
||||
if (!error.config?.silentError) msg.error(error.response.data.message)
|
||||
} else if (!error.config?.silentError) {
|
||||
msg.error(error.message?.includes('timeout') ? '请求超时' : '网络异常,服务可能不可达')
|
||||
}
|
||||
});
|
||||
};
|
||||
return Promise.reject(error)
|
||||
}
|
||||
)
|
||||
|
||||
export { get, post, upload };
|
||||
// opts 支持 { silent: true }(轮询类不弹全局报错)与其他 axios 配置
|
||||
function buildCfg(opts = {}) {
|
||||
const { silent, ...rest } = opts
|
||||
return { silentError: !!silent, ...rest }
|
||||
}
|
||||
|
||||
export const get = (url, params = {}, opts = {}) => service.get(url, { params, ...buildCfg(opts) })
|
||||
export const post = (url, data = {}, opts = {}) => service.post(url, data, buildCfg(opts))
|
||||
export const put = (url, data = {}, opts = {}) => service.put(url, data, buildCfg(opts))
|
||||
export const del = (url, opts = {}) => service.delete(url, buildCfg(opts))
|
||||
|
||||
// 上传(FormData 直传,附加字段由调用方拼进 formData)
|
||||
export const upload = (url, formData, opts = {}) =>
|
||||
service.post(url, formData, { headers: { 'Content-Type': 'multipart/form-data' }, ...buildCfg(opts) })
|
||||
|
||||
// 无拦截器的裸探活(离线检测用:不能被 401 跳转/报错弹窗干扰)
|
||||
export const probeHealth = () => axios.get('/health', { timeout: 5000 })
|
||||
|
||||
export default service
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
import {upload} from '../request.js'
|
||||
|
||||
const prefix = 'upload'
|
||||
|
||||
export function uploadImageApi(credentials) {
|
||||
return upload(`${prefix}/image`, credentials)
|
||||
}
|
||||
export function uploadVideoApi(credentials) {
|
||||
return upload(`${prefix}/video`, credentials)
|
||||
}
|
||||
@@ -1,92 +0,0 @@
|
||||
import {get, post} from '../request.js'
|
||||
|
||||
const prefix = 'user'
|
||||
|
||||
/**
|
||||
* 登录
|
||||
* @param credentials
|
||||
* @returns {Promise<axios.AxiosResponse<any>>}
|
||||
*/
|
||||
export function loginApi(credentials) {
|
||||
return post('login', credentials)
|
||||
}
|
||||
/**
|
||||
* 注册
|
||||
* @param credentials
|
||||
* @returns {Promise<axios.AxiosResponse<any>>}
|
||||
*/
|
||||
export function registerApi(credentials) {
|
||||
return post('register', credentials)
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取用户列表
|
||||
* @param credentials
|
||||
* @returns {Promise<axios.AxiosResponse<any>>}
|
||||
*/
|
||||
export function userListApi(credentials) {
|
||||
return get(`${prefix}/list`, credentials)
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前登录用户信息
|
||||
* @param credentials
|
||||
* @returns {Promise<axios.AxiosResponse<any>>}
|
||||
*/
|
||||
export function userMyInfoApi(credentials) {
|
||||
return get(`${prefix}/my-info`, credentials)
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取用户统计信息
|
||||
* @param credentials
|
||||
* @returns {Promise<axios.AxiosResponse<any>>}
|
||||
*/
|
||||
export function userStatsApi(credentials) {
|
||||
return get(`${prefix}/user-stats`, credentials)
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建用户
|
||||
* @param credentials
|
||||
* @returns {Promise<axios.AxiosResponse<any>>}
|
||||
*/
|
||||
export function createUserApi(credentials) {
|
||||
return post(`${prefix}/create`, credentials)
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新用户
|
||||
* @param credentials
|
||||
* @returns {Promise<axios.AxiosResponse<any>>}
|
||||
*/
|
||||
export function updateUserApi(credentials) {
|
||||
return post(`${prefix}/update`, credentials)
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除用户
|
||||
* @param credentials
|
||||
* @returns {Promise<axios.AxiosResponse<any>>}
|
||||
*/
|
||||
export function deleteUserApi(credentials) {
|
||||
return post(`${prefix}/delete`, credentials)
|
||||
}
|
||||
|
||||
/**
|
||||
* 重置密码
|
||||
* @param credentials
|
||||
* @returns {Promise<axios.AxiosResponse<any>>}
|
||||
*/
|
||||
export function resetPasswordApi(credentials) {
|
||||
return post(`${prefix}/reset-password`, credentials)
|
||||
}
|
||||
|
||||
/**
|
||||
* 重置密码
|
||||
* @param credentials
|
||||
* @returns {Promise<axios.AxiosResponse<any>>}
|
||||
*/
|
||||
export function changePasswordApi(credentials) {
|
||||
return post(`${prefix}/change-password`, credentials)
|
||||
}
|
||||
117
src/components/CommandPalette.vue
Normal file
117
src/components/CommandPalette.vue
Normal file
@@ -0,0 +1,117 @@
|
||||
<script setup>
|
||||
// 命令面板(Ctrl+K):所有页面与快捷动作的兜底入口
|
||||
// - 模糊搜索跳页(11 个页面)
|
||||
// - 快捷动作:失效 LLM 缓存 / 切换主题 / 重新引导 / 退出登录
|
||||
// - 输入纯数字:直接打开对应运行详情(#id)
|
||||
import { ref, computed, watch, nextTick } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { message, Modal } from 'ant-design-vue'
|
||||
import {
|
||||
DashboardOutlined, UnorderedListOutlined, HistoryOutlined, BarChartOutlined,
|
||||
FileTextOutlined, DesktopOutlined, BugOutlined, ApiOutlined, SettingOutlined,
|
||||
DatabaseOutlined, SearchOutlined, ThunderboltOutlined, BulbOutlined,
|
||||
LogoutOutlined, QuestionCircleOutlined, NumberOutlined, CloudDownloadOutlined
|
||||
} from '@ant-design/icons-vue'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
import { invalidateModelCache } from '@/api/models'
|
||||
|
||||
const app = useAppStore()
|
||||
const userStore = useUserStore()
|
||||
const router = useRouter()
|
||||
|
||||
const keyword = ref('')
|
||||
const activeIndex = ref(0)
|
||||
const inputRef = ref(null)
|
||||
|
||||
// 页面项 + 动作项统一结构 {icon, label, keywords, run}
|
||||
const pageItems = [
|
||||
{ icon: DashboardOutlined, label: '仪表盘', keywords: 'dashboard yibiao', to: '/dashboard' },
|
||||
{ icon: UnorderedListOutlined, label: '运行记录', keywords: 'runs yunxing', to: '/runs' },
|
||||
{ icon: HistoryOutlined, label: '历史记录', keywords: 'history lishi db', to: '/history' },
|
||||
{ icon: BarChartOutlined, label: '统计分析', keywords: 'stats tongji', to: '/stats' },
|
||||
{ icon: FileTextOutlined, label: '实时日志', keywords: 'logs rizhi', to: '/logs' },
|
||||
{ icon: DesktopOutlined, label: '系统状态', keywords: 'system xitong', to: '/system' },
|
||||
{ icon: BugOutlined, label: '调试工具', keywords: 'debug tiaoshi enhance', to: '/debug' },
|
||||
{ icon: ApiOutlined, label: '模型管理', keywords: 'models moxing route', to: '/models' },
|
||||
{ icon: SettingOutlined, label: '配置总览', keywords: 'config peizhi', to: '/config' },
|
||||
{ icon: DatabaseOutlined, label: '知识库管理', keywords: 'kb zhishiku import', to: '/kb' },
|
||||
{ icon: SearchOutlined, label: '知识库检索', keywords: 'search jiansuo', to: '/kb-search' },
|
||||
{ icon: CloudDownloadOutlined, label: '药品抓取', keywords: 'crawl yaopin zhuaqu spider', to: '/kb-crawl' }
|
||||
]
|
||||
const actionItems = [
|
||||
{
|
||||
icon: ThunderboltOutlined, label: '动作:失效 LLM 配置缓存', keywords: 'invalidate cache huancun shixiao',
|
||||
run: async () => {
|
||||
const res = await invalidateModelCache()
|
||||
message.success(res.message || '缓存已失效')
|
||||
}
|
||||
},
|
||||
{ icon: BulbOutlined, label: '动作:切换亮/暗主题', keywords: 'theme zhuti dark light', run: () => app.toggleTheme() },
|
||||
{ icon: QuestionCircleOutlined, label: '动作:重新播放新手引导', keywords: 'tour yindao help', run: () => { router.push('/dashboard'); setTimeout(() => app.startTour(), 400) } },
|
||||
{
|
||||
icon: LogoutOutlined, label: '动作:退出登录', keywords: 'logout tuichu',
|
||||
run: () => Modal.confirm({
|
||||
title: '确定退出登录?',
|
||||
onOk: () => { userStore.logout(); router.push('/login') }
|
||||
})
|
||||
}
|
||||
]
|
||||
|
||||
const filtered = computed(() => {
|
||||
const kw = keyword.value.trim().toLowerCase()
|
||||
// 纯数字:提供「打开运行 #id」直达项
|
||||
const items = []
|
||||
if (/^\d+$/.test(kw)) {
|
||||
items.push({ icon: NumberOutlined, label: `打开运行详情 #${kw}`, run: () => app.openRun(Number(kw)) })
|
||||
}
|
||||
const all = [...pageItems, ...actionItems]
|
||||
if (!kw) return [...items, ...all]
|
||||
return [...items, ...all.filter(i => i.label.toLowerCase().includes(kw) || (i.keywords || '').includes(kw))]
|
||||
})
|
||||
|
||||
watch(() => app.paletteOpen, async (open) => {
|
||||
if (open) {
|
||||
keyword.value = ''
|
||||
activeIndex.value = 0
|
||||
await nextTick()
|
||||
inputRef.value?.focus()
|
||||
}
|
||||
})
|
||||
watch(keyword, () => { activeIndex.value = 0 })
|
||||
|
||||
function execute(item) {
|
||||
app.paletteOpen = false
|
||||
if (item.to) router.push(item.to)
|
||||
else if (item.run) item.run()
|
||||
}
|
||||
function onKeydown(e) {
|
||||
if (e.key === 'ArrowDown') { e.preventDefault(); activeIndex.value = Math.min(activeIndex.value + 1, filtered.value.length - 1) }
|
||||
else if (e.key === 'ArrowUp') { e.preventDefault(); activeIndex.value = Math.max(activeIndex.value - 1, 0) }
|
||||
else if (e.key === 'Enter') { const item = filtered.value[activeIndex.value]; if (item) execute(item) }
|
||||
else if (e.key === 'Escape') { app.paletteOpen = false }
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<a-modal :open="app.paletteOpen" :footer="null" :closable="false" :width="560"
|
||||
style="top: 12vh" @cancel="app.paletteOpen = false">
|
||||
<a-input ref="inputRef" v-model:value="keyword" size="large" placeholder="搜索页面 / 动作 / 输入运行 ID…"
|
||||
:bordered="false" @keydown="onKeydown">
|
||||
<template #prefix><SearchOutlined style="color: var(--text-3)" /></template>
|
||||
<template #suffix><a-tag :bordered="false" class="mono">Esc</a-tag></template>
|
||||
</a-input>
|
||||
<a-divider class="!my-2" />
|
||||
<div class="max-h-[46vh] overflow-auto">
|
||||
<div v-for="(item, i) in filtered" :key="item.label"
|
||||
class="flex items-center gap-3 px-3 py-2 rounded-lg cursor-pointer transition-colors"
|
||||
:style="i === activeIndex ? 'background: rgba(245,158,11,0.12)' : ''"
|
||||
@mouseenter="activeIndex = i" @click="execute(item)">
|
||||
<component :is="item.icon" style="color: var(--primary)" />
|
||||
<span class="text-sm" style="color: var(--text-1)">{{ item.label }}</span>
|
||||
<span v-if="i === activeIndex" class="ml-auto text-xs mono" style="color: var(--text-3)">Enter</span>
|
||||
</div>
|
||||
<div v-if="!filtered.length" class="text-center py-6 text-xs" style="color: var(--text-3)">无匹配项</div>
|
||||
</div>
|
||||
</a-modal>
|
||||
</template>
|
||||
43
src/components/EChart.vue
Normal file
43
src/components/EChart.vue
Normal file
@@ -0,0 +1,43 @@
|
||||
<script setup>
|
||||
// ECharts 轻封装:option 变化自动 setOption、容器尺寸变化自动 resize、
|
||||
// 主题切换时整图重建(背景透明,配色由调用方按 CSS 变量传入)
|
||||
import { ref, watch, onMounted, onUnmounted } from 'vue'
|
||||
import * as echarts from 'echarts'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
|
||||
const props = defineProps({
|
||||
option: { type: Object, required: true },
|
||||
height: { type: String, default: '280px' }
|
||||
})
|
||||
|
||||
const el = ref(null)
|
||||
const app = useAppStore()
|
||||
let chart = null
|
||||
let resizeObserver = null
|
||||
|
||||
function init() {
|
||||
if (!el.value) return
|
||||
if (chart) chart.dispose()
|
||||
chart = echarts.init(el.value, null, { renderer: 'canvas' })
|
||||
chart.setOption(props.option, true)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
init()
|
||||
resizeObserver = new ResizeObserver(() => chart && chart.resize())
|
||||
resizeObserver.observe(el.value)
|
||||
})
|
||||
onUnmounted(() => {
|
||||
resizeObserver?.disconnect()
|
||||
chart?.dispose()
|
||||
chart = null
|
||||
})
|
||||
|
||||
watch(() => props.option, (opt) => { chart && chart.setOption(opt, true) }, { deep: true })
|
||||
// 主题切换重建(tooltip 底色等内部样式需要重建才能干净切换)
|
||||
watch(() => app.theme, () => init())
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div ref="el" :style="{ height, width: '100%' }"></div>
|
||||
</template>
|
||||
16
src/components/EmptyHint.vue
Normal file
16
src/components/EmptyHint.vue
Normal file
@@ -0,0 +1,16 @@
|
||||
<script setup>
|
||||
// 空态引导:一句话说明 + 下一步按钮(空态即引导原则)
|
||||
defineProps({
|
||||
text: { type: String, default: '暂无数据' },
|
||||
actionText: { type: String, default: '' }
|
||||
})
|
||||
const emit = defineEmits(['action'])
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="py-10 text-center">
|
||||
<a-empty :description="text">
|
||||
<a-button v-if="actionText" type="primary" @click="emit('action')">{{ actionText }}</a-button>
|
||||
</a-empty>
|
||||
</div>
|
||||
</template>
|
||||
19
src/components/GlassCard.vue
Normal file
19
src/components/GlassCard.vue
Normal file
@@ -0,0 +1,19 @@
|
||||
<script setup>
|
||||
// 毛玻璃卡片:统一「glass 容器 + 标题行 + 右上角动作区」结构
|
||||
// 之前每个页面手写一遍 div.glass + flex 标题行,改动样式要改 N 处——收敛到这里
|
||||
defineProps({
|
||||
title: { type: String, default: '' },
|
||||
// 无内边距场景(如内嵌表格)可传 pad=""
|
||||
pad: { type: String, default: 'p-4' }
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="glass" :class="pad">
|
||||
<div v-if="title || $slots.extra" class="flex items-center justify-between mb-3">
|
||||
<span class="text-sm font-medium" style="color: var(--text-1)">{{ title }}</span>
|
||||
<span class="flex items-center gap-1"><slot name="extra" /></span>
|
||||
</div>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
20
src/components/HelpTip.vue
Normal file
20
src/components/HelpTip.vue
Normal file
@@ -0,0 +1,20 @@
|
||||
<script setup>
|
||||
// 页面一次性提示条:首次进入关键复杂页时说明该页能干什么,
|
||||
// 关闭后记 localStorage 不再出现(新手引导的组成部分)
|
||||
import { ref } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
id: { type: String, required: true }, // 唯一 key,如 'kb-manage'
|
||||
text: { type: String, required: true }
|
||||
})
|
||||
const storageKey = `agent_tip_closed_${props.id}`
|
||||
const visible = ref(localStorage.getItem(storageKey) !== '1')
|
||||
function close() {
|
||||
visible.value = false
|
||||
localStorage.setItem(storageKey, '1')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<a-alert v-if="visible" :message="text" type="info" show-icon closable class="!mb-4" @close="close" />
|
||||
</template>
|
||||
42
src/components/JsonViewer.vue
Normal file
42
src/components/JsonViewer.vue
Normal file
@@ -0,0 +1,42 @@
|
||||
<script setup>
|
||||
// 轻量 JSON 展示:pre + 复制按钮(不引重型依赖)
|
||||
// value 支持对象 / JSON 字符串 / 普通字符串
|
||||
import { computed, ref } from 'vue'
|
||||
import { message } from 'ant-design-vue'
|
||||
import { CopyOutlined, CheckOutlined } from '@ant-design/icons-vue'
|
||||
import { copyText } from '@/utils/format'
|
||||
|
||||
const props = defineProps({
|
||||
value: { type: [Object, Array, String, Number, Boolean], default: null },
|
||||
maxHeight: { type: String, default: '360px' }
|
||||
})
|
||||
|
||||
const text = computed(() => {
|
||||
if (props.value == null) return ''
|
||||
if (typeof props.value === 'string') {
|
||||
// 字符串先尝试当 JSON 美化,失败就原样展示
|
||||
try { return JSON.stringify(JSON.parse(props.value), null, 2) } catch { return props.value }
|
||||
}
|
||||
try { return JSON.stringify(props.value, null, 2) } catch { return String(props.value) }
|
||||
})
|
||||
|
||||
const copied = ref(false)
|
||||
async function doCopy() {
|
||||
if (await copyText(text.value)) {
|
||||
copied.value = true
|
||||
message.success('已复制')
|
||||
setTimeout(() => copied.value = false, 1500)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="relative group">
|
||||
<a-button size="small" class="!absolute right-2 top-2 z-10 opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
@click="doCopy">
|
||||
<template #icon><CheckOutlined v-if="copied" /><CopyOutlined v-else /></template>
|
||||
</a-button>
|
||||
<pre class="mono text-xs p-3 m-0 rounded-lg overflow-auto"
|
||||
:style="{ maxHeight, background: 'rgba(0,0,0,0.25)', border: '1px solid var(--glass-border)', color: 'var(--text-2)' }">{{ text || '(空)' }}</pre>
|
||||
</div>
|
||||
</template>
|
||||
24
src/components/KvGrid.vue
Normal file
24
src/components/KvGrid.vue
Normal file
@@ -0,0 +1,24 @@
|
||||
<script setup>
|
||||
// 键值对网格:左标签右值的两列信息展示(服务状态/生效模型/配置总览通用)
|
||||
// items: [{ key?, label, value, mono?, color? }]
|
||||
// - key :需要自定义值渲染(tag/链接)时,用同名插槽 <template #key名>
|
||||
// - mono :默认 true(数值/代码风格);纯中文值可传 false
|
||||
// - color :值的颜色(如告警色 var(--warn)),缺省 var(--text-2)
|
||||
defineProps({
|
||||
items: { type: Array, default: () => [] }
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="grid grid-cols-2 gap-y-2 text-xs">
|
||||
<template v-for="it in items" :key="it.key || it.label">
|
||||
<span style="color: var(--text-3)">{{ it.label }}</span>
|
||||
<span class="text-right" :class="{ mono: it.mono !== false }" :style="{ color: it.color || 'var(--text-2)' }">
|
||||
<!-- 只有声明了 key 的行才走具名插槽;否则 :name="undefined" 会退化成
|
||||
default 插槽,调用方一旦误放默认内容,所有无 key 行都会被替换 -->
|
||||
<template v-if="it.key"><slot :name="it.key" :item="it">{{ it.value ?? '-' }}</slot></template>
|
||||
<template v-else>{{ it.value ?? '-' }}</template>
|
||||
</span>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
19
src/components/PageHeader.vue
Normal file
19
src/components/PageHeader.vue
Normal file
@@ -0,0 +1,19 @@
|
||||
<script setup>
|
||||
// 页面统一头部:标题 + 一句话说明 + 右侧操作区 slot
|
||||
defineProps({
|
||||
title: { type: String, required: true },
|
||||
desc: { type: String, default: '' }
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex items-start justify-between mb-4 gap-4 flex-wrap">
|
||||
<div>
|
||||
<h2 class="text-lg font-semibold m-0" style="color: var(--text-1)">{{ title }}</h2>
|
||||
<p v-if="desc" class="text-xs mt-1 mb-0" style="color: var(--text-3)">{{ desc }}</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<slot name="actions"></slot>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
123
src/components/RunDetailDrawer.vue
Normal file
123
src/components/RunDetailDrawer.vue
Normal file
@@ -0,0 +1,123 @@
|
||||
<script setup>
|
||||
// 全局运行详情抽屉(App 层挂载,多入口共用)
|
||||
// 入口:仪表盘最近列表 / 运行记录表 / 日志页 / 命令面板搜 id / URL ?run=
|
||||
// 互通:重放按钮 → Enhance 调试台回填;KB 检索步骤 → 检索页复现
|
||||
import { ref, watch, computed } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { message } from 'ant-design-vue'
|
||||
import { CopyOutlined, DownloadOutlined, RedoOutlined, SearchOutlined } from '@ant-design/icons-vue'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import { getRunDetail } from '@/api/agent'
|
||||
import { fmtTimeFull, fmtMs, sceneLabel, downloadJSON, copyText, estimateCost, fmtCost } from '@/utils/format'
|
||||
import StatusTag from './StatusTag.vue'
|
||||
import StepTimeline from './StepTimeline.vue'
|
||||
import JsonViewer from './JsonViewer.vue'
|
||||
|
||||
const app = useAppStore()
|
||||
const router = useRouter()
|
||||
const loading = ref(false)
|
||||
const detail = ref(null)
|
||||
|
||||
// 快照解析(重放 / 检索复现的数据源)
|
||||
const snapshot = computed(() => {
|
||||
if (!detail.value?.request_snapshot) return null
|
||||
try { return JSON.parse(detail.value.request_snapshot) } catch { return null }
|
||||
})
|
||||
// 是否含 KB 检索步骤(决定是否显示「检索复现」入口)
|
||||
const hasKBStep = computed(() => (detail.value?.steps || []).some(s => s.step_type === 'kb_retrieval'))
|
||||
const cost = computed(() => {
|
||||
if (!detail.value) return 0
|
||||
return estimateCost(detail.value.provider, detail.value.prompt_tokens, detail.value.completion_tokens)
|
||||
})
|
||||
|
||||
watch(() => [app.runDrawerOpen, app.runDrawerId], async ([open, id]) => {
|
||||
if (!open || !id) return
|
||||
loading.value = true
|
||||
detail.value = null
|
||||
try {
|
||||
const res = await getRunDetail(id, { silent: true })
|
||||
detail.value = res.data
|
||||
} catch (e) {
|
||||
message.warning(e.message || '记录不存在(可能已被环形缓冲覆盖或服务重启)')
|
||||
app.closeRun()
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
})
|
||||
|
||||
async function copyAll() {
|
||||
if (await copyText(JSON.stringify(detail.value, null, 2))) message.success('已复制完整 JSON')
|
||||
}
|
||||
function exportOne() {
|
||||
downloadJSON(detail.value, `agent-run-${detail.value.id}.json`)
|
||||
}
|
||||
// 重放:快照回填 Enhance 调试台(互通链路)
|
||||
function replay() {
|
||||
if (!snapshot.value) { message.warning('该运行没有请求快照(旧版本记录),无法重放'); return }
|
||||
app.setReplay({ fromRunId: detail.value.id, ...snapshot.value })
|
||||
app.closeRun()
|
||||
router.push('/debug')
|
||||
}
|
||||
// KB 检索复现:带 query 跳检索页
|
||||
function reproduceSearch() {
|
||||
const q = snapshot.value?.context || ''
|
||||
app.closeRun()
|
||||
router.push({ path: '/kb-search', query: q ? { q } : {} })
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<a-drawer :open="app.runDrawerOpen" :width="720" :title="`运行详情 #${app.runDrawerId}`"
|
||||
placement="right" @close="app.closeRun()">
|
||||
<a-skeleton v-if="loading" active :paragraph="{ rows: 8 }" />
|
||||
<template v-else-if="detail">
|
||||
<div class="flex items-center gap-2 mb-4 flex-wrap">
|
||||
<StatusTag :status="detail.status" />
|
||||
<a-tag color="gold">{{ sceneLabel(detail.scene) }}</a-tag>
|
||||
<span class="mono text-xs" style="color: var(--text-2)">{{ detail.provider }}/{{ detail.model }}</span>
|
||||
<a-tag v-if="detail.cfg_source" :bordered="false">{{ detail.cfg_source }}</a-tag>
|
||||
</div>
|
||||
|
||||
<a-descriptions :column="2" size="small" bordered class="mb-4">
|
||||
<a-descriptions-item label="开始时间">{{ fmtTimeFull(detail.started_at) }}</a-descriptions-item>
|
||||
<a-descriptions-item label="总耗时"><span class="mono">{{ fmtMs(detail.total_ms) }}</span></a-descriptions-item>
|
||||
<a-descriptions-item label="tokens">
|
||||
<span class="mono">{{ detail.prompt_tokens }} + {{ detail.completion_tokens }} = {{ detail.total_tokens }}</span>
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="估算成本"><span class="mono">{{ fmtCost(cost) }}</span></a-descriptions-item>
|
||||
<a-descriptions-item v-if="detail.error" label="失败原因" :span="2">
|
||||
<span style="color: var(--err)">{{ detail.error }}</span>
|
||||
</a-descriptions-item>
|
||||
</a-descriptions>
|
||||
|
||||
<h4 class="text-sm font-medium mb-2" style="color: var(--text-1)">步骤时间线({{ (detail.steps || []).length }} 步)</h4>
|
||||
<div class="glass p-3 mb-4">
|
||||
<StepTimeline :steps="detail.steps || []" />
|
||||
</div>
|
||||
|
||||
<template v-if="snapshot">
|
||||
<h4 class="text-sm font-medium mb-2" style="color: var(--text-1)">
|
||||
请求快照
|
||||
<a-tag v-if="snapshot.truncated" color="warning" class="ml-1">已截断</a-tag>
|
||||
</h4>
|
||||
<JsonViewer :value="snapshot" max-height="240px" class="mb-4" />
|
||||
</template>
|
||||
|
||||
<h4 class="text-sm font-medium mb-2" style="color: var(--text-1)">原始 JSON</h4>
|
||||
<JsonViewer :value="detail" max-height="260px" />
|
||||
</template>
|
||||
|
||||
<template #footer>
|
||||
<div class="flex gap-2 justify-end">
|
||||
<a-button v-if="hasKBStep" @click="reproduceSearch"><template #icon><SearchOutlined /></template>检索复现</a-button>
|
||||
<a-button @click="copyAll"><template #icon><CopyOutlined /></template>复制</a-button>
|
||||
<a-button @click="exportOne"><template #icon><DownloadOutlined /></template>导出</a-button>
|
||||
<a-tooltip :title="snapshot ? '把入参回填到 Enhance 调试台重跑' : '该记录无请求快照'">
|
||||
<a-button type="primary" :disabled="!snapshot" @click="replay">
|
||||
<template #icon><RedoOutlined /></template>重放
|
||||
</a-button>
|
||||
</a-tooltip>
|
||||
</div>
|
||||
</template>
|
||||
</a-drawer>
|
||||
</template>
|
||||
26
src/components/SceneTag.vue
Normal file
26
src/components/SceneTag.vue
Normal file
@@ -0,0 +1,26 @@
|
||||
<script setup>
|
||||
// 场景标签:点击可跳运行记录页按场景筛选(互通链路之一)
|
||||
import { useRouter } from 'vue-router'
|
||||
import { sceneLabel } from '@/utils/format'
|
||||
|
||||
const props = defineProps({
|
||||
scene: { type: String, required: true },
|
||||
clickable: { type: Boolean, default: true }
|
||||
})
|
||||
const router = useRouter()
|
||||
const colorMap = {
|
||||
medical_record: 'gold',
|
||||
prescription: 'orange',
|
||||
prescription_validate: 'volcano',
|
||||
knowledge_qa: 'cyan'
|
||||
}
|
||||
function go() {
|
||||
if (props.clickable) router.push({ path: '/runs', query: { scene: props.scene } })
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<a-tag :color="colorMap[scene] || 'default'"
|
||||
:style="clickable ? 'cursor:pointer' : ''"
|
||||
@click.stop="go">{{ sceneLabel(scene) }}</a-tag>
|
||||
</template>
|
||||
33
src/components/StatCard.vue
Normal file
33
src/components/StatCard.vue
Normal file
@@ -0,0 +1,33 @@
|
||||
<script setup>
|
||||
// 指标统计卡:仪表盘 / 系统状态复用
|
||||
// 可点击(clickable)时 hover 泛光,点击 emit,用于跳转带筛选的列表页
|
||||
defineProps({
|
||||
label: { type: String, required: true },
|
||||
value: { type: [Number, String], default: '-' },
|
||||
unit: { type: String, default: '' },
|
||||
precision: { type: Number, default: 0 },
|
||||
tone: { type: String, default: '' }, // ok / err / warn / run,控制数值颜色
|
||||
hint: { type: String, default: '' }, // 底部辅助文案
|
||||
clickable: { type: Boolean, default: false },
|
||||
loading: { type: Boolean, default: false }
|
||||
})
|
||||
const emit = defineEmits(['click'])
|
||||
|
||||
const toneColor = { ok: 'var(--ok)', err: 'var(--err)', warn: 'var(--warn)', run: 'var(--run)' }
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="glass p-4 select-none" :class="{ 'glass-hover': clickable }"
|
||||
@click="clickable && emit('click')">
|
||||
<div class="text-xs mb-2" style="color: var(--text-3)">{{ label }}</div>
|
||||
<a-skeleton v-if="loading" :paragraph="false" active :title="{ width: 80 }" />
|
||||
<div v-else class="flex items-baseline gap-1">
|
||||
<span class="text-2xl font-semibold mono" :style="{ color: toneColor[tone] || 'var(--text-1)' }">
|
||||
{{ typeof value === 'number' ? value.toFixed(precision) : value }}
|
||||
</span>
|
||||
<span v-if="unit" class="text-xs" style="color: var(--text-3)">{{ unit }}</span>
|
||||
</div>
|
||||
<div v-if="hint" class="text-xs mt-1" style="color: var(--text-3)">{{ hint }}</div>
|
||||
<slot name="footer"></slot>
|
||||
</div>
|
||||
</template>
|
||||
20
src/components/StatusTag.vue
Normal file
20
src/components/StatusTag.vue
Normal file
@@ -0,0 +1,20 @@
|
||||
<script setup>
|
||||
// 状态标签:运行状态与生成历史状态的颜色映射单点维护
|
||||
// kind='run':内存运行记录(1成功 2失败 3守卫拦截)
|
||||
// kind='gen':DB 生成历史(0进行中 1成功 2失败)
|
||||
import { computed } from 'vue'
|
||||
import { RUN_STATUS, GEN_STATUS } from '@/utils/format'
|
||||
|
||||
const props = defineProps({
|
||||
status: { type: Number, required: true },
|
||||
kind: { type: String, default: 'run' }
|
||||
})
|
||||
const meta = computed(() => {
|
||||
const map = props.kind === 'gen' ? GEN_STATUS : RUN_STATUS
|
||||
return map[props.status] || { label: `状态${props.status}`, color: 'default' }
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<a-tag :color="meta.color" :bordered="true">{{ meta.label }}</a-tag>
|
||||
</template>
|
||||
65
src/components/StepTimeline.vue
Normal file
65
src/components/StepTimeline.vue
Normal file
@@ -0,0 +1,65 @@
|
||||
<script setup>
|
||||
// 步骤时间线:运行详情抽屉 / 历史详情 / Enhance 调试结果三处共用
|
||||
// steps 兼容两种数据源(字段名一致):
|
||||
// - 内存 RunLog 的 EnhanceStep(step_type/provider/model/*_tokens/duration_ms/detail/status)
|
||||
// - DB xk_ai_generation_step 行(多 step_no/tool_name 字段)
|
||||
import { ref } from 'vue'
|
||||
import { stepTypeLabel, fmtMs, fmtTime } from '@/utils/format'
|
||||
|
||||
defineProps({
|
||||
steps: { type: Array, default: () => [] }
|
||||
})
|
||||
|
||||
// 展开的 detail 索引集合(detail 可能很长,默认折叠)
|
||||
const expanded = ref(new Set())
|
||||
function toggle(i) {
|
||||
const s = new Set(expanded.value)
|
||||
s.has(i) ? s.delete(i) : s.add(i)
|
||||
expanded.value = s
|
||||
}
|
||||
|
||||
const typeColor = {
|
||||
plan: 'var(--run)',
|
||||
kb_retrieval: 'var(--accent)',
|
||||
llm_call: 'var(--primary)',
|
||||
tool_call: '#a855f7',
|
||||
reflection: 'var(--ok)',
|
||||
medical_guard: 'var(--warn)'
|
||||
}
|
||||
// status: 1成功 2失败(0进行中)
|
||||
function dotColor(st) {
|
||||
if (st.status === 2) return 'var(--err)'
|
||||
if (st.status === 0) return 'var(--run)'
|
||||
return typeColor[st.step_type] || 'var(--text-3)'
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="!steps.length" class="text-center py-6 text-xs" style="color: var(--text-3)">无步骤记录</div>
|
||||
<div v-else class="relative pl-5">
|
||||
<!-- 竖线 -->
|
||||
<div class="absolute left-[5px] top-1 bottom-1 w-px" style="background: var(--glass-border)"></div>
|
||||
<div v-for="(st, i) in steps" :key="i" class="relative mb-3 last:mb-0">
|
||||
<!-- 节点圆点 -->
|
||||
<span class="absolute -left-5 top-1 w-[11px] h-[11px] rounded-full border-2"
|
||||
:style="{ background: dotColor(st), borderColor: 'var(--bg-base)' }"></span>
|
||||
<div class="flex items-center gap-2 flex-wrap">
|
||||
<span class="text-xs font-medium" :style="{ color: dotColor(st) }">
|
||||
{{ stepTypeLabel(st.step_type) }}<template v-if="st.tool_name">·{{ st.tool_name }}</template>
|
||||
</span>
|
||||
<span v-if="st.provider" class="text-xs mono" style="color: var(--text-2)">{{ st.provider }}/{{ st.model }}</span>
|
||||
<span class="text-xs mono" style="color: var(--text-3)">{{ fmtMs(st.duration_ms) }}</span>
|
||||
<span v-if="st.total_tokens" class="text-xs mono" style="color: var(--text-3)">{{ st.total_tokens }} tok</span>
|
||||
<a-tag v-if="st.status === 2" color="error" size="small">失败</a-tag>
|
||||
<span v-if="st.started_at" class="text-xs" style="color: var(--text-3)">{{ fmtTime(st.started_at) }}</span>
|
||||
</div>
|
||||
<div v-if="st.detail" class="text-xs mt-1 rounded px-2 py-1 cursor-pointer"
|
||||
style="color: var(--text-2); background: rgba(0,0,0,0.15); border: 1px solid var(--glass-border)"
|
||||
@click="toggle(i)">
|
||||
<span :class="expanded.has(i) ? '' : 'line-clamp-2'" style="white-space: pre-wrap; word-break: break-all; display: block;"
|
||||
:style="expanded.has(i) ? '' : 'display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden'">{{ st.detail }}</span>
|
||||
<span v-if="st.detail.length > 80" class="text-xs" style="color: var(--primary)">{{ expanded.has(i) ? '收起' : '展开' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
48
src/composables/usePolling.js
Normal file
48
src/composables/usePolling.js
Normal file
@@ -0,0 +1,48 @@
|
||||
import { ref, onMounted, onUnmounted } from 'vue'
|
||||
|
||||
// 统一轮询 composable
|
||||
//
|
||||
// 特性:
|
||||
// - 组件卸载自动清理(防泄漏)
|
||||
// - document 隐藏时自动暂停(后台标签页不空转、不浪费服务端配额)
|
||||
// - pause/resume 手动控制(AutoRefresh 开关用)
|
||||
//
|
||||
// 用法:const { pause, resume } = usePolling(fetchData, 5000)
|
||||
export function usePolling(fn, intervalMs, { immediate = true, enabled = true } = {}) {
|
||||
let timer = null
|
||||
const running = ref(false)
|
||||
let userPaused = !enabled
|
||||
|
||||
async function tick() {
|
||||
try { await fn() } catch { /* 轮询失败静默,由调用方自行处理错误态 */ }
|
||||
}
|
||||
|
||||
function start() {
|
||||
if (timer || userPaused) return
|
||||
running.value = true
|
||||
timer = setInterval(() => {
|
||||
if (document.visibilityState === 'hidden') return
|
||||
tick()
|
||||
}, intervalMs)
|
||||
}
|
||||
|
||||
function stop() {
|
||||
if (timer) { clearInterval(timer); timer = null }
|
||||
running.value = false
|
||||
}
|
||||
|
||||
function pause() { userPaused = true; stop() }
|
||||
function resume({ runNow = true } = {}) {
|
||||
userPaused = false
|
||||
if (runNow) tick()
|
||||
start()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (immediate && !userPaused) tick()
|
||||
start()
|
||||
})
|
||||
onUnmounted(stop)
|
||||
|
||||
return { pause, resume, running, tick }
|
||||
}
|
||||
312
src/layouts/AdminLayout.vue
Normal file
312
src/layouts/AdminLayout.vue
Normal file
@@ -0,0 +1,312 @@
|
||||
<script setup>
|
||||
// ========================================================================
|
||||
// AdminLayout —— 全站布局框架
|
||||
// ========================================================================
|
||||
// - 左侧玻璃 Sider:三分组菜单(监控 / AI 管理 / 知识库),折叠态记 localStorage
|
||||
// - 顶栏:面包屑 + 服务健康点 + 失败告警铃铛 + 命令面板按钮 + 主题切换 + 帮助 + 用户区
|
||||
// - 全局机制:/health 离线检测、失败告警轮询、JWT 静默续签、Ctrl+K、首登 Tour
|
||||
// ========================================================================
|
||||
import { ref, computed, onMounted, onUnmounted, h } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { Modal, notification } from 'ant-design-vue'
|
||||
import {
|
||||
DashboardOutlined, UnorderedListOutlined, HistoryOutlined, BarChartOutlined,
|
||||
FileTextOutlined, DesktopOutlined, BugOutlined, ApiOutlined, SettingOutlined,
|
||||
DatabaseOutlined, SearchOutlined, BellOutlined, BulbOutlined, QuestionCircleOutlined,
|
||||
UserOutlined, LogoutOutlined, ThunderboltOutlined, MenuFoldOutlined, MenuUnfoldOutlined,
|
||||
CloudDownloadOutlined
|
||||
} from '@ant-design/icons-vue'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
import { probeHealth } from '@/api/request'
|
||||
import { getStats, getRuns } from '@/api/agent'
|
||||
import { usePolling } from '@/composables/usePolling'
|
||||
import { fmtTime, sceneLabel } from '@/utils/format'
|
||||
|
||||
const app = useAppStore()
|
||||
const userStore = useUserStore()
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
// ---------------- Sider 菜单(三分组) ----------------
|
||||
const COLLAPSE_KEY = 'agent_admin_sider_collapsed'
|
||||
const collapsed = ref(localStorage.getItem(COLLAPSE_KEY) === '1')
|
||||
function toggleCollapse() {
|
||||
collapsed.value = !collapsed.value
|
||||
localStorage.setItem(COLLAPSE_KEY, collapsed.value ? '1' : '0')
|
||||
}
|
||||
|
||||
const menuItems = [
|
||||
{ type: 'group', label: '监控', children: [
|
||||
{ key: '/dashboard', icon: () => h(DashboardOutlined), label: '仪表盘' },
|
||||
{ key: '/runs', icon: () => h(UnorderedListOutlined), label: '运行记录' },
|
||||
{ key: '/history', icon: () => h(HistoryOutlined), label: '历史记录' },
|
||||
{ key: '/stats', icon: () => h(BarChartOutlined), label: '统计分析' },
|
||||
{ key: '/logs', icon: () => h(FileTextOutlined), label: '实时日志' },
|
||||
{ key: '/system', icon: () => h(DesktopOutlined), label: '系统状态' }
|
||||
]},
|
||||
{ type: 'group', label: 'AI 管理', children: [
|
||||
{ key: '/debug', icon: () => h(BugOutlined), label: '调试工具' },
|
||||
{ key: '/models', icon: () => h(ApiOutlined), label: '模型管理' },
|
||||
{ key: '/config', icon: () => h(SettingOutlined), label: '配置总览' }
|
||||
]},
|
||||
{ type: 'group', label: '知识库', children: [
|
||||
{ key: '/kb', icon: () => h(DatabaseOutlined), label: '知识库管理' },
|
||||
{ key: '/kb-search', icon: () => h(SearchOutlined), label: '知识库检索' },
|
||||
{ key: '/kb-crawl', icon: () => h(CloudDownloadOutlined), label: '药品抓取' }
|
||||
]}
|
||||
]
|
||||
const selectedKeys = computed(() => ['/' + route.path.split('/')[1]])
|
||||
function onMenuClick({ key }) { router.push(key) }
|
||||
|
||||
// ---------------- 服务健康点(离线检测) ----------------
|
||||
let healthFails = 0
|
||||
usePolling(async () => {
|
||||
try {
|
||||
await probeHealth()
|
||||
healthFails = 0
|
||||
if (app.offline) {
|
||||
app.offline = false
|
||||
notification.success({ message: '服务已恢复', description: 'Agent 服务重新可达' })
|
||||
}
|
||||
} catch {
|
||||
healthFails++
|
||||
// 连续 2 次失败才判离线(容忍单次网络抖动)
|
||||
if (healthFails >= 2 && !app.offline) {
|
||||
app.offline = true
|
||||
app.offlineSince = Date.now()
|
||||
}
|
||||
}
|
||||
}, 10000)
|
||||
|
||||
// ---------------- 失败告警(铃铛 + 浏览器通知) ----------------
|
||||
const bellOpen = ref(false)
|
||||
const recentFails = ref([])
|
||||
usePolling(async () => {
|
||||
try {
|
||||
const res = await getStats({ silent: true })
|
||||
const failed = res.data.failed + res.data.blocked
|
||||
if (app.lastFailedTotal >= 0 && failed > app.lastFailedTotal) {
|
||||
const delta = failed - app.lastFailedTotal
|
||||
app.alarmCount += delta
|
||||
if (app.notifyEnabled) {
|
||||
notification.warning({
|
||||
message: `新增 ${delta} 条失败/拦截运行`,
|
||||
description: res.data.last_error ? res.data.last_error.slice(0, 80) : '',
|
||||
onClick: () => router.push('/runs?status=2')
|
||||
})
|
||||
// 浏览器系统通知(需授权;页面在后台时也能提醒)
|
||||
if ('Notification' in window && Notification.permission === 'granted') {
|
||||
new Notification('TCM Agent 告警', { body: `新增 ${delta} 条失败运行` })
|
||||
}
|
||||
}
|
||||
}
|
||||
app.lastFailedTotal = failed
|
||||
} catch { /* 离线时静默 */ }
|
||||
}, 15000)
|
||||
|
||||
async function openBell() {
|
||||
bellOpen.value = true
|
||||
app.clearAlarm()
|
||||
try {
|
||||
// 铃铛计数含「失败(2)+拦截(3)」,列表也要两种都展示——
|
||||
// runs 接口一次只能筛一种状态,所以拉全量后前端过滤取前 8 条
|
||||
const res = await getRuns({ limit: 50 }, { silent: true })
|
||||
recentFails.value = (res.data || []).filter(r => r.status >= 2).slice(0, 8)
|
||||
} catch { recentFails.value = [] }
|
||||
}
|
||||
function toggleNotifyPermission() {
|
||||
app.toggleNotify()
|
||||
if (app.notifyEnabled && 'Notification' in window && Notification.permission === 'default') {
|
||||
Notification.requestPermission()
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------- JWT 静默续签(每 10 分钟检查一次) ----------------
|
||||
usePolling(() => userStore.maybeRefresh(), 600000, { immediate: true })
|
||||
|
||||
// ---------------- Ctrl+K 命令面板 ----------------
|
||||
function onGlobalKey(e) {
|
||||
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'k') {
|
||||
e.preventDefault()
|
||||
app.paletteOpen = !app.paletteOpen
|
||||
}
|
||||
}
|
||||
onMounted(() => window.addEventListener('keydown', onGlobalKey))
|
||||
onUnmounted(() => window.removeEventListener('keydown', onGlobalKey))
|
||||
|
||||
// ---------------- 首登新手引导(antd Tour) ----------------
|
||||
const TOUR_KEY = 'agent_admin_tour_done'
|
||||
const refSider = ref(null), refHealth = ref(null), refBell = ref(null)
|
||||
const refPalette = ref(null), refTheme = ref(null), refHelp = ref(null)
|
||||
// Tour 目标解析:必须返回真实 DOM 元素,否则返回 null(antd 会安全地居中展示)。
|
||||
// 组件 ref 拿到的是组件实例,直接传给 Tour 会在定位计算时抛
|
||||
// "getBoundingClientRect is not a function"——遮罩渲染了但定位卡死,
|
||||
// 且异常会打断 Vue 更新队列导致整个布局停止响应(已踩过坑)
|
||||
function tourEl(r) {
|
||||
const el = r?.$el ?? r
|
||||
return el instanceof HTMLElement ? el : null
|
||||
}
|
||||
const tourSteps = [
|
||||
{ title: '侧边菜单', description: '功能按「监控 / AI 管理 / 知识库」三组划分:看运行去监控,调模型去 AI 管理,管语料去知识库。', target: () => tourEl(refSider.value) },
|
||||
{ title: '服务健康点', description: '绿点=Go 服务在线,红点=不可达(会弹出全局横幅并自动重连)。', target: () => tourEl(refHealth.value) },
|
||||
{ title: '失败告警', description: '出现新的失败/拦截运行时这里会亮红点,可开关浏览器通知;点开可直达失败详情。', target: () => tourEl(refBell.value) },
|
||||
{ title: '命令面板', description: '按 Ctrl+K 随时唤起:搜页面、执行快捷动作、输入运行 ID 直达详情。', target: () => tourEl(refPalette.value) },
|
||||
{ title: '主题切换', description: '暗色暖调是默认主题,也可以切到亮色,偏好会记住。', target: () => tourEl(refTheme.value) },
|
||||
{ title: '随时回看引导', description: '点这里可以重新播放本引导。先去「调试工具」发一条测试请求,再回仪表盘看数据流动起来!', target: () => tourEl(refHelp.value) }
|
||||
]
|
||||
function onTourFinish() {
|
||||
app.tourOpen = false
|
||||
localStorage.setItem(TOUR_KEY, '1')
|
||||
}
|
||||
onMounted(() => {
|
||||
// 首次登录自动启动(localStorage 无标记时)
|
||||
if (localStorage.getItem(TOUR_KEY) !== '1') {
|
||||
setTimeout(() => app.startTour(), 800)
|
||||
}
|
||||
})
|
||||
|
||||
// ---------------- 用户区 ----------------
|
||||
function doLogout() {
|
||||
Modal.confirm({
|
||||
title: '确定退出登录?',
|
||||
onOk: () => {
|
||||
userStore.logout()
|
||||
router.push('/login')
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 离线横幅重连倒计时展示
|
||||
const now = ref(Date.now())
|
||||
usePolling(() => { now.value = Date.now() }, 1000)
|
||||
const retryCountdown = computed(() => {
|
||||
const elapsed = Math.floor((now.value - app.offlineSince) / 1000)
|
||||
return 10 - (elapsed % 10)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<a-layout class="min-h-screen" style="background: transparent">
|
||||
<!-- 侧边栏 -->
|
||||
<a-layout-sider ref="refSider" v-model:collapsed="collapsed" :trigger="null" collapsible
|
||||
:width="224" :collapsed-width="64"
|
||||
class="!sticky top-0 h-screen glass-strong !rounded-none border-r"
|
||||
style="border-color: var(--glass-border); z-index: 20">
|
||||
<div class="flex items-center gap-2 px-4 h-14" style="border-bottom: 1px solid var(--glass-border)">
|
||||
<span class="text-xl">🌿</span>
|
||||
<span v-if="!collapsed" class="font-semibold text-sm whitespace-nowrap" style="color: var(--primary)">TCM Agent 控制台</span>
|
||||
</div>
|
||||
<a-menu :selected-keys="selectedKeys" mode="inline" :items="menuItems"
|
||||
style="background: transparent; border-inline-end: none"
|
||||
@click="onMenuClick" />
|
||||
</a-layout-sider>
|
||||
|
||||
<a-layout style="background: transparent">
|
||||
<!-- 顶栏 -->
|
||||
<a-layout-header class="!px-4 !h-14 flex items-center gap-3 sticky top-0 glass-strong !rounded-none"
|
||||
style="z-index: 19; line-height: normal; border-bottom: 1px solid var(--glass-border); background: var(--glass-bg-strong)">
|
||||
<a-button type="text" @click="toggleCollapse">
|
||||
<MenuUnfoldOutlined v-if="collapsed" /><MenuFoldOutlined v-else />
|
||||
</a-button>
|
||||
<a-breadcrumb>
|
||||
<a-breadcrumb-item>{{ route.meta.group || 'TCM Agent' }}</a-breadcrumb-item>
|
||||
<a-breadcrumb-item>{{ route.meta.title }}</a-breadcrumb-item>
|
||||
</a-breadcrumb>
|
||||
|
||||
<div class="flex-1"></div>
|
||||
|
||||
<!-- 服务健康点 -->
|
||||
<a-tooltip :title="app.offline ? '服务不可达' : '服务在线(10s 轮询 /health)'">
|
||||
<span ref="refHealth" class="flex items-center gap-1.5 text-xs" style="color: var(--text-2)">
|
||||
<span class="health-dot" :class="{ down: app.offline }"></span>
|
||||
{{ app.offline ? '离线' : '在线' }}
|
||||
</span>
|
||||
</a-tooltip>
|
||||
|
||||
<!-- 失败告警铃铛 -->
|
||||
<a-popover v-model:open="bellOpen" trigger="click" placement="bottomRight" @open-change="v => v && openBell()">
|
||||
<template #content>
|
||||
<div class="w-72">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<span class="text-sm font-medium">最近失败运行</span>
|
||||
<a-switch :checked="app.notifyEnabled" size="small"
|
||||
checked-children="通知开" un-checked-children="通知关"
|
||||
@click="toggleNotifyPermission" />
|
||||
</div>
|
||||
<div v-if="!recentFails.length" class="text-xs text-center py-4" style="color: var(--text-3)">暂无失败记录</div>
|
||||
<div v-for="r in recentFails" :key="r.id"
|
||||
class="py-1.5 px-2 rounded cursor-pointer text-xs glass-hover flex items-center gap-2"
|
||||
@click="bellOpen = false; app.openRun(r.id)">
|
||||
<span class="mono" style="color: var(--text-3)">#{{ r.id }}</span>
|
||||
<span>{{ sceneLabel(r.scene) }}</span>
|
||||
<span class="ml-auto" style="color: var(--text-3)">{{ fmtTime(r.started_at) }}</span>
|
||||
</div>
|
||||
<a-button block size="small" class="mt-2" @click="bellOpen = false; router.push('/runs?status=2')">查看全部失败</a-button>
|
||||
</div>
|
||||
</template>
|
||||
<a-badge ref="refBell" :count="app.alarmCount" :offset="[-2, 4]" size="small">
|
||||
<a-button type="text"><BellOutlined /></a-button>
|
||||
</a-badge>
|
||||
</a-popover>
|
||||
|
||||
<!-- 命令面板入口(多入口之一,另有 Ctrl+K) -->
|
||||
<a-tooltip title="命令面板(Ctrl+K)">
|
||||
<a-button ref="refPalette" type="text" @click="app.paletteOpen = true">
|
||||
<ThunderboltOutlined />
|
||||
</a-button>
|
||||
</a-tooltip>
|
||||
|
||||
<!-- 主题切换 -->
|
||||
<a-tooltip :title="app.theme === 'dark' ? '切换到亮色' : '切换到暗色'">
|
||||
<a-button ref="refTheme" type="text" @click="app.toggleTheme()"><BulbOutlined /></a-button>
|
||||
</a-tooltip>
|
||||
|
||||
<!-- 帮助(重播引导) -->
|
||||
<a-tooltip title="重播新手引导">
|
||||
<a-button ref="refHelp" type="text" @click="app.startTour()"><QuestionCircleOutlined /></a-button>
|
||||
</a-tooltip>
|
||||
|
||||
<!-- 用户下拉 -->
|
||||
<a-dropdown>
|
||||
<span class="flex items-center gap-2 cursor-pointer px-2">
|
||||
<a-avatar size="small" style="background: var(--primary)"><template #icon><UserOutlined /></template></a-avatar>
|
||||
<span class="text-sm" style="color: var(--text-1)">{{ userStore.user?.nick_name || '管理员' }}</span>
|
||||
</span>
|
||||
<template #overlay>
|
||||
<a-menu>
|
||||
<a-menu-item disabled>
|
||||
<span class="text-xs">{{ userStore.user?.role_name || '面板管理员' }}</span>
|
||||
</a-menu-item>
|
||||
<a-menu-divider />
|
||||
<a-menu-item @click="doLogout"><LogoutOutlined /> 退出登录</a-menu-item>
|
||||
</a-menu>
|
||||
</template>
|
||||
</a-dropdown>
|
||||
</a-layout-header>
|
||||
|
||||
<!-- 服务离线横幅 -->
|
||||
<a-alert v-if="app.offline" banner type="error" class="!rounded-none"
|
||||
:message="`Agent 服务不可达 —— 正在自动重连(${retryCountdown}s 后重试),服务恢复后本横幅自动消失`" />
|
||||
|
||||
<!-- 内容区 -->
|
||||
<a-layout-content class="p-5">
|
||||
<router-view />
|
||||
</a-layout-content>
|
||||
</a-layout>
|
||||
|
||||
<!-- 新手引导 -->
|
||||
<a-tour :open="app.tourOpen" :steps="tourSteps" @close="onTourFinish" @finish="onTourFinish" />
|
||||
</a-layout>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* 菜单玻璃化:菜单项 hover/选中带暖色 */
|
||||
:deep(.ant-menu-item-selected) {
|
||||
background: rgba(245, 158, 11, 0.14) !important;
|
||||
}
|
||||
:deep(.ant-menu-item-group-title) {
|
||||
font-size: 11px;
|
||||
color: var(--text-3);
|
||||
}
|
||||
</style>
|
||||
@@ -1,32 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<header class="bg-gradient-to-r from-blue-500 to-indigo-600 text-white py-4 px-6 flex items-center justify-between">
|
||||
<div class="flex items-center space-x-4">
|
||||
<!-- Logo -->
|
||||
<img src="https://via.placeholder.com/40" alt="Logo" class="h-8 w-8 rounded-full">
|
||||
<span class="font-bold">My App</span>
|
||||
</div>
|
||||
|
||||
<!-- Menu -->
|
||||
<nav class="hidden md:flex space-x-4">
|
||||
<a href="#" class="hover:text-gray-300">首页</a>
|
||||
<a href="#" class="hover:text-gray-300">About</a>
|
||||
<a href="#" class="hover:text-gray-300">Services</a>
|
||||
<a href="#" class="hover:text-gray-300">Contact</a>
|
||||
</nav>
|
||||
|
||||
<!-- User Profile -->
|
||||
<div class="flex items-center space-x-4">
|
||||
<button class="text-sm bg-gray-800 hover:bg-gray-700 text-white font-bold py-2 px-4 rounded">
|
||||
Personal Center
|
||||
</button>
|
||||
<img src="https://via.placeholder.com/40" alt="User Avatar" class="h-8 w-8 rounded-full cursor-pointer">
|
||||
</div>
|
||||
</header>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
|
||||
</style>
|
||||
@@ -1,16 +1,13 @@
|
||||
import { createApp } from 'vue'
|
||||
import { createPinia } from 'pinia'
|
||||
import Antd from 'ant-design-vue'
|
||||
import 'ant-design-vue/dist/reset.css'
|
||||
import './style.css'
|
||||
import App from './App.vue'
|
||||
import '@fortawesome/fontawesome-free/css/all.css' // 引入所有样式
|
||||
// 注册路由
|
||||
import router from './router'
|
||||
|
||||
// TCM Agent 管理前端入口:Pinia → Router → Ant Design Vue
|
||||
const app = createApp(App)
|
||||
|
||||
|
||||
// 注册Ant Design组件
|
||||
app.use(createPinia())
|
||||
app.use(router)
|
||||
app.use(Antd)
|
||||
|
||||
@@ -1,38 +1,62 @@
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
import MainLayout from '@/layouts/Header.vue'
|
||||
import AdminLayout from '@/layouts/AdminLayout.vue'
|
||||
|
||||
// 在路由配置中添加个人中心路由
|
||||
// 路由表:/login 独立,其余全部挂在 AdminLayout 下(登录守卫统一控制)
|
||||
// meta.title 同时用于菜单、面包屑与命令面板搜索
|
||||
const routes = [
|
||||
{
|
||||
path: '/',
|
||||
component: MainLayout,
|
||||
children: [
|
||||
{ path: '', name: 'Home', component: () => import('@/views/index/index.vue') },
|
||||
]
|
||||
},
|
||||
{
|
||||
path: '/login',
|
||||
name: 'Login',
|
||||
component: () => import('@/views/login/index.vue'),
|
||||
meta: { title: '登录' }
|
||||
},
|
||||
{
|
||||
path: '/',
|
||||
component: AdminLayout,
|
||||
redirect: '/dashboard',
|
||||
meta: { requiresAuth: true },
|
||||
children: [
|
||||
{ path: 'dashboard', name: 'Dashboard', component: () => import('@/views/dashboard/index.vue'), meta: { title: '仪表盘', group: '监控' } },
|
||||
{ path: 'runs', name: 'Runs', component: () => import('@/views/runs/index.vue'), meta: { title: '运行记录', group: '监控' } },
|
||||
{ path: 'history', name: 'History', component: () => import('@/views/history/index.vue'), meta: { title: '历史记录', group: '监控' } },
|
||||
{ path: 'stats', name: 'Stats', component: () => import('@/views/stats/index.vue'), meta: { title: '统计分析', group: '监控' } },
|
||||
{ path: 'logs', name: 'Logs', component: () => import('@/views/logs/index.vue'), meta: { title: '实时日志', group: '监控' } },
|
||||
{ path: 'system', name: 'System', component: () => import('@/views/system/index.vue'), meta: { title: '系统状态', group: '监控' } },
|
||||
{ path: 'debug', name: 'Debug', component: () => import('@/views/debug/index.vue'), meta: { title: '调试工具', group: 'AI 管理' } },
|
||||
{ path: 'models', name: 'Models', component: () => import('@/views/models/index.vue'), meta: { title: '模型管理', group: 'AI 管理' } },
|
||||
{ path: 'config', name: 'Config', component: () => import('@/views/config/index.vue'), meta: { title: '配置总览', group: 'AI 管理' } },
|
||||
{ path: 'kb', name: 'Kb', component: () => import('@/views/kb/index.vue'), meta: { title: '知识库管理', group: '知识库' } },
|
||||
{ path: 'kb-search', name: 'KbSearch', component: () => import('@/views/kb-search/index.vue'), meta: { title: '知识库检索', group: '知识库' } },
|
||||
{ path: 'kb-crawl', name: 'KbCrawl', component: () => import('@/views/kb-crawl/index.vue'), meta: { title: '药品抓取', group: '知识库' } }
|
||||
]
|
||||
},
|
||||
{ path: '/:pathMatch(.*)*', redirect: '/dashboard' }
|
||||
]
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
routes,
|
||||
scrollBehavior(to, from, savedPosition) {
|
||||
return savedPosition || { top: 0 }
|
||||
}
|
||||
// base 是 /admin/(vite base 注入),history 模式由 Go 端 NoRoute 回退支撑刷新
|
||||
history: createWebHistory(import.meta.env.BASE_URL),
|
||||
routes,
|
||||
scrollBehavior(to, from, savedPosition) {
|
||||
return savedPosition || { top: 0 }
|
||||
}
|
||||
})
|
||||
|
||||
router.beforeEach((to, from) => {
|
||||
const userStore = useUserStore()
|
||||
if (to.meta.requiresAuth && !userStore.user) {
|
||||
return { name: 'Login', query: { redirect: to.fullPath } }
|
||||
}
|
||||
// 登录守卫:未登录访问受保护页 → 登录页(带 redirect 回跳);
|
||||
// 已登录访问登录页 → 直接进仪表盘
|
||||
router.beforeEach((to) => {
|
||||
const userStore = useUserStore()
|
||||
if (to.meta.requiresAuth && !userStore.isAuthenticated) {
|
||||
return { name: 'Login', query: { redirect: to.fullPath } }
|
||||
}
|
||||
if (to.name === 'Login' && userStore.isAuthenticated) {
|
||||
return { path: '/dashboard' }
|
||||
}
|
||||
})
|
||||
|
||||
router.afterEach((to) => {
|
||||
document.title = to.meta.title ? `${to.meta.title} · TCM Agent 控制台` : 'TCM Agent 控制台'
|
||||
})
|
||||
|
||||
export default router
|
||||
|
||||
router.beforeEach((to, from) => {
|
||||
const userStore = useUserStore()
|
||||
if (to.meta.requiresAuth && !userStore.user) {
|
||||
return { name: 'Login', query: { redirect: to.fullPath } }
|
||||
}
|
||||
})
|
||||
|
||||
91
src/stores/app.js
Normal file
91
src/stores/app.js
Normal file
@@ -0,0 +1,91 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
|
||||
// ========================================================================
|
||||
// 全局应用 store —— 页面互通的三大底座之一
|
||||
// ========================================================================
|
||||
// - 全局运行详情抽屉:任何页面 openRun(id) 即可打开,不用先跳页
|
||||
// - 命令面板(Ctrl+K)开关
|
||||
// - 服务离线状态(health 轮询驱动)
|
||||
// - 失败告警(铃铛红点 + 浏览器通知开关)
|
||||
// - 重放载荷:运行详情「重放」→ 调试台回填的桥梁
|
||||
// - 新手引导 Tour 开关
|
||||
// ========================================================================
|
||||
|
||||
const THEME_KEY = 'agent_admin_theme'
|
||||
const NOTIFY_KEY = 'agent_admin_notify'
|
||||
|
||||
export const useAppStore = defineStore('app', () => {
|
||||
// ---------- 主题(暗色暖调优先) ----------
|
||||
const theme = ref(localStorage.getItem(THEME_KEY) || 'dark')
|
||||
applyTheme(theme.value)
|
||||
function applyTheme(t) {
|
||||
document.documentElement.dataset.theme = t
|
||||
}
|
||||
function toggleTheme() {
|
||||
theme.value = theme.value === 'dark' ? 'light' : 'dark'
|
||||
localStorage.setItem(THEME_KEY, theme.value)
|
||||
applyTheme(theme.value)
|
||||
}
|
||||
|
||||
// ---------- 全局运行详情抽屉 ----------
|
||||
const runDrawerOpen = ref(false)
|
||||
const runDrawerId = ref(0)
|
||||
function openRun(id) {
|
||||
runDrawerId.value = Number(id)
|
||||
runDrawerOpen.value = true
|
||||
}
|
||||
function closeRun() {
|
||||
runDrawerOpen.value = false
|
||||
}
|
||||
|
||||
// ---------- 命令面板 ----------
|
||||
const paletteOpen = ref(false)
|
||||
|
||||
// ---------- 服务离线检测(AdminLayout 轮询 /health 驱动) ----------
|
||||
const offline = ref(false)
|
||||
const offlineSince = ref(0)
|
||||
|
||||
// ---------- 失败告警 ----------
|
||||
const alarmCount = ref(0) // 铃铛红点数(自上次查看以来的新失败)
|
||||
const lastFailedTotal = ref(-1) // 上一次轮询看到的失败总数(-1=未初始化)
|
||||
const notifyEnabled = ref(localStorage.getItem(NOTIFY_KEY) !== '0')
|
||||
function toggleNotify() {
|
||||
notifyEnabled.value = !notifyEnabled.value
|
||||
localStorage.setItem(NOTIFY_KEY, notifyEnabled.value ? '1' : '0')
|
||||
}
|
||||
function clearAlarm() { alarmCount.value = 0 }
|
||||
|
||||
// ---------- 重放载荷(运行详情 → Enhance 调试台) ----------
|
||||
const replayPayload = ref(null) // { fromRunId, scene, context, kb_enabled, top_k, messages }
|
||||
function setReplay(payload) { replayPayload.value = payload }
|
||||
function consumeReplay() {
|
||||
const p = replayPayload.value
|
||||
replayPayload.value = null
|
||||
return p
|
||||
}
|
||||
|
||||
// ---------- KB 检索 → 调试台注入 ----------
|
||||
const injectContext = ref(null) // { query, content }
|
||||
function setInject(payload) { injectContext.value = payload }
|
||||
function consumeInject() {
|
||||
const p = injectContext.value
|
||||
injectContext.value = null
|
||||
return p
|
||||
}
|
||||
|
||||
// ---------- 新手引导 ----------
|
||||
const tourOpen = ref(false)
|
||||
function startTour() { tourOpen.value = true }
|
||||
|
||||
return {
|
||||
theme, toggleTheme,
|
||||
runDrawerOpen, runDrawerId, openRun, closeRun,
|
||||
paletteOpen,
|
||||
offline, offlineSince,
|
||||
alarmCount, lastFailedTotal, notifyEnabled, toggleNotify, clearAlarm,
|
||||
replayPayload, setReplay, consumeReplay,
|
||||
injectContext, setInject, consumeInject,
|
||||
tourOpen, startTour
|
||||
}
|
||||
})
|
||||
@@ -1,87 +1,72 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, computed } from 'vue'
|
||||
import {loginApi, registerApi} from "@/api/request/user.js";
|
||||
import {message, notification} from "ant-design-vue";
|
||||
import { notification } from 'ant-design-vue'
|
||||
import { loginApi, refreshApi, profileApi } from '@/api/auth'
|
||||
import { TOKEN_KEY } from '@/api/request'
|
||||
|
||||
const USER_KEY = 'agent_admin_user'
|
||||
const EXPIRE_KEY = 'agent_admin_expire'
|
||||
|
||||
// 用户会话 store:登录 / 登出 / 会话校验 / 静默续签
|
||||
export const useUserStore = defineStore('user', () => {
|
||||
const user = ref(JSON.parse(localStorage.getItem('user') || 'null'))
|
||||
const modal = ref(localStorage.getItem('authModal') || 'false')
|
||||
const token = ref(localStorage.getItem(TOKEN_KEY) || '')
|
||||
const user = ref(safeParse(localStorage.getItem(USER_KEY)))
|
||||
const expireAt = ref(Number(localStorage.getItem(EXPIRE_KEY) || 0))
|
||||
|
||||
// 添加初始化状态校验
|
||||
const initUser = () => {
|
||||
const userData = localStorage.getItem('user')
|
||||
if (!userData) return null
|
||||
try {
|
||||
return JSON.parse(userData)
|
||||
} catch {
|
||||
localStorage.removeItem('user')
|
||||
localStorage.removeItem('token')
|
||||
return null
|
||||
const isAuthenticated = computed(() => !!token.value)
|
||||
|
||||
function safeParse(s) {
|
||||
try { return s ? JSON.parse(s) : null } catch { return null }
|
||||
}
|
||||
}
|
||||
|
||||
const isAuthenticated = computed(() => !!user.value?.nick_name)
|
||||
|
||||
function login(credentials, functionName) {
|
||||
console.log(functionName, 'sssssssssssss')
|
||||
loginApi({
|
||||
account: credentials.username,
|
||||
password: credentials.password,
|
||||
remember: credentials.remember,
|
||||
}).then((res) => {
|
||||
const userData = {
|
||||
nick_name: res.nick_name,
|
||||
role_name: res.role_name,
|
||||
role_value: res.role_value,
|
||||
}
|
||||
user.value = userData;
|
||||
localStorage.setItem('user', JSON.stringify(userData))
|
||||
localStorage.setItem('token', res.token)
|
||||
notification.success({
|
||||
message: '登录成功',
|
||||
description: `欢迎回来${res.nick_name}`,
|
||||
})
|
||||
functionName()
|
||||
})
|
||||
}
|
||||
function register(credentials, functionName) {
|
||||
registerApi({
|
||||
account: credentials.username,
|
||||
password: credentials.password,
|
||||
email: credentials.email,
|
||||
code: credentials.code,
|
||||
}).then((res) => {
|
||||
const userData = {
|
||||
nick_name: res.nick_name,
|
||||
role_name: res.role_name,
|
||||
role_value: res.role_value,
|
||||
}
|
||||
user.value = userData;
|
||||
localStorage.setItem('user', JSON.stringify(userData))
|
||||
localStorage.setItem('token', res.token)
|
||||
message.success('注册成功')
|
||||
functionName()
|
||||
})
|
||||
}
|
||||
|
||||
function logout() {
|
||||
user.value = null
|
||||
localStorage.removeItem('user')
|
||||
localStorage.removeItem('token')
|
||||
}
|
||||
// 登录:成功后落 localStorage 并返回,失败抛错由登录页展示(含 shake 动效)
|
||||
async function login({ username, password, captcha }) {
|
||||
const res = await loginApi({ username, password, captcha })
|
||||
const d = res.data
|
||||
token.value = d.token
|
||||
user.value = { nick_name: d.nick_name, role_name: d.role_name, role: d.role }
|
||||
expireAt.value = d.expire_at || 0
|
||||
localStorage.setItem(TOKEN_KEY, d.token)
|
||||
localStorage.setItem(USER_KEY, JSON.stringify(user.value))
|
||||
localStorage.setItem(EXPIRE_KEY, String(expireAt.value))
|
||||
notification.success({ message: '登录成功', description: `欢迎回来,${d.nick_name}` })
|
||||
return d
|
||||
}
|
||||
|
||||
function setModal(value) {
|
||||
modal.value = modal.value === 'false' ? 'true' : 'false';
|
||||
localStorage.setItem('authModal', value)
|
||||
}
|
||||
// 会话校验:启动时调 profile,token 失效会走 401 全局跳转
|
||||
async function checkSession() {
|
||||
if (!token.value) return false
|
||||
try {
|
||||
const res = await profileApi()
|
||||
user.value = { ...user.value, ...res.data }
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
modal,
|
||||
user,
|
||||
isAuthenticated,
|
||||
setModal,
|
||||
login,
|
||||
register,
|
||||
logout
|
||||
}
|
||||
})
|
||||
// 静默续签:剩余有效期 < 24h 时换新 token(AdminLayout 定时驱动)
|
||||
async function maybeRefresh() {
|
||||
if (!token.value || !expireAt.value) return
|
||||
const remain = expireAt.value - Math.floor(Date.now() / 1000)
|
||||
if (remain > 24 * 3600 || remain <= 0) return
|
||||
try {
|
||||
const res = await refreshApi()
|
||||
token.value = res.data.token
|
||||
expireAt.value = res.data.expire_at || 0
|
||||
localStorage.setItem(TOKEN_KEY, token.value)
|
||||
localStorage.setItem(EXPIRE_KEY, String(expireAt.value))
|
||||
} catch { /* 续签失败不打扰用户,到期自然走 401 */ }
|
||||
}
|
||||
|
||||
function logout() {
|
||||
token.value = ''
|
||||
user.value = null
|
||||
expireAt.value = 0
|
||||
localStorage.removeItem(TOKEN_KEY)
|
||||
localStorage.removeItem(USER_KEY)
|
||||
localStorage.removeItem(EXPIRE_KEY)
|
||||
}
|
||||
|
||||
return { token, user, expireAt, isAuthenticated, login, logout, checkSession, maybeRefresh }
|
||||
})
|
||||
|
||||
180
src/style.css
180
src/style.css
@@ -1 +1,179 @@
|
||||
@import "tailwindcss";
|
||||
@import "tailwindcss";
|
||||
|
||||
/* ========================================================================
|
||||
TCM Agent 控制台 —— 主题变量体系(毛玻璃 + 暖色 + 暗色优先)
|
||||
========================================================================
|
||||
设计约定:
|
||||
- 暗色暖调是第一公民:暖黑底渐变,主色琥珀金,中性色全部带暖偏
|
||||
- 亮色主题作为次要选项(宝蓝主色,与旧面板同源)
|
||||
- 所有卡片/抽屉/模态统一玻璃表面(.glass),层级靠透明度与边线区分
|
||||
- 状态色语义全局固定:成功绿 / 失败红 / 运行蓝 / 警告琥珀
|
||||
======================================================================== */
|
||||
|
||||
:root[data-theme='dark'] {
|
||||
--bg-base: #16100a;
|
||||
--bg-grad-1: #1c1409;
|
||||
--bg-grad-2: #22150b;
|
||||
--glass-bg: rgba(43, 32, 19, 0.55);
|
||||
--glass-bg-strong: rgba(50, 38, 22, 0.82);
|
||||
--glass-border: rgba(245, 158, 11, 0.16);
|
||||
--glass-hover: rgba(245, 158, 11, 0.07);
|
||||
--text-1: #f3e9d6;
|
||||
--text-2: #bfae8d;
|
||||
--text-3: #8d7c60;
|
||||
--primary: #f59e0b;
|
||||
--primary-strong: #fbbf24;
|
||||
--accent: #fb923c;
|
||||
--ok: #22c55e;
|
||||
--err: #ef4444;
|
||||
--run: #3b82f6;
|
||||
--warn: #f59e0b;
|
||||
--shadow-glow: 0 0 24px rgba(245, 158, 11, 0.08);
|
||||
}
|
||||
|
||||
:root[data-theme='light'] {
|
||||
--bg-base: #f2f4fb;
|
||||
--bg-grad-1: #eef1fa;
|
||||
--bg-grad-2: #e8ecf9;
|
||||
--glass-bg: rgba(255, 255, 255, 0.72);
|
||||
--glass-bg-strong: rgba(255, 255, 255, 0.92);
|
||||
--glass-border: rgba(67, 97, 238, 0.14);
|
||||
--glass-hover: rgba(67, 97, 238, 0.05);
|
||||
--text-1: #1e2433;
|
||||
--text-2: #5a6478;
|
||||
--text-3: #98a1b3;
|
||||
--primary: #4361ee;
|
||||
--primary-strong: #3651d4;
|
||||
--accent: #7c3aed;
|
||||
--ok: #16a34a;
|
||||
--err: #dc2626;
|
||||
--run: #2563eb;
|
||||
--warn: #d97706;
|
||||
--shadow-glow: 0 6px 24px rgba(67, 97, 238, 0.08);
|
||||
}
|
||||
|
||||
/* 等宽字体栈:日志 / JSON / token 数字 / 耗时 */
|
||||
:root {
|
||||
--mono: ui-monospace, 'JetBrains Mono', 'Cascadia Mono', Consolas, monospace;
|
||||
}
|
||||
|
||||
html, body, #app { height: 100%; }
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
color: var(--text-1);
|
||||
background-color: var(--bg-base);
|
||||
/* 暖色氛围光 + 细网格纹理(纯 CSS,无图片依赖) */
|
||||
background-image:
|
||||
radial-gradient(1100px 500px at 85% -10%, rgba(245, 158, 11, 0.10), transparent 60%),
|
||||
radial-gradient(900px 460px at -10% 100%, rgba(251, 146, 60, 0.07), transparent 55%),
|
||||
linear-gradient(160deg, var(--bg-grad-1), var(--bg-base) 55%, var(--bg-grad-2)),
|
||||
repeating-linear-gradient(0deg, transparent 0 39px, rgba(245, 158, 11, 0.025) 39px 40px),
|
||||
repeating-linear-gradient(90deg, transparent 0 39px, rgba(245, 158, 11, 0.025) 39px 40px);
|
||||
background-attachment: fixed;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', sans-serif;
|
||||
transition: background-color .25s ease;
|
||||
}
|
||||
|
||||
:root[data-theme='light'] body {
|
||||
background-image:
|
||||
radial-gradient(1100px 500px at 85% -10%, rgba(67, 97, 238, 0.08), transparent 60%),
|
||||
linear-gradient(160deg, var(--bg-grad-1), var(--bg-base) 55%, var(--bg-grad-2));
|
||||
}
|
||||
|
||||
/* ---------------- 玻璃表面 ---------------- */
|
||||
.glass {
|
||||
background: var(--glass-bg);
|
||||
border: 1px solid var(--glass-border);
|
||||
border-radius: 12px;
|
||||
backdrop-filter: blur(14px);
|
||||
-webkit-backdrop-filter: blur(14px);
|
||||
box-shadow: var(--shadow-glow);
|
||||
}
|
||||
.glass-strong {
|
||||
background: var(--glass-bg-strong);
|
||||
border: 1px solid var(--glass-border);
|
||||
border-radius: 12px;
|
||||
backdrop-filter: blur(18px);
|
||||
-webkit-backdrop-filter: blur(18px);
|
||||
}
|
||||
.glass-hover:hover {
|
||||
background: var(--glass-hover);
|
||||
border-color: rgba(245, 158, 11, 0.3);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* ---------------- 通用工具类 ---------------- */
|
||||
.mono { font-family: var(--mono); }
|
||||
.text-1 { color: var(--text-1); }
|
||||
.text-2 { color: var(--text-2); }
|
||||
.text-3 { color: var(--text-3); }
|
||||
|
||||
/* 服务健康点微光 */
|
||||
.health-dot {
|
||||
width: 10px; height: 10px; border-radius: 50%;
|
||||
display: inline-block;
|
||||
background: var(--ok);
|
||||
box-shadow: 0 0 8px var(--ok);
|
||||
animation: pulse-glow 2.4s ease-in-out infinite;
|
||||
}
|
||||
.health-dot.down {
|
||||
background: var(--err);
|
||||
box-shadow: 0 0 8px var(--err);
|
||||
animation: none;
|
||||
}
|
||||
@keyframes pulse-glow {
|
||||
0%, 100% { box-shadow: 0 0 4px var(--ok); }
|
||||
50% { box-shadow: 0 0 12px var(--ok); }
|
||||
}
|
||||
|
||||
/* 登录失败晃动 */
|
||||
@keyframes shake-x {
|
||||
0%, 100% { transform: translateX(0); }
|
||||
20%, 60% { transform: translateX(-8px); }
|
||||
40%, 80% { transform: translateX(8px); }
|
||||
}
|
||||
.shake { animation: shake-x .45s ease; }
|
||||
|
||||
/* 尊重系统减弱动效偏好 */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*, *::before, *::after { animation-duration: .01ms !important; transition-duration: .01ms !important; }
|
||||
}
|
||||
|
||||
/* ---------------- antd 组件的玻璃化微调 ---------------- */
|
||||
/* 侧边栏 / 顶栏由 AdminLayout 自己的 class 控制,这里只处理弹层类 */
|
||||
.ant-modal .ant-modal-content,
|
||||
.ant-drawer .ant-drawer-content {
|
||||
backdrop-filter: blur(18px);
|
||||
-webkit-backdrop-filter: blur(18px);
|
||||
}
|
||||
/* 表格行 hover 暖化 */
|
||||
:root[data-theme='dark'] .ant-table-tbody > tr:hover > td {
|
||||
background: rgba(245, 158, 11, 0.06) !important;
|
||||
}
|
||||
|
||||
/* 终端风日志区 */
|
||||
.log-terminal {
|
||||
font-family: var(--mono);
|
||||
font-size: 12px;
|
||||
line-height: 1.75;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
}
|
||||
.log-terminal .log-warn { color: var(--warn); }
|
||||
.log-terminal .log-err { color: var(--err); }
|
||||
.log-terminal .log-ok { color: var(--ok); }
|
||||
|
||||
/* 滚动条(暗色下默认滚动条太亮) */
|
||||
:root[data-theme='dark'] ::-webkit-scrollbar { width: 8px; height: 8px; }
|
||||
:root[data-theme='dark'] ::-webkit-scrollbar-thumb { background: rgba(245, 158, 11, 0.18); border-radius: 4px; }
|
||||
:root[data-theme='dark'] ::-webkit-scrollbar-thumb:hover { background: rgba(245, 158, 11, 0.32); }
|
||||
:root[data-theme='dark'] ::-webkit-scrollbar-track { background: transparent; }
|
||||
|
||||
/* 检索关键词高亮 */
|
||||
mark.kw-hl {
|
||||
background: rgba(245, 158, 11, 0.28);
|
||||
color: var(--primary-strong);
|
||||
padding: 0 2px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
135
src/utils/format.js
Normal file
135
src/utils/format.js
Normal file
@@ -0,0 +1,135 @@
|
||||
// ========================================================================
|
||||
// 展示格式化工具 + 业务常量(场景/状态映射、token 单价表)
|
||||
// ========================================================================
|
||||
|
||||
// unix 秒 → 'MM-DD HH:mm:ss'(列表用短格式,省列宽)
|
||||
export function fmtTime(ts) {
|
||||
if (!ts) return '-'
|
||||
const d = new Date(ts * 1000)
|
||||
const p = n => String(n).padStart(2, '0')
|
||||
return `${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`
|
||||
}
|
||||
|
||||
// unix 秒 → 完整 'YYYY-MM-DD HH:mm:ss'
|
||||
export function fmtTimeFull(ts) {
|
||||
if (!ts) return '-'
|
||||
const d = new Date(ts * 1000)
|
||||
const p = n => String(n).padStart(2, '0')
|
||||
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`
|
||||
}
|
||||
|
||||
// 毫秒 → 人类可读(890ms / 3.2s / 1m5s)
|
||||
export function fmtMs(ms) {
|
||||
if (ms == null) return '-'
|
||||
if (ms < 1000) return `${ms}ms`
|
||||
if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`
|
||||
return `${Math.floor(ms / 60000)}m${Math.round((ms % 60000) / 1000)}s`
|
||||
}
|
||||
|
||||
// 秒 → 运行时长(3d2h / 5h12m / 8m)
|
||||
export function fmtUptime(sec) {
|
||||
if (!sec) return '-'
|
||||
const d = Math.floor(sec / 86400), h = Math.floor((sec % 86400) / 3600), m = Math.floor((sec % 3600) / 60)
|
||||
if (d > 0) return `${d}d${h}h`
|
||||
if (h > 0) return `${h}h${m}m`
|
||||
return `${m}m${sec % 60}s`
|
||||
}
|
||||
|
||||
// 耗时着色(列表里一眼看到慢请求)
|
||||
export function msTone(ms) {
|
||||
if (ms == null) return 'var(--text-2)'
|
||||
if (ms < 5000) return 'var(--ok)'
|
||||
if (ms < 20000) return 'var(--warn)'
|
||||
return 'var(--err)'
|
||||
}
|
||||
|
||||
// ---------------- 场景 ----------------
|
||||
export const SCENE_LABELS = {
|
||||
medical_record: '病历生成',
|
||||
prescription: '处方生成',
|
||||
prescription_validate: '处方校验',
|
||||
knowledge_qa: '知识问答',
|
||||
'emr-generator': '病历(Go)',
|
||||
'knowledge-qa': '问答(Go)'
|
||||
}
|
||||
export const SCENE_OPTIONS = [
|
||||
{ label: '病历生成', value: 'medical_record' },
|
||||
{ label: '处方生成', value: 'prescription' },
|
||||
{ label: '处方校验', value: 'prescription_validate' },
|
||||
{ label: '知识问答', value: 'knowledge_qa' }
|
||||
]
|
||||
export function sceneLabel(s) { return SCENE_LABELS[s] || s || '-' }
|
||||
|
||||
// ---------------- 运行状态(内存 RunLog:1成功 2失败 3守卫拦截) ----------------
|
||||
export const RUN_STATUS = {
|
||||
1: { label: '成功', color: 'success' },
|
||||
2: { label: '失败', color: 'error' },
|
||||
3: { label: '拦截', color: 'warning' }
|
||||
}
|
||||
// 生成历史状态(DB xk_ai_generation:0进行中 1成功 2失败)
|
||||
export const GEN_STATUS = {
|
||||
0: { label: '进行中', color: 'processing' },
|
||||
1: { label: '成功', color: 'success' },
|
||||
2: { label: '失败', color: 'error' }
|
||||
}
|
||||
|
||||
// ---------------- token 成本估算 ----------------
|
||||
// 单价:元 / 1K token(输入, 输出)。内部估算用,非账单口径;
|
||||
// 未知 provider 用 default 兜底
|
||||
export const TOKEN_PRICES = {
|
||||
spark: { in: 0.001, out: 0.001 }, // 讯飞 lite 档约价
|
||||
deepseek: { in: 0.001, out: 0.002 },
|
||||
qwen: { in: 0.0008, out: 0.002 },
|
||||
openai: { in: 0.018, out: 0.072 }, // gpt-4o 折算人民币
|
||||
ollama: { in: 0, out: 0 }, // 本地模型无 API 成本
|
||||
default: { in: 0.002, out: 0.004 }
|
||||
}
|
||||
export function estimateCost(provider, promptTokens = 0, completionTokens = 0) {
|
||||
const p = TOKEN_PRICES[provider] || TOKEN_PRICES.default
|
||||
return (promptTokens / 1000) * p.in + (completionTokens / 1000) * p.out
|
||||
}
|
||||
export function fmtCost(v) {
|
||||
if (v == null || isNaN(v)) return '-'
|
||||
if (v === 0) return '¥0'
|
||||
if (v < 0.01) return `¥${v.toFixed(4)}`
|
||||
return `¥${v.toFixed(2)}`
|
||||
}
|
||||
|
||||
// ---------------- 步骤类型 ----------------
|
||||
export const STEP_TYPE_LABELS = {
|
||||
plan: '计划',
|
||||
kb_retrieval: '知识检索',
|
||||
llm_call: 'LLM 调用',
|
||||
tool_call: '工具调用',
|
||||
reflection: '反思审核',
|
||||
medical_guard: '医疗守卫',
|
||||
json_repair: 'JSON 修复'
|
||||
}
|
||||
export function stepTypeLabel(t) { return STEP_TYPE_LABELS[t] || t }
|
||||
|
||||
// 下载 JSON 文件(导出用)
|
||||
export function downloadJSON(obj, filename) {
|
||||
const blob = new Blob([JSON.stringify(obj, null, 2)], { type: 'application/json' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = filename
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
// 复制到剪贴板(带降级)
|
||||
export async function copyText(text) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text)
|
||||
return true
|
||||
} catch {
|
||||
const ta = document.createElement('textarea')
|
||||
ta.value = text
|
||||
document.body.appendChild(ta)
|
||||
ta.select()
|
||||
const ok = document.execCommand('copy')
|
||||
document.body.removeChild(ta)
|
||||
return ok
|
||||
}
|
||||
}
|
||||
128
src/views/config/index.vue
Normal file
128
src/views/config/index.vue
Normal file
@@ -0,0 +1,128 @@
|
||||
<script setup>
|
||||
// 配置总览(只读):LLM / 知识库 / Agent 行为 / 面板会话 四组玻璃卡
|
||||
// 面板不改配置(此前约定:开关写入口统一在 PHP 后台,避免双写),只提供缓存失效
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { message } from 'ant-design-vue'
|
||||
import { ReloadOutlined, ClearOutlined } from '@ant-design/icons-vue'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
import { getAgentConfig } from '@/api/agent'
|
||||
import { getActiveConfig, invalidateModelCache } from '@/api/models'
|
||||
import { fmtTimeFull } from '@/utils/format'
|
||||
import PageHeader from '@/components/PageHeader.vue'
|
||||
import GlassCard from '@/components/GlassCard.vue'
|
||||
|
||||
const user = useUserStore()
|
||||
const cfg = ref(null)
|
||||
const llm = ref(null)
|
||||
const loading = ref(false)
|
||||
|
||||
async function loadAll() {
|
||||
loading.value = true
|
||||
try {
|
||||
// 两个接口并行拉,任一失败不影响另一个展示
|
||||
const [cfgRes, llmRes] = await Promise.allSettled([
|
||||
getAgentConfig({ silent: true }),
|
||||
getActiveConfig({ silent: true })
|
||||
])
|
||||
if (cfgRes.status === 'fulfilled') cfg.value = cfgRes.value.data
|
||||
if (llmRes.status === 'fulfilled') llm.value = llmRes.value.data
|
||||
} finally { loading.value = false }
|
||||
}
|
||||
async function doInvalidate() {
|
||||
await invalidateModelCache()
|
||||
message.success('LLM 配置缓存已失效')
|
||||
loadAll()
|
||||
}
|
||||
|
||||
// JWT 过期时间(面板会话卡展示)
|
||||
const expireText = computed(() => {
|
||||
const exp = Number(localStorage.getItem('agent_admin_expire') || 0)
|
||||
return exp ? fmtTimeFull(exp) : '-'
|
||||
})
|
||||
|
||||
const onoff = v => v ? '开启' : '关闭'
|
||||
onMounted(loadAll)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<PageHeader title="配置总览" desc="全部只读;开关修改统一走 PHP 管理后台(xk_system_config),避免双写冲突">
|
||||
<template #actions>
|
||||
<a-tag color="default" :bordered="false">只读</a-tag>
|
||||
<a-button :loading="loading" @click="loadAll"><template #icon><ReloadOutlined /></template>刷新</a-button>
|
||||
<a-popconfirm title="确认失效 LLM 配置缓存?" ok-text="失效" cancel-text="取消" @confirm="doInvalidate">
|
||||
<a-button danger><template #icon><ClearOutlined /></template>失效缓存</a-button>
|
||||
</a-popconfirm>
|
||||
</template>
|
||||
</PageHeader>
|
||||
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
<!-- LLM -->
|
||||
<GlassCard title="LLM(当前生效,脱敏)">
|
||||
<a-skeleton v-if="loading && !llm" active :paragraph="{ rows: 4 }" :title="false" />
|
||||
<a-descriptions v-else-if="llm" :column="1" size="small" bordered>
|
||||
<a-descriptions-item label="Provider"><span class="mono" style="color: var(--primary)">{{ llm.provider }}</span></a-descriptions-item>
|
||||
<a-descriptions-item label="Model"><span class="mono">{{ llm.model }}</span></a-descriptions-item>
|
||||
<a-descriptions-item label="API 地址"><span class="mono text-xs">{{ llm.api_url || '-' }}</span></a-descriptions-item>
|
||||
<a-descriptions-item label="API Key"><span class="mono">{{ llm.api_key_tail ? '****' + llm.api_key_tail : '-' }}(#{{ llm.api_key_id }})</span></a-descriptions-item>
|
||||
<a-descriptions-item label="配置来源"><a-tag :bordered="false" :color="llm.source === 'db_active' ? 'processing' : 'default'">{{ llm.source }}</a-tag></a-descriptions-item>
|
||||
</a-descriptions>
|
||||
<div v-else class="text-xs" style="color: var(--text-3)">读取失败(数据库不可达时不影响其他卡片)</div>
|
||||
</GlassCard>
|
||||
|
||||
<!-- 知识库 -->
|
||||
<GlassCard title="知识库">
|
||||
<a-skeleton v-if="loading && !cfg" active :paragraph="{ rows: 4 }" :title="false" />
|
||||
<a-descriptions v-else-if="cfg" :column="1" size="small" bordered>
|
||||
<a-descriptions-item label="检索来源"><span class="mono">{{ cfg.kb?.source }}</span></a-descriptions-item>
|
||||
<a-descriptions-item label="检索模式"><span class="mono">{{ cfg.kb?.search_mode || '-' }}</span></a-descriptions-item>
|
||||
<a-descriptions-item label="TopK 默认值">{{ cfg.kb?.top_k }}</a-descriptions-item>
|
||||
<a-descriptions-item label="Embedding"><span class="mono">{{ cfg.kb?.embedding_provider || '-' }}</span></a-descriptions-item>
|
||||
</a-descriptions>
|
||||
</GlassCard>
|
||||
|
||||
<!-- Agent 行为 -->
|
||||
<GlassCard title="Agent 行为(xk_system_config 实时值,30s 缓存)">
|
||||
<a-skeleton v-if="loading && !cfg" active :paragraph="{ rows: 6 }" :title="false" />
|
||||
<template v-else-if="cfg">
|
||||
<a-descriptions :column="1" size="small" bordered class="mb-3">
|
||||
<a-descriptions-item label="ReAct 引擎">{{ onoff(cfg.react?.enabled) }}(最多 {{ cfg.react?.max_iterations }} 轮)</a-descriptions-item>
|
||||
<a-descriptions-item label="Planning / Reflection">{{ onoff(cfg.react?.planning_enabled) }} / {{ onoff(cfg.react?.reflection_enabled) }}</a-descriptions-item>
|
||||
<a-descriptions-item label="JSON 修复">{{ onoff(cfg.react?.json_repair_enabled) }}</a-descriptions-item>
|
||||
<a-descriptions-item label="Token 预算">
|
||||
{{ onoff(cfg.token_budget?.enabled) }}
|
||||
<span v-if="cfg.token_budget?.enabled" class="mono text-xs ml-1">单请求 {{ cfg.token_budget?.per_request }} / 单次 {{ cfg.token_budget?.max_tokens_per_call }}</span>
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="Debug 日志">
|
||||
<span :style="{ color: cfg.debug?.log_request_body ? 'var(--warn)' : '' }">{{ cfg.debug?.log_request_body ? '开(含请求体,排查完记得关)' : '关' }}</span>
|
||||
</a-descriptions-item>
|
||||
</a-descriptions>
|
||||
<div class="text-xs mb-1.5" style="color: var(--text-3)">医疗守卫 {{ onoff(cfg.medical_guard?.enabled) }} · 白名单 {{ (cfg.medical_guard?.whitelist || []).length }} 词 · 黑名单 {{ (cfg.medical_guard?.blacklist || []).length }} 词</div>
|
||||
<a-collapse ghost size="small">
|
||||
<a-collapse-panel key="words" header="展开守卫词表">
|
||||
<div class="mb-2">
|
||||
<div class="text-xs mb-1" style="color: var(--text-3)">白名单(命中即放行)</div>
|
||||
<a-tag v-for="w in cfg.medical_guard?.whitelist || []" :key="w" color="success" :bordered="false" class="!mb-1">{{ w }}</a-tag>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-xs mb-1" style="color: var(--text-3)">黑名单(命中即拦截)</div>
|
||||
<a-tag v-for="b in cfg.medical_guard?.blacklist || []" :key="b" color="error" :bordered="false" class="!mb-1">{{ b }}</a-tag>
|
||||
</div>
|
||||
</a-collapse-panel>
|
||||
</a-collapse>
|
||||
</template>
|
||||
</GlassCard>
|
||||
|
||||
<!-- 面板会话 -->
|
||||
<GlassCard title="面板会话">
|
||||
<a-descriptions :column="1" size="small" bordered>
|
||||
<a-descriptions-item label="登录账号">{{ user.userInfo?.nick_name || user.userInfo?.username || '-' }}</a-descriptions-item>
|
||||
<a-descriptions-item label="角色"><a-tag :bordered="false" color="processing">{{ user.userInfo?.role_name || 'panel_admin' }}</a-tag></a-descriptions-item>
|
||||
<a-descriptions-item label="会话过期"><span class="mono text-xs">{{ expireText }}</span></a-descriptions-item>
|
||||
<a-descriptions-item label="续签策略">剩余 < 24h 时自动静默续签</a-descriptions-item>
|
||||
<a-descriptions-item label="旧面板">/ 与 /kb/view 口令鉴权,不受本面板影响</a-descriptions-item>
|
||||
</a-descriptions>
|
||||
</GlassCard>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
191
src/views/dashboard/index.vue
Normal file
191
src/views/dashboard/index.vue
Normal file
@@ -0,0 +1,191 @@
|
||||
<script setup>
|
||||
// 仪表盘:stats 概况卡(可点击带筛选跳转)+ 最近运行 + 服务状态 + 生效模型
|
||||
// 10s 自动轮询;每张卡都是对应页面的入口(多入口原则)
|
||||
import { ref, computed } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { message } from 'ant-design-vue'
|
||||
import { ThunderboltOutlined, ApiOutlined, ReloadOutlined } from '@ant-design/icons-vue'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import { getStats, getRuns, getSystem } from '@/api/agent'
|
||||
import { getActiveConfig, invalidateModelCache, modelTest } from '@/api/models'
|
||||
import { usePolling } from '@/composables/usePolling'
|
||||
import { fmtTime, fmtMs, fmtUptime, msTone, sceneLabel, estimateCost, fmtCost } from '@/utils/format'
|
||||
import PageHeader from '@/components/PageHeader.vue'
|
||||
import StatCard from '@/components/StatCard.vue'
|
||||
import StatusTag from '@/components/StatusTag.vue'
|
||||
import SceneTag from '@/components/SceneTag.vue'
|
||||
import EmptyHint from '@/components/EmptyHint.vue'
|
||||
import GlassCard from '@/components/GlassCard.vue'
|
||||
import KvGrid from '@/components/KvGrid.vue'
|
||||
|
||||
const router = useRouter()
|
||||
const app = useAppStore()
|
||||
|
||||
const stats = ref(null)
|
||||
const recentRuns = ref([])
|
||||
const sys = ref(null)
|
||||
const active = ref(null)
|
||||
const loading = ref(true)
|
||||
|
||||
const successRate = computed(() => {
|
||||
if (!stats.value || !stats.value.total) return '-'
|
||||
return ((stats.value.success / stats.value.total) * 100).toFixed(1)
|
||||
})
|
||||
// token 成本估算(用生效 provider 单价粗估缓冲内总消耗)
|
||||
const estCost = computed(() => {
|
||||
if (!stats.value) return '-'
|
||||
return fmtCost(estimateCost(active.value?.provider || 'default', stats.value.total_tokens * 0.7, stats.value.total_tokens * 0.3))
|
||||
})
|
||||
|
||||
async function load() {
|
||||
// allSettled:任一接口失败不影响其余卡片刷新(服务重启瞬间尤其重要),
|
||||
// 且 loading 一定会解除,避免首次加载失败时骨架屏永远转圈
|
||||
const [s, r, m] = await Promise.allSettled([
|
||||
getStats({ silent: true }),
|
||||
getRuns({ limit: 10 }, { silent: true }),
|
||||
getSystem({ silent: true })
|
||||
])
|
||||
if (s.status === 'fulfilled') stats.value = s.value.data
|
||||
if (r.status === 'fulfilled') recentRuns.value = r.value.data || []
|
||||
if (m.status === 'fulfilled') sys.value = m.value.data
|
||||
loading.value = false
|
||||
}
|
||||
usePolling(load, 10000)
|
||||
|
||||
// 生效模型卡(不高频轮询,进页拉一次 + 手动刷新)
|
||||
async function loadActive() {
|
||||
try {
|
||||
const res = await getActiveConfig({ silent: true })
|
||||
active.value = res.data
|
||||
} catch { active.value = null }
|
||||
}
|
||||
loadActive()
|
||||
|
||||
const invalidating = ref(false)
|
||||
async function invalidate() {
|
||||
invalidating.value = true
|
||||
try {
|
||||
const res = await invalidateModelCache()
|
||||
message.success(res.message || '缓存已失效')
|
||||
loadActive()
|
||||
} finally { invalidating.value = false }
|
||||
}
|
||||
const testing = ref(false)
|
||||
async function quickTest() {
|
||||
testing.value = true
|
||||
try {
|
||||
const res = await modelTest({})
|
||||
const d = res.data
|
||||
d.ok ? message.success(`连通正常:${d.provider}/${d.model}(${fmtMs(d.duration_ms)})`)
|
||||
: message.error(`连通失败:${d.error}`)
|
||||
} finally { testing.value = false }
|
||||
}
|
||||
|
||||
// 服务状态卡的键值对(KvGrid 数据化渲染,样式统一走组件)
|
||||
const sysItems = computed(() => {
|
||||
const s = sys.value
|
||||
if (!s) return []
|
||||
const used = s.enhance_concurrency?.used ?? 0
|
||||
const cap = s.enhance_concurrency?.capacity ?? 16
|
||||
return [
|
||||
{ label: '运行时长', value: fmtUptime(s.uptime_seconds) },
|
||||
{ label: 'enhance 并发', value: `${used} / ${cap}`, color: used / cap > 0.8 ? 'var(--warn)' : 'var(--ok)' },
|
||||
{ label: '堆内存', value: `${s.heap_alloc_mb?.toFixed(1)} MB` },
|
||||
{ label: 'goroutines', value: s.goroutines }
|
||||
]
|
||||
})
|
||||
// 生效模型卡:provider 与来源用插槽自定义(可点击/tag)
|
||||
const activeItems = computed(() => {
|
||||
const a = active.value
|
||||
if (!a) return []
|
||||
return [
|
||||
{ key: 'provider', label: 'provider', value: a.provider },
|
||||
{ label: 'model', value: a.model },
|
||||
{ label: 'key 尾号', value: a.api_key_tail ? `****${a.api_key_tail}` : '-' },
|
||||
{ key: 'source', label: '来源', value: a.source }
|
||||
]
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<PageHeader title="仪表盘" desc="Agent 运行概况(内存缓冲最近 200 次运行,10s 自动刷新)" />
|
||||
|
||||
<!-- 顶部统计卡:点击带筛选直达对应页面 -->
|
||||
<div class="grid grid-cols-2 lg:grid-cols-4 gap-4 mb-4">
|
||||
<StatCard label="缓冲内运行数" :value="stats?.total ?? '-'" :hint="`容量 ${stats?.buffer_size || 200} 条 · 点击查看列表`"
|
||||
clickable :loading="loading" @click="router.push('/runs')" />
|
||||
<StatCard label="成功率" :value="successRate" unit="%" :tone="Number(successRate) >= 90 ? 'ok' : 'warn'"
|
||||
:hint="`失败 ${stats?.failed ?? 0} · 拦截 ${stats?.blocked ?? 0} · 点击看失败`"
|
||||
clickable :loading="loading" @click="router.push('/runs?status=2')" />
|
||||
<StatCard label="平均耗时(成功)" :value="stats ? fmtMs(stats.avg_ms) : '-'" hint="点击看趋势"
|
||||
clickable :loading="loading" @click="router.push('/stats')" />
|
||||
<StatCard label="token 消耗" :value="stats?.total_tokens ?? '-'" :hint="`估算 ${estCost} · 点击看用量分析`"
|
||||
clickable :loading="loading" @click="router.push('/stats')" />
|
||||
</div>
|
||||
|
||||
<!-- 最近失败提醒 -->
|
||||
<a-alert v-if="stats?.last_error" type="warning" show-icon class="!mb-4"
|
||||
:message="`最近失败(${fmtTime(stats.last_error_at)}):${stats.last_error}`" />
|
||||
|
||||
<div class="grid grid-cols-1 xl:grid-cols-3 gap-4">
|
||||
<!-- 最近运行列表 -->
|
||||
<GlassCard class="xl:col-span-2" title="最近运行">
|
||||
<template #extra>
|
||||
<a-button size="small" type="link" @click="router.push('/runs')">全部 →</a-button>
|
||||
</template>
|
||||
<EmptyHint v-if="!recentRuns.length && !loading" text="还没有任何运行记录"
|
||||
action-text="去调试工具发一条测试请求" @action="router.push('/debug')" />
|
||||
<div v-else>
|
||||
<div v-for="r in recentRuns" :key="r.id"
|
||||
class="flex items-center gap-3 py-2 px-2 rounded-lg cursor-pointer glass-hover text-xs"
|
||||
style="border-bottom: 1px solid var(--glass-border)"
|
||||
@click="app.openRun(r.id)">
|
||||
<span class="mono w-10" style="color: var(--text-3)">#{{ r.id }}</span>
|
||||
<span class="w-24" style="color: var(--text-3)">{{ fmtTime(r.started_at) }}</span>
|
||||
<SceneTag :scene="r.scene" :clickable="false" />
|
||||
<span class="mono hidden md:inline" style="color: var(--text-2)">{{ r.provider }}/{{ r.model }}</span>
|
||||
<span class="ml-auto mono" :style="{ color: msTone(r.total_ms) }">{{ fmtMs(r.total_ms) }}</span>
|
||||
<span class="mono hidden sm:inline" style="color: var(--text-3)">{{ r.total_tokens }} tok</span>
|
||||
<StatusTag :status="r.status" />
|
||||
</div>
|
||||
</div>
|
||||
</GlassCard>
|
||||
|
||||
<div class="flex flex-col gap-4">
|
||||
<!-- 服务状态卡 -->
|
||||
<GlassCard title="服务状态">
|
||||
<template #extra>
|
||||
<a-button size="small" type="link" @click="router.push('/system')">详情 →</a-button>
|
||||
</template>
|
||||
<KvGrid v-if="sys" :items="sysItems" />
|
||||
<a-skeleton v-else active :paragraph="{ rows: 3 }" :title="false" />
|
||||
</GlassCard>
|
||||
|
||||
<!-- 生效模型卡(失效缓存 / 连通测试双入口) -->
|
||||
<GlassCard title="生效模型">
|
||||
<template #extra>
|
||||
<a-button size="small" type="text" @click="loadActive"><ReloadOutlined /></a-button>
|
||||
</template>
|
||||
<KvGrid v-if="active" :items="activeItems" class="mb-3">
|
||||
<template #provider="{ item }">
|
||||
<span class="cursor-pointer" style="color: var(--primary)" @click="router.push('/models')">{{ item.value }}</span>
|
||||
</template>
|
||||
<template #source="{ item }">
|
||||
<a-tag :bordered="false">{{ item.value }}</a-tag>
|
||||
</template>
|
||||
</KvGrid>
|
||||
<div v-else class="text-xs mb-3" style="color: var(--text-3)">配置加载失败(DB 未就绪?)</div>
|
||||
<div class="flex gap-2">
|
||||
<a-button size="small" :loading="invalidating" @click="invalidate">
|
||||
<template #icon><ThunderboltOutlined /></template>失效缓存
|
||||
</a-button>
|
||||
<a-button size="small" :loading="testing" @click="quickTest">
|
||||
<template #icon><ApiOutlined /></template>连通测试
|
||||
</a-button>
|
||||
</div>
|
||||
</GlassCard>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
283
src/views/debug/index.vue
Normal file
283
src/views/debug/index.vue
Normal file
@@ -0,0 +1,283 @@
|
||||
<script setup>
|
||||
// 调试工具:守卫测试 / KB 检索测试 / 模型连通测试 / Enhance 全链路测试
|
||||
// - 每个测试台入参持久化 localStorage(下次进来接着调)
|
||||
// - 运行详情「重放」与检索页「注入调试」都会回填到这里(互通)
|
||||
// - 样例模板一键填充(默认值填满原则)
|
||||
import { ref, reactive, onMounted, watch } from 'vue'
|
||||
import { message } from 'ant-design-vue'
|
||||
import { SafetyOutlined, FileSearchOutlined, ApiOutlined, RocketOutlined } from '@ant-design/icons-vue'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import { guardTest, kbTest, enhanceTest } from '@/api/agent'
|
||||
import { modelTest, getModelRoutes } from '@/api/models'
|
||||
import { fmtMs, SCENE_OPTIONS } from '@/utils/format'
|
||||
import PageHeader from '@/components/PageHeader.vue'
|
||||
import StepTimeline from '@/components/StepTimeline.vue'
|
||||
import JsonViewer from '@/components/JsonViewer.vue'
|
||||
import HelpTip from '@/components/HelpTip.vue'
|
||||
|
||||
const app = useAppStore()
|
||||
const STORE_KEY = 'agent_admin_debug_inputs'
|
||||
|
||||
// ---------------- 入参持久化 ----------------
|
||||
const saved = (() => { try { return JSON.parse(localStorage.getItem(STORE_KEY) || '{}') } catch { return {} } })()
|
||||
function persist() {
|
||||
localStorage.setItem(STORE_KEY, JSON.stringify({
|
||||
guardText: guard.text, kbQuery: kb.query, kbTopK: kb.topK,
|
||||
mtProvider: mt.provider, mtMessage: mt.message,
|
||||
enhScene: enh.scene, enhContext: enh.context, enhKB: enh.kbEnabled,
|
||||
enhTopK: enh.topK, enhMessages: enh.messagesText
|
||||
}))
|
||||
}
|
||||
|
||||
// ---------------- 1. 医疗守卫测试 ----------------
|
||||
const guard = reactive({ text: saved.guardText || '', loading: false, result: null })
|
||||
const guardPresets = [
|
||||
{ label: '病历样例', text: '患者胃疼一月有余,时轻时重,伴干呕,舌淡红苔白,脉弦细,请辨证。' },
|
||||
{ label: '处方样例', text: '脾胃虚寒证,拟温中健脾,请开一副中药饮片方。' },
|
||||
{ label: '无关文本', text: '帮我写一份项目管理计划,包括甘特图和资源分配。' }
|
||||
]
|
||||
async function runGuard() {
|
||||
if (!guard.text.trim()) { message.warning('请输入测试文本'); return }
|
||||
guard.loading = true
|
||||
persist()
|
||||
try {
|
||||
const res = await guardTest(guard.text)
|
||||
guard.result = res.data
|
||||
} finally { guard.loading = false }
|
||||
}
|
||||
|
||||
// ---------------- 2. KB 检索测试 ----------------
|
||||
const kb = reactive({ query: saved.kbQuery || '', topK: saved.kbTopK || 5, loading: false, result: null })
|
||||
async function runKB() {
|
||||
if (!kb.query.trim()) { message.warning('请输入检索词'); return }
|
||||
kb.loading = true
|
||||
persist()
|
||||
try {
|
||||
const res = await kbTest({ query: kb.query, top_k: kb.topK })
|
||||
kb.result = res.data
|
||||
} finally { kb.loading = false }
|
||||
}
|
||||
|
||||
// ---------------- 3. 模型连通测试 ----------------
|
||||
const mt = reactive({ provider: saved.mtProvider || '', message: saved.mtMessage || '', loading: false, result: null })
|
||||
const providerOptions = ref([{ label: '当前生效配置', value: '' }])
|
||||
async function loadProviders() {
|
||||
try {
|
||||
const res = await getModelRoutes({ silent: true })
|
||||
const set = new Set(Object.values(res.routes || {}))
|
||||
providerOptions.value = [{ label: '当前生效配置', value: '' }, ...[...set].map(p => ({ label: p, value: p }))]
|
||||
} catch { /* 下拉加载失败不阻断 */ }
|
||||
}
|
||||
async function runModelTest() {
|
||||
mt.loading = true
|
||||
persist()
|
||||
try {
|
||||
const res = await modelTest({ provider: mt.provider, message: mt.message })
|
||||
mt.result = res.data
|
||||
} finally { mt.loading = false }
|
||||
}
|
||||
|
||||
// ---------------- 4. Enhance 全链路测试 ----------------
|
||||
const MSG_TEMPLATES = {
|
||||
medical_record: JSON.stringify([
|
||||
{ role: 'system', content: '你是资深中医师,请根据患者主诉生成结构化病历 JSON。' },
|
||||
{ role: 'user', content: '患者男,23 岁,主诉:胃疼一月有余,时轻时重,有干呕。请生成完整病历。' }
|
||||
], null, 2),
|
||||
prescription: JSON.stringify([
|
||||
{ role: 'system', content: '你是资深中医师与方剂学专家,请辨证论治并开出完整中药饮片方,只输出 JSON。' },
|
||||
{ role: 'user', content: '患者女,35 岁,脾胃虚寒证,症见胃脘冷痛、喜温喜按、泛吐清水。请开方。' }
|
||||
], null, 2)
|
||||
}
|
||||
const enh = reactive({
|
||||
scene: saved.enhScene || 'medical_record',
|
||||
context: saved.enhContext || '',
|
||||
kbEnabled: saved.enhKB ?? false,
|
||||
topK: saved.enhTopK || 5,
|
||||
messagesText: saved.enhMessages || MSG_TEMPLATES.medical_record,
|
||||
loading: false,
|
||||
result: null,
|
||||
error: '',
|
||||
replayFrom: 0
|
||||
})
|
||||
function fillTemplate(scene) {
|
||||
enh.scene = scene
|
||||
enh.messagesText = MSG_TEMPLATES[scene] || MSG_TEMPLATES.medical_record
|
||||
message.success('已填充样例模板')
|
||||
}
|
||||
async function runEnhance() {
|
||||
let messages
|
||||
try {
|
||||
messages = JSON.parse(enh.messagesText)
|
||||
if (!Array.isArray(messages) || !messages.length) throw new Error()
|
||||
} catch {
|
||||
message.error('messages 必须是非空 JSON 数组([{role, content}])')
|
||||
return
|
||||
}
|
||||
enh.loading = true
|
||||
enh.result = null
|
||||
enh.error = ''
|
||||
persist()
|
||||
try {
|
||||
const body = { scene: enh.scene, messages, kb_enabled: enh.kbEnabled }
|
||||
// 只在启用 KB 时携带 context/top_k(与 PHP 调用行为一致)
|
||||
if (enh.kbEnabled) {
|
||||
body.context = enh.context
|
||||
body.top_k = enh.topK
|
||||
}
|
||||
const res = await enhanceTest(body)
|
||||
enh.result = res.data
|
||||
} catch (e) {
|
||||
enh.error = e.message || '调用失败'
|
||||
} finally { enh.loading = false }
|
||||
}
|
||||
|
||||
// ---------------- 重放 / 注入回填(互通入口) ----------------
|
||||
// 消费时机有两个:
|
||||
// 1. onMounted —— 从别的页面点「重放/注入」跳转进来
|
||||
// 2. watch —— 用户已经在调试台页面时点(此时不会重新挂载,靠 watch 即时响应)
|
||||
const activeKeys = ref(['enhance'])
|
||||
function applyReplay(replay) {
|
||||
if (!replay) return
|
||||
enh.scene = replay.scene || 'medical_record'
|
||||
enh.context = replay.context || ''
|
||||
enh.kbEnabled = !!replay.kb_enabled
|
||||
enh.topK = replay.top_k || 5
|
||||
enh.messagesText = JSON.stringify(replay.messages || [], null, 2)
|
||||
enh.replayFrom = replay.fromRunId || 0
|
||||
activeKeys.value = ['enhance']
|
||||
message.info(`已从运行 #${replay.fromRunId} 回填入参,点击「执行」即可重跑`)
|
||||
}
|
||||
function applyInject(inject) {
|
||||
if (!inject) return
|
||||
enh.kbEnabled = true
|
||||
enh.context = inject.query || ''
|
||||
const msgs = [
|
||||
{ role: 'system', content: '你是资深中医师,请结合参考资料回答。' },
|
||||
{ role: 'user', content: `参考资料:\n${inject.content}\n\n请基于以上内容生成建议。` }
|
||||
]
|
||||
enh.messagesText = JSON.stringify(msgs, null, 2)
|
||||
activeKeys.value = ['enhance']
|
||||
message.info('已把检索结果注入 Enhance 测试台')
|
||||
}
|
||||
watch(() => app.replayPayload, (p) => { if (p) applyReplay(app.consumeReplay()) })
|
||||
watch(() => app.injectContext, (p) => { if (p) applyInject(app.consumeInject()) })
|
||||
onMounted(() => {
|
||||
loadProviders()
|
||||
applyReplay(app.consumeReplay())
|
||||
applyInject(app.consumeInject())
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<PageHeader title="调试工具" desc="四个测试台覆盖 Agent 全链路:守卫 → 检索 → 模型 → Enhance;入参自动保存" />
|
||||
<HelpTip id="debug" text="不知道从哪开始?展开「Enhance 全链路」点一下样例模板再点执行,就能看到 Agent 完整跑一遍的每个步骤。" />
|
||||
|
||||
<a-collapse v-model:active-key="activeKeys" expand-icon-position="end" class="glass !border-0">
|
||||
<!-- 1. 医疗守卫 -->
|
||||
<a-collapse-panel key="guard">
|
||||
<template #header><span class="flex items-center gap-2"><SafetyOutlined style="color: var(--warn)" />医疗守卫测试 <span class="text-xs" style="color: var(--text-3)">判断文本是否医疗相关(拦截跑题请求)</span></span></template>
|
||||
<div class="flex gap-2 mb-2 flex-wrap">
|
||||
<a-button v-for="p in guardPresets" :key="p.label" size="small" @click="guard.text = p.text">{{ p.label }}</a-button>
|
||||
</div>
|
||||
<a-textarea v-model:value="guard.text" :rows="3" placeholder="输入要检测的文本…" class="mb-2" />
|
||||
<a-button type="primary" :loading="guard.loading" @click="runGuard">测试</a-button>
|
||||
<div v-if="guard.result" class="mt-3 glass p-3">
|
||||
<a-tag :color="guard.result.passed ? 'success' : 'error'" class="!text-sm">{{ guard.result.passed ? '放行' : '拦截' }}</a-tag>
|
||||
<span v-if="guard.result.reason" class="text-xs ml-2" style="color: var(--text-2)">{{ guard.result.reason }}</span>
|
||||
<div v-if="(guard.result.hit_whitelist || []).length" class="mt-2 flex gap-1 flex-wrap items-center">
|
||||
<span class="text-xs" style="color: var(--text-3)">命中白名单:</span>
|
||||
<a-tag v-for="w in guard.result.hit_whitelist" :key="w" color="success" :bordered="false">{{ w }}</a-tag>
|
||||
</div>
|
||||
<div v-if="(guard.result.hit_blacklist || []).length" class="mt-2 flex gap-1 flex-wrap items-center">
|
||||
<span class="text-xs" style="color: var(--text-3)">命中黑名单:</span>
|
||||
<a-tag v-for="b in guard.result.hit_blacklist" :key="b" color="error" :bordered="false">{{ b }}</a-tag>
|
||||
</div>
|
||||
</div>
|
||||
</a-collapse-panel>
|
||||
|
||||
<!-- 2. KB 检索 -->
|
||||
<a-collapse-panel key="kb">
|
||||
<template #header><span class="flex items-center gap-2"><FileSearchOutlined style="color: var(--accent)" />KB 检索测试 <span class="text-xs" style="color: var(--text-3)">回答「Agent 实际会检索到什么」(走 enhance 同款路径)</span></span></template>
|
||||
<div class="flex gap-3 items-center mb-2 flex-wrap">
|
||||
<a-input v-model:value="kb.query" placeholder="检索词,如:脾胃虚寒 温中健脾" style="width: 300px" @press-enter="runKB" />
|
||||
<span class="text-xs" style="color: var(--text-3)">TopK</span>
|
||||
<a-slider v-model:value="kb.topK" :min="1" :max="10" style="width: 140px" />
|
||||
<a-button type="primary" :loading="kb.loading" @click="runKB">检索</a-button>
|
||||
</div>
|
||||
<div v-if="kb.result" class="glass p-3">
|
||||
<div class="text-xs mb-2" style="color: var(--text-2)">{{ kb.result.source }} · {{ fmtMs(kb.result.duration_ms) }}</div>
|
||||
<div v-if="kb.result.error" class="text-xs" style="color: var(--err)">{{ kb.result.error }}</div>
|
||||
<div v-else-if="!(kb.result.docs || []).length" class="text-xs" style="color: var(--text-3)">未命中任何文档</div>
|
||||
<div v-for="(d, i) in kb.result.docs || []" :key="i"
|
||||
class="text-xs p-2 mb-1.5 rounded" style="background: rgba(0,0,0,0.15); color: var(--text-2); white-space: pre-wrap">{{ d }}</div>
|
||||
</div>
|
||||
</a-collapse-panel>
|
||||
|
||||
<!-- 3. 模型连通 -->
|
||||
<a-collapse-panel key="model">
|
||||
<template #header><span class="flex items-center gap-2"><ApiOutlined style="color: var(--run)" />模型连通测试 <span class="text-xs" style="color: var(--text-3)">真实调一次 LLM(max_tokens 64,成本可忽略)</span></span></template>
|
||||
<div class="flex gap-3 items-center mb-2 flex-wrap">
|
||||
<a-select v-model:value="mt.provider" :options="providerOptions" style="width: 180px" />
|
||||
<a-input v-model:value="mt.message" placeholder="测试消息(留空用默认)" style="width: 280px" />
|
||||
<a-button type="primary" :loading="mt.loading" @click="runModelTest">测试</a-button>
|
||||
</div>
|
||||
<div v-if="mt.result" class="glass p-3 text-xs">
|
||||
<div class="flex items-center gap-2 mb-2 flex-wrap">
|
||||
<a-tag :color="mt.result.ok ? 'success' : 'error'">{{ mt.result.ok ? '连通正常' : '连通失败' }}</a-tag>
|
||||
<span class="mono" style="color: var(--text-2)">{{ mt.result.provider }}/{{ mt.result.model }}</span>
|
||||
<a-tag :bordered="false">{{ mt.result.cfg_source }}</a-tag>
|
||||
<span class="mono" style="color: var(--text-3)">{{ fmtMs(mt.result.duration_ms) }}</span>
|
||||
<span class="mono" style="color: var(--text-3)">key#{{ mt.result.api_key_id }}</span>
|
||||
</div>
|
||||
<div v-if="mt.result.reply" style="color: var(--text-2)">回复:{{ mt.result.reply }}</div>
|
||||
<div v-if="mt.result.error" style="color: var(--err)">{{ mt.result.error }}</div>
|
||||
</div>
|
||||
</a-collapse-panel>
|
||||
|
||||
<!-- 4. Enhance 全链路 -->
|
||||
<a-collapse-panel key="enhance">
|
||||
<template #header><span class="flex items-center gap-2"><RocketOutlined style="color: var(--primary)" />Enhance 全链路测试 <span class="text-xs" style="color: var(--text-3)">跑一遍完整 Agent 流程(守卫 → KB → 计划 → LLM → 反思)</span></span></template>
|
||||
<a-alert v-if="enh.replayFrom" type="info" show-icon closable class="!mb-3"
|
||||
:message="`当前入参来自运行 #${enh.replayFrom} 的快照重放(可能被截断)`" @close="enh.replayFrom = 0" />
|
||||
<div class="flex gap-3 items-center mb-2 flex-wrap">
|
||||
<a-select v-model:value="enh.scene" :options="SCENE_OPTIONS" style="width: 140px" />
|
||||
<a-button size="small" @click="fillTemplate('medical_record')">病历模板</a-button>
|
||||
<a-button size="small" @click="fillTemplate('prescription')">处方模板</a-button>
|
||||
<span class="text-xs ml-2" style="color: var(--text-3)">知识库</span>
|
||||
<a-switch v-model:checked="enh.kbEnabled" size="small" />
|
||||
<template v-if="enh.kbEnabled">
|
||||
<a-input v-model:value="enh.context" placeholder="检索关键词 context" style="width: 200px" />
|
||||
<span class="text-xs" style="color: var(--text-3)">TopK</span>
|
||||
<a-input-number v-model:value="enh.topK" :min="1" :max="10" size="small" />
|
||||
</template>
|
||||
</div>
|
||||
<div class="text-xs mb-1" style="color: var(--text-3)">messages(JSON 数组)</div>
|
||||
<a-textarea v-model:value="enh.messagesText" :rows="8" class="mono !text-xs mb-2" />
|
||||
<a-button type="primary" :loading="enh.loading" @click="runEnhance">
|
||||
执行{{ enh.loading ? '中(最长 2 分钟)' : '' }}
|
||||
</a-button>
|
||||
|
||||
<div v-if="enh.error" class="mt-3"><a-alert type="error" :message="enh.error" show-icon /></div>
|
||||
<div v-if="enh.result" class="mt-3">
|
||||
<div class="flex items-center gap-2 mb-2 flex-wrap text-xs">
|
||||
<a-tag color="success">完成</a-tag>
|
||||
<span class="mono" style="color: var(--text-2)">{{ enh.result.provider }}/{{ enh.result.model }}</span>
|
||||
<span class="mono" style="color: var(--text-3)">{{ fmtMs(enh.result.total_ms) }}</span>
|
||||
</div>
|
||||
<div class="glass p-3 mb-3">
|
||||
<div class="text-xs font-medium mb-2" style="color: var(--text-1)">步骤时间线</div>
|
||||
<StepTimeline :steps="enh.result.steps || []" />
|
||||
</div>
|
||||
<div class="text-xs font-medium mb-1" style="color: var(--text-1)">生成内容</div>
|
||||
<JsonViewer :value="enh.result.content" max-height="300px" />
|
||||
</div>
|
||||
</a-collapse-panel>
|
||||
</a-collapse>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
:deep(.ant-collapse) { background: transparent; }
|
||||
:deep(.ant-collapse-item) { border-color: var(--glass-border) !important; }
|
||||
</style>
|
||||
180
src/views/history/index.vue
Normal file
180
src/views/history/index.vue
Normal file
@@ -0,0 +1,180 @@
|
||||
<script setup>
|
||||
// 历史记录:读 PHP 落库的 xk_ai_generation(_step),补齐长期视角
|
||||
// 与「运行记录」的差异:这里是权威审计数据、支持分页与日期筛选、重启不丢
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { message } from 'ant-design-vue'
|
||||
import { getHistory, getHistoryDetail, getHistoryScenes } from '@/api/agent'
|
||||
import { fmtTime, fmtTimeFull, fmtMs, msTone, sceneLabel, estimateCost, fmtCost } from '@/utils/format'
|
||||
import PageHeader from '@/components/PageHeader.vue'
|
||||
import StatusTag from '@/components/StatusTag.vue'
|
||||
import SceneTag from '@/components/SceneTag.vue'
|
||||
import StepTimeline from '@/components/StepTimeline.vue'
|
||||
import JsonViewer from '@/components/JsonViewer.vue'
|
||||
import EmptyHint from '@/components/EmptyHint.vue'
|
||||
import HelpTip from '@/components/HelpTip.vue'
|
||||
|
||||
const rows = ref([])
|
||||
const total = ref(0)
|
||||
const loading = ref(false)
|
||||
const sceneOptions = ref([])
|
||||
|
||||
const filter = reactive({
|
||||
page: 1,
|
||||
size: 20,
|
||||
scene: '',
|
||||
status: -1,
|
||||
via_agent: -1,
|
||||
range: null // [dayjs, dayjs]
|
||||
})
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try {
|
||||
const params = {
|
||||
page: filter.page, size: filter.size,
|
||||
scene: filter.scene || '',
|
||||
status: filter.status, via_agent: filter.via_agent
|
||||
}
|
||||
if (filter.range && filter.range.length === 2) {
|
||||
params.date_start = Math.floor(filter.range[0].startOf('day').valueOf() / 1000)
|
||||
params.date_end = Math.floor(filter.range[1].endOf('day').valueOf() / 1000)
|
||||
}
|
||||
const res = await getHistory(params)
|
||||
rows.value = res.data.list || []
|
||||
total.value = res.data.total || 0
|
||||
} finally { loading.value = false }
|
||||
}
|
||||
|
||||
function search() { filter.page = 1; load() }
|
||||
function onPageChange(page, size) { filter.page = page; filter.size = size; load() }
|
||||
|
||||
onMounted(async () => {
|
||||
load()
|
||||
try {
|
||||
const res = await getHistoryScenes()
|
||||
sceneOptions.value = (res.data || []).map(s => ({ label: sceneLabel(s), value: s }))
|
||||
} catch { /* 场景下拉失败不阻断 */ }
|
||||
})
|
||||
|
||||
// ---------------- 详情抽屉 ----------------
|
||||
const detailOpen = ref(false)
|
||||
const detailLoading = ref(false)
|
||||
const detail = ref(null)
|
||||
const steps = ref([])
|
||||
async function openDetail(row) {
|
||||
detailOpen.value = true
|
||||
detailLoading.value = true
|
||||
try {
|
||||
const res = await getHistoryDetail(row.id)
|
||||
detail.value = res.data.generation
|
||||
steps.value = res.data.steps || []
|
||||
} catch (e) {
|
||||
message.error(e.message || '加载详情失败')
|
||||
detailOpen.value = false
|
||||
} finally { detailLoading.value = false }
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<PageHeader title="历史记录" desc="AI 生成历史(数据库长期数据,PHP 侧每次生成落库;只读)" />
|
||||
<HelpTip id="history" text="这里是数据库里的权威审计数据(重启不丢)。「运行记录」页看的是 Go 内存里最近 200 条实时轨迹,两者互补。" />
|
||||
|
||||
<!-- 筛选条 -->
|
||||
<div class="glass p-3 mb-4 flex items-center gap-3 flex-wrap">
|
||||
<a-select v-model:value="filter.scene" style="width: 130px" placeholder="全部场景"
|
||||
:options="[{ label: '全部场景', value: '' }, ...sceneOptions]" @change="search" />
|
||||
<a-select v-model:value="filter.status" style="width: 110px"
|
||||
:options="[{ label: '全部状态', value: -1 }, { label: '进行中', value: 0 }, { label: '成功', value: 1 }, { label: '失败', value: 2 }]"
|
||||
@change="search" />
|
||||
<a-select v-model:value="filter.via_agent" style="width: 130px"
|
||||
:options="[{ label: '全部路径', value: -1 }, { label: 'Go Agent', value: 1 }, { label: 'PHP 直连', value: 0 }]"
|
||||
@change="search" />
|
||||
<a-range-picker v-model:value="filter.range" @change="search" />
|
||||
<a-button type="primary" @click="search">查询</a-button>
|
||||
</div>
|
||||
|
||||
<div class="glass overflow-hidden">
|
||||
<a-skeleton v-if="loading && !rows.length" active class="p-4" />
|
||||
<EmptyHint v-else-if="!rows.length" text="没有符合条件的历史记录" />
|
||||
<template v-else>
|
||||
<div class="overflow-auto">
|
||||
<table class="w-full text-xs" style="border-collapse: collapse">
|
||||
<thead>
|
||||
<tr style="color: var(--text-3); border-bottom: 1px solid var(--glass-border)">
|
||||
<th class="text-left px-3 py-2 font-medium">ID</th>
|
||||
<th class="text-left px-3 py-2 font-medium">时间</th>
|
||||
<th class="text-left px-3 py-2 font-medium">场景</th>
|
||||
<th class="text-left px-3 py-2 font-medium">名称</th>
|
||||
<th class="text-left px-3 py-2 font-medium">provider/model</th>
|
||||
<th class="text-left px-3 py-2 font-medium">路径</th>
|
||||
<th class="text-left px-3 py-2 font-medium">步骤</th>
|
||||
<th class="text-right px-3 py-2 font-medium">tokens</th>
|
||||
<th class="text-right px-3 py-2 font-medium">成本</th>
|
||||
<th class="text-right px-3 py-2 font-medium">耗时</th>
|
||||
<th class="text-left px-3 py-2 font-medium">状态</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="r in rows" :key="r.id" class="cursor-pointer glass-hover"
|
||||
style="border-bottom: 1px solid var(--glass-border)" @click="openDetail(r)">
|
||||
<td class="px-3 py-2 mono" style="color: var(--text-3)">{{ r.id }}</td>
|
||||
<td class="px-3 py-2" style="color: var(--text-2)">{{ fmtTime(r.created_at) }}</td>
|
||||
<td class="px-3 py-2"><SceneTag :scene="r.scene" :clickable="false" /></td>
|
||||
<td class="px-3 py-2 max-w-[140px] truncate" style="color: var(--text-2)">{{ r.name || '-' }}</td>
|
||||
<td class="px-3 py-2 mono" style="color: var(--text-2)">{{ r.provider }}/{{ r.model }}</td>
|
||||
<td class="px-3 py-2">
|
||||
<a-tag :color="r.via_agent ? 'gold' : 'default'" :bordered="false">{{ r.via_agent ? 'Agent' : '直连' }}</a-tag>
|
||||
</td>
|
||||
<td class="px-3 py-2 mono" style="color: var(--text-3)">{{ r.step_count }}</td>
|
||||
<td class="px-3 py-2 mono text-right" style="color: var(--text-3)">{{ r.total_tokens }}</td>
|
||||
<td class="px-3 py-2 mono text-right" style="color: var(--text-3)">{{ fmtCost(estimateCost(r.provider, r.prompt_tokens, r.completion_tokens)) }}</td>
|
||||
<td class="px-3 py-2 mono text-right" :style="{ color: msTone(r.duration_ms) }">{{ fmtMs(r.duration_ms) }}</td>
|
||||
<td class="px-3 py-2"><StatusTag :status="r.status" kind="gen" /></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="flex justify-end p-3">
|
||||
<a-pagination :current="filter.page" :page-size="filter.size" :total="total"
|
||||
show-size-changer :page-size-options="['20', '50', '100']"
|
||||
:show-total="t => `共 ${t} 条`" @change="onPageChange" />
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- 详情抽屉 -->
|
||||
<a-drawer v-model:open="detailOpen" :width="720" :title="detail ? `生成记录 #${detail.id}` : '详情'">
|
||||
<a-skeleton v-if="detailLoading" active :paragraph="{ rows: 8 }" />
|
||||
<template v-else-if="detail">
|
||||
<div class="flex items-center gap-2 mb-4 flex-wrap">
|
||||
<StatusTag :status="detail.status" kind="gen" />
|
||||
<SceneTag :scene="detail.scene" :clickable="false" />
|
||||
<a-tag :color="detail.via_agent ? 'gold' : 'default'" :bordered="false">{{ detail.via_agent ? 'Go Agent' : 'PHP 直连' }}</a-tag>
|
||||
<span class="mono text-xs" style="color: var(--text-2)">{{ detail.provider }}/{{ detail.model }}</span>
|
||||
</div>
|
||||
<a-descriptions :column="2" size="small" bordered class="mb-4">
|
||||
<a-descriptions-item label="创建时间">{{ fmtTimeFull(detail.created_at) }}</a-descriptions-item>
|
||||
<a-descriptions-item label="耗时"><span class="mono">{{ fmtMs(detail.duration_ms) }}</span></a-descriptions-item>
|
||||
<a-descriptions-item label="tokens"><span class="mono">{{ detail.prompt_tokens }}+{{ detail.completion_tokens }}={{ detail.total_tokens }}</span></a-descriptions-item>
|
||||
<a-descriptions-item label="门店/医生"><span class="mono">{{ detail.store_id }} / {{ detail.doctor_id }}</span></a-descriptions-item>
|
||||
<a-descriptions-item v-if="detail.error_msg" label="失败原因" :span="2">
|
||||
<span style="color: var(--err)">{{ detail.error_msg }}</span>
|
||||
</a-descriptions-item>
|
||||
</a-descriptions>
|
||||
|
||||
<h4 class="text-sm font-medium mb-2" style="color: var(--text-1)">步骤明细({{ steps.length }} 步)</h4>
|
||||
<div class="glass p-3 mb-4"><StepTimeline :steps="steps" /></div>
|
||||
|
||||
<template v-if="detail.input_snapshot">
|
||||
<h4 class="text-sm font-medium mb-2" style="color: var(--text-1)">输入快照</h4>
|
||||
<JsonViewer :value="detail.input_snapshot" max-height="220px" class="mb-4" />
|
||||
</template>
|
||||
<template v-if="detail.result_json">
|
||||
<h4 class="text-sm font-medium mb-2" style="color: var(--text-1)">生成结果</h4>
|
||||
<JsonViewer :value="detail.result_json" max-height="260px" />
|
||||
</template>
|
||||
</template>
|
||||
</a-drawer>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,11 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
你好
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
||||
</style>
|
||||
308
src/views/kb-crawl/index.vue
Normal file
308
src/views/kb-crawl/index.vue
Normal file
@@ -0,0 +1,308 @@
|
||||
<script setup>
|
||||
// 药品抓取:定时任务把中药材(含别名/性味归经/功效等)抓进知识库
|
||||
// - 任务卡片:调度配置 + 断点进度 + 上次运行摘要 + 立即抓取/启停/历史
|
||||
// - 运行中任务 5s 轮询刷新(抓一批约 1-3 分钟,进度靠日志兜底)
|
||||
// - 新建/编辑共用一个弹窗;目标库可在弹窗里一键快速创建
|
||||
import { ref, reactive, computed, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { message } from 'ant-design-vue'
|
||||
import {
|
||||
PlusOutlined, PlayCircleOutlined, HistoryOutlined, EditOutlined,
|
||||
DeleteOutlined, CloudDownloadOutlined, LoadingOutlined, DatabaseOutlined
|
||||
} from '@ant-design/icons-vue'
|
||||
import {
|
||||
getCrawlSources, getCrawlTasks, createCrawlTask, updateCrawlTask,
|
||||
deleteCrawlTask, runCrawlTask, getCrawlLogs, getLibraries, createLibrary
|
||||
} from '@/api/kb'
|
||||
import { fmtTime, fmtMs } from '@/utils/format'
|
||||
import { usePolling } from '@/composables/usePolling'
|
||||
import PageHeader from '@/components/PageHeader.vue'
|
||||
import HelpTip from '@/components/HelpTip.vue'
|
||||
import EmptyHint from '@/components/EmptyHint.vue'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
// ---------------- 基础数据 ----------------
|
||||
const tasks = ref([])
|
||||
const sources = ref([])
|
||||
const libraries = ref([])
|
||||
const loading = ref(true)
|
||||
|
||||
async function loadTasks() {
|
||||
try {
|
||||
const res = await getCrawlTasks({ silent: true })
|
||||
tasks.value = res.data || []
|
||||
} finally { loading.value = false }
|
||||
}
|
||||
async function loadMeta() {
|
||||
try {
|
||||
const [s, l] = await Promise.allSettled([getCrawlSources(), getLibraries({ silent: true })])
|
||||
if (s.status === 'fulfilled') sources.value = s.value.data || []
|
||||
if (l.status === 'fulfilled') libraries.value = l.value.data || []
|
||||
} catch { /* 下拉数据失败不阻断页面 */ }
|
||||
}
|
||||
onMounted(loadMeta)
|
||||
// 任务列表 5s 轮询:运行中的任务要实时看到状态翻转(running → success)
|
||||
usePolling(loadTasks, 5000)
|
||||
|
||||
const libName = (id) => libraries.value.find(l => l.id === id)?.name || `库#${id}`
|
||||
const sourceLabel = (name) => sources.value.find(s => s.name === name)?.label || name
|
||||
|
||||
// 调度方式的人类可读描述(卡片展示)
|
||||
const WEEKDAYS = ['周日', '周一', '周二', '周三', '周四', '周五', '周六']
|
||||
function scheduleDesc(t) {
|
||||
switch (t.schedule_type) {
|
||||
case 'interval': return `每 ${t.interval_hours} 小时`
|
||||
case 'daily': return `每天 ${String(t.run_at_hour).padStart(2, '0')}:00`
|
||||
case 'weekly': return `${WEEKDAYS[t.run_at_weekday] || '周一'} ${String(t.run_at_hour).padStart(2, '0')}:00`
|
||||
default: return '仅手动'
|
||||
}
|
||||
}
|
||||
const statusMeta = {
|
||||
running: { color: 'processing', label: '运行中' },
|
||||
success: { color: 'success', label: '成功' },
|
||||
partial: { color: 'warning', label: '部分成功' },
|
||||
failed: { color: 'error', label: '失败' }
|
||||
}
|
||||
|
||||
// ---------------- 新建/编辑弹窗 ----------------
|
||||
const modalOpen = ref(false)
|
||||
const editingId = ref(0)
|
||||
const saving = ref(false)
|
||||
const form = reactive({
|
||||
name: '', source: 'zhongyoo', library_id: undefined,
|
||||
schedule_type: 'daily', interval_hours: 24, run_at_hour: 3,
|
||||
run_at_weekday: 1, items_per_run: 50, status: 1
|
||||
})
|
||||
function openCreate() {
|
||||
editingId.value = 0
|
||||
Object.assign(form, {
|
||||
name: '中药药材库每日增量', source: sources.value[0]?.name || 'zhongyoo',
|
||||
library_id: libraries.value[0]?.id, schedule_type: 'daily',
|
||||
interval_hours: 24, run_at_hour: 3, run_at_weekday: 1, items_per_run: 50, status: 1
|
||||
})
|
||||
modalOpen.value = true
|
||||
}
|
||||
function openEdit(t) {
|
||||
editingId.value = t.id
|
||||
Object.assign(form, {
|
||||
name: t.name, source: t.source, library_id: t.library_id,
|
||||
schedule_type: t.schedule_type, interval_hours: t.interval_hours,
|
||||
run_at_hour: t.run_at_hour, run_at_weekday: t.run_at_weekday,
|
||||
items_per_run: t.items_per_run, status: t.status
|
||||
})
|
||||
modalOpen.value = true
|
||||
}
|
||||
async function save() {
|
||||
if (!form.name.trim()) { message.warning('请填写任务名称'); return }
|
||||
if (!form.library_id) { message.warning('请选择目标知识库'); return }
|
||||
saving.value = true
|
||||
try {
|
||||
if (editingId.value) await updateCrawlTask(editingId.value, { ...form })
|
||||
else await createCrawlTask({ ...form })
|
||||
message.success(editingId.value ? '任务已更新' : '任务已创建')
|
||||
modalOpen.value = false
|
||||
loadTasks()
|
||||
} finally { saving.value = false }
|
||||
}
|
||||
// 快速建库:没有合适的目标库时一键创建(少一次页面往返)
|
||||
const quickCreating = ref(false)
|
||||
async function quickCreateLibrary() {
|
||||
quickCreating.value = true
|
||||
try {
|
||||
const res = await createLibrary({ name: '中药药材库', description: '定时抓取的中药材知识(含别名/性味归经/功效/禁忌)', source: 'manual' })
|
||||
message.success('已创建「中药药材库」')
|
||||
await loadMeta()
|
||||
form.library_id = res.data?.id || libraries.value.find(l => l.name === '中药药材库')?.id
|
||||
} finally { quickCreating.value = false }
|
||||
}
|
||||
|
||||
// ---------------- 任务操作 ----------------
|
||||
async function triggerRun(t) {
|
||||
const res = await runCrawlTask(t.id)
|
||||
message.success(res.message || '已启动抓取')
|
||||
loadTasks()
|
||||
}
|
||||
async function toggleStatus(t) {
|
||||
await updateCrawlTask(t.id, {
|
||||
name: t.name, source: t.source, library_id: t.library_id,
|
||||
schedule_type: t.schedule_type, interval_hours: t.interval_hours,
|
||||
run_at_hour: t.run_at_hour, run_at_weekday: t.run_at_weekday,
|
||||
items_per_run: t.items_per_run, status: t.status === 1 ? 0 : 1
|
||||
})
|
||||
message.success(t.status === 1 ? '任务已停用' : '任务已启用')
|
||||
loadTasks()
|
||||
}
|
||||
async function removeTask(t) {
|
||||
await deleteCrawlTask(t.id)
|
||||
message.success('任务已删除')
|
||||
loadTasks()
|
||||
}
|
||||
|
||||
// ---------------- 运行历史抽屉 ----------------
|
||||
const logsOpen = ref(false)
|
||||
const logsLoading = ref(false)
|
||||
const logs = ref([])
|
||||
const logsTask = ref(null)
|
||||
async function openLogs(t) {
|
||||
logsTask.value = t
|
||||
logsOpen.value = true
|
||||
logsLoading.value = true
|
||||
try {
|
||||
const res = await getCrawlLogs(t.id, 30)
|
||||
logs.value = res.data || []
|
||||
} finally { logsLoading.value = false }
|
||||
}
|
||||
const hasRunning = computed(() => tasks.value.some(t => t.running))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<PageHeader title="药品抓取" desc="定时从公开药典站点抓取中药材与别名信息入知识库;断点续抓、按药名去重更新">
|
||||
<template #actions>
|
||||
<a-button @click="router.push('/kb')"><template #icon><DatabaseOutlined /></template>知识库管理</a-button>
|
||||
<a-button type="primary" @click="openCreate"><template #icon><PlusOutlined /></template>新建抓取任务</a-button>
|
||||
</template>
|
||||
</PageHeader>
|
||||
<HelpTip id="kb-crawl" text="任务按「单次限量」分批抓取(默认每次 50 味药,约 1-3 分钟),游标自动续接,抓完一轮转为增量更新。抓到的药材按药名去重:已存在则刷新内容,检索时按别名也能命中。" />
|
||||
|
||||
<a-alert v-if="hasRunning" type="info" show-icon class="!mb-4"
|
||||
message="有任务正在抓取中,本页 5 秒自动刷新;完整结果看「运行历史」" />
|
||||
|
||||
<a-skeleton v-if="loading && !tasks.length" active :paragraph="{ rows: 4 }" />
|
||||
<EmptyHint v-else-if="!tasks.length"
|
||||
text="还没有抓取任务。建一个定时任务,让中药材和别名信息自动流进知识库。"
|
||||
action-text="新建抓取任务" @action="openCreate" />
|
||||
|
||||
<div v-else class="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
<div v-for="t in tasks" :key="t.id" class="glass p-4">
|
||||
<!-- 头行:名称 + 状态 -->
|
||||
<div class="flex items-center gap-2 mb-2">
|
||||
<CloudDownloadOutlined style="color: var(--primary)" />
|
||||
<span class="text-sm font-medium" style="color: var(--text-1)">{{ t.name }}</span>
|
||||
<a-tag v-if="t.running" color="processing" :bordered="false">
|
||||
<LoadingOutlined class="mr-1" />抓取中
|
||||
</a-tag>
|
||||
<a-tag v-else-if="t.last_status && statusMeta[t.last_status]" :color="statusMeta[t.last_status].color" :bordered="false">
|
||||
{{ statusMeta[t.last_status].label }}
|
||||
</a-tag>
|
||||
<a-switch class="!ml-auto" :checked="t.status === 1" size="small"
|
||||
checked-children="启用" un-checked-children="停用"
|
||||
:disabled="t.running" @click="toggleStatus(t)" />
|
||||
</div>
|
||||
|
||||
<!-- 配置信息 -->
|
||||
<div class="grid grid-cols-2 gap-y-1.5 text-xs mb-3">
|
||||
<span style="color: var(--text-3)">抓取源</span>
|
||||
<span class="text-right" style="color: var(--text-2)">{{ sourceLabel(t.source) }}</span>
|
||||
<span style="color: var(--text-3)">目标库</span>
|
||||
<span class="text-right cursor-pointer hover:underline" style="color: var(--primary)"
|
||||
@click="router.push({ path: '/kb', query: { lib: t.library_id } })">{{ libName(t.library_id) }}</span>
|
||||
<span style="color: var(--text-3)">调度</span>
|
||||
<span class="text-right mono" style="color: var(--text-2)">{{ scheduleDesc(t) }} · 每批 {{ t.items_per_run }} 条</span>
|
||||
<span style="color: var(--text-3)">断点游标</span>
|
||||
<span class="text-right mono" style="color: var(--text-2)">{{ t.progress_offset }}</span>
|
||||
<span style="color: var(--text-3)">上次运行</span>
|
||||
<span class="text-right mono" style="color: var(--text-2)">{{ t.last_run_at ? fmtTime(t.last_run_at) : '从未运行' }}</span>
|
||||
</div>
|
||||
|
||||
<!-- 上次运行摘要 -->
|
||||
<div v-if="t.last_message" class="text-xs rounded px-2 py-1.5 mb-3"
|
||||
style="color: var(--text-2); background: rgba(0,0,0,0.15); border: 1px solid var(--glass-border)">
|
||||
{{ t.last_message }}
|
||||
</div>
|
||||
|
||||
<!-- 操作行 -->
|
||||
<div class="flex gap-2">
|
||||
<a-button size="small" type="primary" :loading="t.running" :disabled="t.status !== 1" @click="triggerRun(t)">
|
||||
<template #icon><PlayCircleOutlined /></template>{{ t.running ? '抓取中' : '立即抓取' }}
|
||||
</a-button>
|
||||
<a-button size="small" @click="openLogs(t)"><template #icon><HistoryOutlined /></template>运行历史</a-button>
|
||||
<a-button size="small" @click="openEdit(t)"><template #icon><EditOutlined /></template>编辑</a-button>
|
||||
<a-popconfirm title="删除任务不会删除已抓取的知识库内容,确认?" ok-text="删除" cancel-text="取消" @confirm="removeTask(t)">
|
||||
<a-button size="small" danger :disabled="t.running"><template #icon><DeleteOutlined /></template></a-button>
|
||||
</a-popconfirm>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 新建/编辑弹窗 -->
|
||||
<a-modal v-model:open="modalOpen" :title="editingId ? '编辑抓取任务' : '新建抓取任务'"
|
||||
:confirm-loading="saving" :width="560" @ok="save">
|
||||
<div class="flex flex-col gap-3 py-2">
|
||||
<div>
|
||||
<div class="text-xs mb-1" style="color: var(--text-3)">任务名称</div>
|
||||
<a-input v-model:value="form.name" placeholder="如:中药药材库每日增量" :maxlength="50" />
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-xs mb-1" style="color: var(--text-3)">抓取源</div>
|
||||
<a-select v-model:value="form.source" class="w-full"
|
||||
:options="sources.map(s => ({ label: s.label, value: s.name }))" />
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-xs mb-1" style="color: var(--text-3)">目标知识库(抓到的药材写进这个库)</div>
|
||||
<div class="flex gap-2">
|
||||
<a-select v-model:value="form.library_id" class="flex-1" placeholder="选择知识库"
|
||||
:options="libraries.map(l => ({ label: `${l.name}(${l.doc_count} 文档)`, value: l.id }))" />
|
||||
<a-tooltip title="快速创建一个「中药药材库」">
|
||||
<a-button :loading="quickCreating" @click="quickCreateLibrary"><PlusOutlined />快速建库</a-button>
|
||||
</a-tooltip>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-xs mb-1" style="color: var(--text-3)">调度方式</div>
|
||||
<a-segmented v-model:value="form.schedule_type" block
|
||||
:options="[{ label: '仅手动', value: 'manual' }, { label: '每N小时', value: 'interval' }, { label: '每天', value: 'daily' }, { label: '每周', value: 'weekly' }]" />
|
||||
</div>
|
||||
<div class="flex gap-4 flex-wrap">
|
||||
<div v-if="form.schedule_type === 'interval'">
|
||||
<div class="text-xs mb-1" style="color: var(--text-3)">间隔小时</div>
|
||||
<a-input-number v-model:value="form.interval_hours" :min="1" :max="168" />
|
||||
</div>
|
||||
<div v-if="form.schedule_type === 'weekly'">
|
||||
<div class="text-xs mb-1" style="color: var(--text-3)">星期</div>
|
||||
<a-select v-model:value="form.run_at_weekday" style="width: 100px"
|
||||
:options="WEEKDAYS.map((w, i) => ({ label: w, value: i }))" />
|
||||
</div>
|
||||
<div v-if="form.schedule_type === 'daily' || form.schedule_type === 'weekly'">
|
||||
<div class="text-xs mb-1" style="color: var(--text-3)">整点(24时制)</div>
|
||||
<a-input-number v-model:value="form.run_at_hour" :min="0" :max="23" />
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-xs mb-1" style="color: var(--text-3)">单次限量(礼貌抓取)</div>
|
||||
<a-input-number v-model:value="form.items_per_run" :min="1" :max="500" :step="10" />
|
||||
</div>
|
||||
</div>
|
||||
<a-alert type="info" show-icon
|
||||
message="建议保持默认:每天凌晨 3 点抓 50 条,约 18 天抓完全量(899 味),之后自动转增量更新。" />
|
||||
</div>
|
||||
</a-modal>
|
||||
|
||||
<!-- 运行历史抽屉 -->
|
||||
<a-drawer v-model:open="logsOpen" :width="640" :title="logsTask ? `运行历史 —— ${logsTask.name}` : '运行历史'">
|
||||
<a-skeleton v-if="logsLoading" active :paragraph="{ rows: 6 }" />
|
||||
<EmptyHint v-else-if="!logs.length" text="还没有运行记录,点「立即抓取」跑一次试试" />
|
||||
<div v-else class="flex flex-col gap-3">
|
||||
<div v-for="lg in logs" :key="lg.id" class="glass p-3 text-xs">
|
||||
<div class="flex items-center gap-2 mb-1.5">
|
||||
<a-tag :color="(statusMeta[lg.status] || {}).color || 'default'" :bordered="false">
|
||||
{{ (statusMeta[lg.status] || {}).label || lg.status }}
|
||||
</a-tag>
|
||||
<a-tag :bordered="false">{{ lg.trigger_type === 'schedule' ? '定时' : '手动' }}</a-tag>
|
||||
<span class="mono" style="color: var(--text-3)">{{ fmtTime(lg.started_at) }}</span>
|
||||
<span v-if="lg.finished_at" class="mono ml-auto" style="color: var(--text-3)">
|
||||
耗时 {{ fmtMs((lg.finished_at - lg.started_at) * 1000) }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex gap-4 mono mb-1" style="color: var(--text-2)">
|
||||
<span>抓取 {{ lg.total_fetched }}</span>
|
||||
<span style="color: var(--ok)">新建 {{ lg.created_docs }}</span>
|
||||
<span style="color: var(--primary)">更新 {{ lg.updated_docs }}</span>
|
||||
<span :style="{ color: lg.failed_items ? 'var(--err)' : 'var(--text-3)' }">失败 {{ lg.failed_items }}</span>
|
||||
</div>
|
||||
<div v-if="lg.message" style="color: var(--text-3)">{{ lg.message }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</a-drawer>
|
||||
</div>
|
||||
</template>
|
||||
143
src/views/kb-search/index.vue
Normal file
143
src/views/kb-search/index.vue
Normal file
@@ -0,0 +1,143 @@
|
||||
<script setup>
|
||||
// 知识库检索:库选择 + query + topK → 结果卡(评分/回退标签/关键词高亮)
|
||||
// 「注入调试」把命中内容一键带进 Enhance 测试台(检索质量 → 生成质量联动)
|
||||
import { ref, reactive, computed, onMounted, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { message } from 'ant-design-vue'
|
||||
import { SearchOutlined, ExperimentOutlined } from '@ant-design/icons-vue'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import { getLibraries, kbSearch } from '@/api/kb'
|
||||
import { fmtMs } from '@/utils/format'
|
||||
import PageHeader from '@/components/PageHeader.vue'
|
||||
import HelpTip from '@/components/HelpTip.vue'
|
||||
import EmptyHint from '@/components/EmptyHint.vue'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const app = useAppStore()
|
||||
|
||||
const libraries = ref([])
|
||||
const form = reactive({ libraryId: null, query: '', topK: 5 })
|
||||
const state = reactive({ loading: false, searched: false, results: [], durationMs: 0 })
|
||||
|
||||
async function loadLibraries() {
|
||||
const res = await getLibraries({ silent: true })
|
||||
libraries.value = res.data || []
|
||||
// URL ?lib= 直达 or 默认第一个库
|
||||
const fromUrl = Number(route.query.lib)
|
||||
if (fromUrl && libraries.value.some(l => l.id === fromUrl)) form.libraryId = fromUrl
|
||||
else if (libraries.value.length && !form.libraryId) form.libraryId = libraries.value[0].id
|
||||
// ?q= 带检索词直达(运行详情「复现检索」入口)
|
||||
if (route.query.q) {
|
||||
form.query = String(route.query.q)
|
||||
if (form.libraryId) doSearch()
|
||||
}
|
||||
}
|
||||
|
||||
async function doSearch() {
|
||||
if (!form.libraryId) { message.warning('请先选择知识库'); return }
|
||||
if (!form.query.trim()) { message.warning('请输入检索词'); return }
|
||||
state.loading = true
|
||||
const start = Date.now()
|
||||
try {
|
||||
const res = await kbSearch({ library_id: form.libraryId, query: form.query.trim(), top_k: form.topK })
|
||||
state.results = res.data || []
|
||||
state.durationMs = Date.now() - start
|
||||
state.searched = true
|
||||
// 检索词进 URL(可分享定位)
|
||||
router.replace({ query: { ...route.query, lib: form.libraryId, q: form.query.trim() } })
|
||||
} finally { state.loading = false }
|
||||
}
|
||||
|
||||
// 关键词高亮:把 query 按空格拆词,逐词 mark(转义正则特殊字符防注入)
|
||||
function highlight(text) {
|
||||
const words = form.query.trim().split(/\s+/).filter(w => w.length >= 1)
|
||||
let safe = text.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||
for (const w of words) {
|
||||
const esc = w.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||||
safe = safe.replace(new RegExp(esc, 'gi'), m => `<mark>${m}</mark>`)
|
||||
}
|
||||
return safe
|
||||
}
|
||||
|
||||
// 评分归一化成进度条(BM25 分数无固定上限,按本次结果最大值归一)
|
||||
const maxScore = computed(() => Math.max(...state.results.map(r => r.score), 0.0001))
|
||||
|
||||
// 注入 Enhance 调试台(互通)
|
||||
function injectDebug(item) {
|
||||
app.setInject({ query: form.query.trim(), content: item.content })
|
||||
router.push('/debug')
|
||||
}
|
||||
|
||||
const usedFallback = computed(() => state.results.some(r => r.source_type === 'fulltext-natural'))
|
||||
onMounted(loadLibraries)
|
||||
|
||||
// 已在本页时点「检索复现」:路由不会重新挂载,靠监听 ?q= 变化即时重搜;
|
||||
// doSearch 内部 router.replace 写回相同 q 时会被下方相等判断挡住,不会死循环
|
||||
watch(() => route.query.q, (q) => {
|
||||
if (q == null) return
|
||||
const s = String(q)
|
||||
if (s === form.query.trim()) return
|
||||
form.query = s
|
||||
if (form.libraryId) doSearch()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<PageHeader title="知识库检索" desc="模拟 Agent 的实际检索效果;BOOLEAN 精确优先,零命中自动切自然语言分词" />
|
||||
<HelpTip id="kb-search" text="短词(如「脾胃虚寒」)走精确匹配;整句自然语言会自动分词回退,结果带「分词回退」标签。命中结果可一键注入调试台验证生成效果。" />
|
||||
|
||||
<div class="glass p-4 mb-4">
|
||||
<div class="flex items-center gap-3 flex-wrap">
|
||||
<a-select v-model:value="form.libraryId" style="width: 220px" placeholder="选择知识库"
|
||||
:options="libraries.map(l => ({ label: `${l.name}(${l.chunk_count} 分段)`, value: l.id }))" />
|
||||
<a-input v-model:value="form.query" placeholder="如:脾胃虚寒 温中健脾 / 胃疼吃什么中药?" style="width: 320px"
|
||||
allow-clear @press-enter="doSearch" />
|
||||
<span class="text-xs" style="color: var(--text-3)">TopK</span>
|
||||
<a-slider v-model:value="form.topK" :min="1" :max="10" style="width: 140px" />
|
||||
<a-button type="primary" :loading="state.loading" @click="doSearch">
|
||||
<template #icon><SearchOutlined /></template>检索
|
||||
</a-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 结果区 -->
|
||||
<template v-if="state.searched">
|
||||
<div class="flex items-center gap-2 mb-3 text-xs" style="color: var(--text-2)">
|
||||
<span>命中 {{ state.results.length }} 条 · {{ fmtMs(state.durationMs) }}</span>
|
||||
<a-tag v-if="usedFallback" color="warning" :bordered="false">分词回退(BOOLEAN 零命中,已切自然语言模式)</a-tag>
|
||||
</div>
|
||||
<EmptyHint v-if="!state.results.length"
|
||||
text="未命中任何分段。试试:① 换更短的核心词(如「温中健脾」)② 检查该库是否已导入相关文档 ③ 分段可能被禁用。"
|
||||
action-text="去管理知识库" @action="router.push({ path: '/kb', query: { lib: form.libraryId } })" />
|
||||
<div v-else class="flex flex-col gap-3">
|
||||
<div v-for="(item, i) in state.results" :key="item.chunk_id" class="glass p-4">
|
||||
<div class="flex items-center gap-2 mb-2 flex-wrap">
|
||||
<span class="mono text-xs" style="color: var(--text-3)">#{{ i + 1 }}</span>
|
||||
<span class="text-sm font-medium" style="color: var(--text-1)">{{ item.title || '(无标题分段)' }}</span>
|
||||
<a-tag v-if="item.source_type === 'fulltext-natural'" color="warning" :bordered="false" class="!text-[10px]">分词回退</a-tag>
|
||||
<span class="mono text-xs ml-auto" style="color: var(--text-3)">score {{ item.score.toFixed(3) }}</span>
|
||||
</div>
|
||||
<a-progress :percent="Math.round((item.score / maxScore) * 100)" :show-info="false" size="small"
|
||||
:stroke-color="'var(--primary)'" class="!mb-2" />
|
||||
<div class="text-xs kb-content" style="color: var(--text-2); white-space: pre-wrap" v-html="highlight(item.content)"></div>
|
||||
<div class="flex gap-2 mt-3">
|
||||
<a-button size="small" @click="injectDebug(item)"><template #icon><ExperimentOutlined /></template>注入调试台</a-button>
|
||||
<a-button size="small" type="text" @click="router.push({ path: '/kb', query: { lib: item.library_id } })">所属库 →</a-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<EmptyHint v-else text="输入检索词试试 Agent 能查到什么。短词精确匹配,长句自动分词。" action-text="" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.kb-content :deep(mark) {
|
||||
background: rgba(245, 158, 11, 0.35);
|
||||
color: inherit;
|
||||
border-radius: 2px;
|
||||
padding: 0 1px;
|
||||
}
|
||||
</style>
|
||||
49
src/views/kb/components/ChunkEditModal.vue
Normal file
49
src/views/kb/components/ChunkEditModal.vue
Normal file
@@ -0,0 +1,49 @@
|
||||
<script setup>
|
||||
// 分段编辑弹窗:标题/内容/关联问题(每行一个)
|
||||
import { reactive, ref, watch } from 'vue'
|
||||
import { message } from 'ant-design-vue'
|
||||
import { updateChunk } from '@/api/kb'
|
||||
|
||||
const open = defineModel('open', { type: Boolean, default: false })
|
||||
const props = defineProps({ chunk: { type: Object, default: null } })
|
||||
const emit = defineEmits(['saved'])
|
||||
const loading = ref(false)
|
||||
const form = reactive({ title: '', content: '', questions: '' })
|
||||
|
||||
// 打开时从 chunk 回填(related_questions 藏在 meta_json)
|
||||
watch(() => props.chunk, (c) => {
|
||||
if (!c) return
|
||||
form.title = c.title || ''
|
||||
form.content = c.content || ''
|
||||
let qs = []
|
||||
try { qs = JSON.parse(c.meta_json || '{}').related_questions || [] } catch { /* meta 解析失败当作无 */ }
|
||||
form.questions = qs.join('\n')
|
||||
}, { immediate: true })
|
||||
|
||||
async function submit() {
|
||||
if (!form.content.trim()) { message.warning('内容不能为空'); return }
|
||||
loading.value = true
|
||||
try {
|
||||
await updateChunk(props.chunk.id, {
|
||||
title: form.title.trim(),
|
||||
content: form.content,
|
||||
related_questions: form.questions.split('\n').map(s => s.trim()).filter(Boolean)
|
||||
})
|
||||
message.success('已保存')
|
||||
open.value = false
|
||||
emit('saved')
|
||||
} finally { loading.value = false }
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<a-modal v-model:open="open" :title="`编辑分段 #${chunk?.id || ''}`" width="640px" :confirm-loading="loading" @ok="submit">
|
||||
<a-form layout="vertical">
|
||||
<a-form-item label="标题"><a-input v-model:value="form.title" :maxlength="100" /></a-form-item>
|
||||
<a-form-item label="内容" required><a-textarea v-model:value="form.content" :rows="8" class="mono !text-xs" /></a-form-item>
|
||||
<a-form-item label="关联问题(每行一个,会参与检索命中)">
|
||||
<a-textarea v-model:value="form.questions" :rows="3" placeholder="如:脾胃虚寒怎么调理?" />
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</a-modal>
|
||||
</template>
|
||||
124
src/views/kb/components/ChunkListDrawer.vue
Normal file
124
src/views/kb/components/ChunkListDrawer.vue
Normal file
@@ -0,0 +1,124 @@
|
||||
<script setup>
|
||||
// 分段列表抽屉(800px):
|
||||
// - 启用 Switch 即点即生效(单条 UpdateChunk)
|
||||
// - 多选 → 批量启用/禁用/删除(PUT /chunks/batch,事务)
|
||||
// - 行内编辑按钮开 ChunkEditModal
|
||||
import { ref, watch, computed } from 'vue'
|
||||
import { message, Modal } from 'ant-design-vue'
|
||||
import { EditOutlined } from '@ant-design/icons-vue'
|
||||
import { getChunks, updateChunk, batchChunks } from '@/api/kb'
|
||||
import ChunkEditModal from './ChunkEditModal.vue'
|
||||
|
||||
const open = defineModel('open', { type: Boolean, default: false })
|
||||
const props = defineProps({ doc: { type: Object, default: null } })
|
||||
const emit = defineEmits(['changed'])
|
||||
|
||||
const chunks = ref([])
|
||||
const loading = ref(false)
|
||||
const selectedIds = ref([])
|
||||
const editOpen = ref(false)
|
||||
const editingChunk = ref(null)
|
||||
const switching = ref(0) // 正在切换的 chunk id(防止重复点)
|
||||
|
||||
async function load() {
|
||||
if (!props.doc?.id) return
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await getChunks(props.doc.id)
|
||||
chunks.value = res.data || []
|
||||
selectedIds.value = []
|
||||
} finally { loading.value = false }
|
||||
}
|
||||
watch([open, () => props.doc?.id], ([isOpen]) => { if (isOpen) load() })
|
||||
|
||||
// 单条启用/禁用(即点即生效)
|
||||
async function toggleActive(chunk, checked) {
|
||||
switching.value = chunk.id
|
||||
try {
|
||||
await updateChunk(chunk.id, { is_active: checked ? 1 : 0 })
|
||||
chunk.is_active = checked ? 1 : 0
|
||||
message.success(checked ? '已启用' : '已禁用(不再参与检索)')
|
||||
emit('changed')
|
||||
} finally { switching.value = 0 }
|
||||
}
|
||||
|
||||
// 批量操作
|
||||
const batchLoading = ref(false)
|
||||
async function doBatch(action) {
|
||||
const labels = { enable: '启用', disable: '禁用', delete: '删除' }
|
||||
const run = async () => {
|
||||
batchLoading.value = true
|
||||
try {
|
||||
const res = await batchChunks({ ids: selectedIds.value, action })
|
||||
message.success(`已${labels[action]} ${res.data?.affected ?? selectedIds.value.length} 个分段`)
|
||||
load()
|
||||
emit('changed')
|
||||
} finally { batchLoading.value = false }
|
||||
}
|
||||
if (action === 'delete') {
|
||||
Modal.confirm({
|
||||
title: `确认删除选中的 ${selectedIds.value.length} 个分段?`,
|
||||
content: '软删除后不再参与检索,且不可在面板恢复',
|
||||
okText: '删除', okType: 'danger', cancelText: '取消',
|
||||
onOk: run
|
||||
})
|
||||
} else {
|
||||
await run()
|
||||
}
|
||||
}
|
||||
|
||||
function openEdit(chunk) {
|
||||
editingChunk.value = chunk
|
||||
editOpen.value = true
|
||||
}
|
||||
const activeCount = computed(() => chunks.value.filter(c => c.is_active === 1).length)
|
||||
|
||||
const columns = [
|
||||
{ title: '#', dataIndex: 'chunk_index', key: 'idx', width: 52 },
|
||||
{ title: '内容', key: 'content' },
|
||||
{ title: '启用', key: 'active', width: 70 },
|
||||
{ title: '', key: 'action', width: 46 }
|
||||
]
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<a-drawer v-model:open="open" width="800" placement="right">
|
||||
<template #title>
|
||||
<div class="flex items-center gap-2">
|
||||
<span>分段管理</span>
|
||||
<span class="text-xs" style="color: var(--text-3)">{{ doc?.title }} · {{ activeCount }}/{{ chunks.length }} 启用</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 批量操作条(选中才出现) -->
|
||||
<div v-if="selectedIds.length" class="glass p-2.5 mb-3 flex items-center gap-2">
|
||||
<span class="text-xs" style="color: var(--text-2)">已选 {{ selectedIds.length }} 项</span>
|
||||
<a-button size="small" :loading="batchLoading" @click="doBatch('enable')">批量启用</a-button>
|
||||
<a-button size="small" :loading="batchLoading" @click="doBatch('disable')">批量禁用</a-button>
|
||||
<a-button size="small" danger :loading="batchLoading" @click="doBatch('delete')">批量删除</a-button>
|
||||
<a-button size="small" type="text" @click="selectedIds = []">取消选择</a-button>
|
||||
</div>
|
||||
|
||||
<a-table :columns="columns" :data-source="chunks" :loading="loading" row-key="id" size="small"
|
||||
:pagination="{ pageSize: 20, size: 'small', showTotal: t => `共 ${t} 段` }"
|
||||
:row-selection="{ selectedRowKeys: selectedIds, onChange: keys => selectedIds = keys }">
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'idx'"><span class="mono text-xs" style="color: var(--text-3)">{{ record.chunk_index + 1 }}</span></template>
|
||||
<template v-else-if="column.key === 'content'">
|
||||
<div class="text-xs font-medium mb-0.5 truncate" style="color: var(--text-1); max-width: 480px">{{ record.title || '(无标题)' }}</div>
|
||||
<div class="text-xs" style="color: var(--text-3); display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden">{{ record.content }}</div>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'active'">
|
||||
<a-switch :checked="record.is_active === 1" size="small" :loading="switching === record.id"
|
||||
@change="checked => toggleActive(record, checked)" />
|
||||
</template>
|
||||
<template v-else-if="column.key === 'action'">
|
||||
<a-button size="small" type="text" @click="openEdit(record)"><template #icon><EditOutlined /></template></a-button>
|
||||
</template>
|
||||
</template>
|
||||
<template #emptyText><div class="py-8 text-xs" style="color: var(--text-3)">该文档暂无分段</div></template>
|
||||
</a-table>
|
||||
|
||||
<ChunkEditModal v-model:open="editOpen" :chunk="editingChunk" @saved="load(); emit('changed')" />
|
||||
</a-drawer>
|
||||
</template>
|
||||
43
src/views/kb/components/CreateLibraryModal.vue
Normal file
43
src/views/kb/components/CreateLibraryModal.vue
Normal file
@@ -0,0 +1,43 @@
|
||||
<script setup>
|
||||
// 新建知识库弹窗:名称/描述/来源,确认后回调父级刷新
|
||||
import { reactive, ref } from 'vue'
|
||||
import { message } from 'ant-design-vue'
|
||||
import { createLibrary } from '@/api/kb'
|
||||
|
||||
const open = defineModel('open', { type: Boolean, default: false })
|
||||
const emit = defineEmits(['created'])
|
||||
const loading = ref(false)
|
||||
const form = reactive({ name: '', description: '', source: 'manual' })
|
||||
|
||||
async function submit() {
|
||||
if (!form.name.trim()) { message.warning('请输入库名称'); return }
|
||||
loading.value = true
|
||||
try {
|
||||
await createLibrary({ name: form.name.trim(), description: form.description.trim(), source: form.source })
|
||||
message.success('创建成功')
|
||||
open.value = false
|
||||
form.name = ''
|
||||
form.description = ''
|
||||
emit('created')
|
||||
} finally { loading.value = false }
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<a-modal v-model:open="open" title="新建知识库" :confirm-loading="loading" @ok="submit">
|
||||
<a-form layout="vertical">
|
||||
<a-form-item label="名称" required>
|
||||
<a-input v-model:value="form.name" placeholder="如:中医方剂知识库" :maxlength="50" show-count @press-enter="submit" />
|
||||
</a-form-item>
|
||||
<a-form-item label="描述">
|
||||
<a-textarea v-model:value="form.description" :rows="2" placeholder="这个库放什么内容(可选)" :maxlength="200" />
|
||||
</a-form-item>
|
||||
<a-form-item label="来源标记">
|
||||
<a-radio-group v-model:value="form.source">
|
||||
<a-radio value="manual">手动维护</a-radio>
|
||||
<a-radio value="import">批量导入</a-radio>
|
||||
</a-radio-group>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</a-modal>
|
||||
</template>
|
||||
37
src/views/kb/components/DocPreviewDrawer.vue
Normal file
37
src/views/kb/components/DocPreviewDrawer.vue
Normal file
@@ -0,0 +1,37 @@
|
||||
<script setup>
|
||||
// 文档原文预览抽屉:基本信息头 + mono 滚动正文
|
||||
import { ref, watch } from 'vue'
|
||||
import { getDoc } from '@/api/kb'
|
||||
import { fmtTimeFull } from '@/utils/format'
|
||||
|
||||
const open = defineModel('open', { type: Boolean, default: false })
|
||||
const props = defineProps({ docId: { type: Number, default: 0 } })
|
||||
const doc = ref(null)
|
||||
const loading = ref(false)
|
||||
|
||||
watch([open, () => props.docId], async ([isOpen, id]) => {
|
||||
if (!isOpen || !id) return
|
||||
loading.value = true
|
||||
doc.value = null
|
||||
try {
|
||||
const res = await getDoc(id)
|
||||
doc.value = res.data
|
||||
} finally { loading.value = false }
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<a-drawer v-model:open="open" :title="doc ? doc.title : '文档原文'" width="640" placement="right">
|
||||
<a-skeleton v-if="loading" active :paragraph="{ rows: 10 }" />
|
||||
<template v-else-if="doc">
|
||||
<div class="flex gap-2 mb-3 flex-wrap text-xs" style="color: var(--text-3)">
|
||||
<a-tag :bordered="false">{{ doc.source_type || 'text' }}</a-tag>
|
||||
<span>{{ doc.chunk_count }} 个分段</span>
|
||||
<span>{{ (doc.content || '').length }} 字</span>
|
||||
<span>{{ fmtTimeFull(doc.created_at) }}</span>
|
||||
</div>
|
||||
<div class="mono text-xs p-3 rounded-lg overflow-auto"
|
||||
style="background: rgba(0,0,0,0.15); color: var(--text-2); max-height: calc(100vh - 180px); white-space: pre-wrap">{{ doc.content }}</div>
|
||||
</template>
|
||||
</a-drawer>
|
||||
</template>
|
||||
49
src/views/kb/components/RechunkModal.vue
Normal file
49
src/views/kb/components/RechunkModal.vue
Normal file
@@ -0,0 +1,49 @@
|
||||
<script setup>
|
||||
// 重新分段弹窗:用新 max_len/overlap 对已导入文档重切
|
||||
// 人工编辑过的分段会被覆盖 → 强提示 + 二次确认语义(按钮文案挑明)
|
||||
import { reactive, ref, watch } from 'vue'
|
||||
import { message } from 'ant-design-vue'
|
||||
import { rechunkDoc } from '@/api/kb'
|
||||
|
||||
const open = defineModel('open', { type: Boolean, default: false })
|
||||
const props = defineProps({ doc: { type: Object, default: null } })
|
||||
const emit = defineEmits(['done'])
|
||||
const loading = ref(false)
|
||||
const form = reactive({ max_len: 500, overlap: 50 })
|
||||
const result = ref(null)
|
||||
|
||||
watch(open, (v) => { if (v) result.value = null })
|
||||
|
||||
async function submit() {
|
||||
if (form.overlap >= form.max_len) { message.warning('overlap 必须小于 max_len'); return }
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await rechunkDoc(props.doc.id, { max_len: form.max_len, overlap: form.overlap })
|
||||
result.value = { ...res.data, max_len: form.max_len, overlap: form.overlap }
|
||||
message.success(`重切完成:${res.data?.old_count ?? '?'} 段 → ${res.data?.chunk_count ?? '?'} 段`)
|
||||
emit('done')
|
||||
} finally { loading.value = false }
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<a-modal v-model:open="open" :title="`重新分段:${doc?.title || ''}`" :confirm-loading="loading"
|
||||
ok-text="覆盖并重切" :ok-button-props="{ danger: true }" @ok="submit">
|
||||
<a-alert type="warning" show-icon class="!mb-4"
|
||||
message="将用文档原文按新参数重新切分,原有全部分段(含人工编辑过的内容与启停状态)会被替换" />
|
||||
<a-form layout="vertical">
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<a-form-item label="分段长度 max_len(100~2000 字)">
|
||||
<a-input-number v-model:value="form.max_len" :min="100" :max="2000" :step="50" class="!w-full" />
|
||||
</a-form-item>
|
||||
<a-form-item label="重叠长度 overlap(0~200 字)">
|
||||
<a-input-number v-model:value="form.overlap" :min="0" :max="200" :step="10" class="!w-full" />
|
||||
</a-form-item>
|
||||
</div>
|
||||
<div class="text-xs" style="color: var(--text-3)">经验值:结构化条目类 300~500 / 长文叙述类 600~800;overlap 取 max_len 的 10% 左右</div>
|
||||
</a-form>
|
||||
<div v-if="result" class="glass p-3 mt-2 text-xs" style="color: var(--text-2)">
|
||||
完成:旧 {{ result.old_count }} 段 → 新 {{ result.chunk_count }} 段(max_len={{ result.max_len }} / overlap={{ result.overlap }})
|
||||
</div>
|
||||
</a-modal>
|
||||
</template>
|
||||
342
src/views/kb/index.vue
Normal file
342
src/views/kb/index.vue
Normal file
@@ -0,0 +1,342 @@
|
||||
<script setup>
|
||||
// 知识库管理:库卡片栅格 → 进库视图(文档表 + 上传 Dragger + 分段参数)
|
||||
// URL 驱动:?lib=3 直达库内视图,刷新不丢
|
||||
import { ref, reactive, computed, onMounted, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { message } from 'ant-design-vue'
|
||||
import {
|
||||
PlusOutlined, DeleteOutlined, InboxOutlined, ArrowLeftOutlined,
|
||||
FileTextOutlined, PartitionOutlined, ScissorOutlined, SearchOutlined,
|
||||
CloudDownloadOutlined
|
||||
} from '@ant-design/icons-vue'
|
||||
import { getLibraries, deleteLibrary, getDocs, deleteDoc, importDoc } from '@/api/kb'
|
||||
import { fmtTime } from '@/utils/format'
|
||||
import PageHeader from '@/components/PageHeader.vue'
|
||||
import HelpTip from '@/components/HelpTip.vue'
|
||||
import EmptyHint from '@/components/EmptyHint.vue'
|
||||
import CreateLibraryModal from './components/CreateLibraryModal.vue'
|
||||
import ChunkListDrawer from './components/ChunkListDrawer.vue'
|
||||
import DocPreviewDrawer from './components/DocPreviewDrawer.vue'
|
||||
import RechunkModal from './components/RechunkModal.vue'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
// ---------------- 库列表 ----------------
|
||||
const libraries = ref([])
|
||||
const libLoading = ref(false)
|
||||
const createOpen = ref(false)
|
||||
async function loadLibraries() {
|
||||
libLoading.value = true
|
||||
try {
|
||||
const res = await getLibraries({ silent: true })
|
||||
libraries.value = res.data || []
|
||||
} finally { libLoading.value = false }
|
||||
}
|
||||
async function removeLibrary(lib) {
|
||||
await deleteLibrary(lib.id)
|
||||
message.success(`已删除「${lib.name}」`)
|
||||
loadLibraries()
|
||||
}
|
||||
|
||||
// ---------------- 库内视图(URL 驱动) ----------------
|
||||
const currentLib = computed(() => libraries.value.find(l => l.id === Number(route.query.lib)) || null)
|
||||
function enterLibrary(lib) { router.push({ query: { lib: lib.id } }) }
|
||||
function backToList() { router.push({ query: {} }); loadLibraries() }
|
||||
|
||||
const docs = ref([])
|
||||
const docsLoading = ref(false)
|
||||
async function loadDocs() {
|
||||
if (!route.query.lib) return
|
||||
docsLoading.value = true
|
||||
try {
|
||||
const res = await getDocs(Number(route.query.lib))
|
||||
docs.value = res.data || []
|
||||
} finally { docsLoading.value = false }
|
||||
}
|
||||
watch(() => route.query.lib, (v) => { if (v) loadDocs() })
|
||||
|
||||
async function removeDoc(doc) {
|
||||
await deleteDoc(doc.id)
|
||||
message.success(`已删除「${doc.title}」`)
|
||||
loadDocs()
|
||||
loadLibraries()
|
||||
}
|
||||
|
||||
// ---------------- 批量上传导入(多文件多格式 + 自定义分段参数) ----------------
|
||||
// 交互:多选/拖拽多个文件 → 进队列 → 默认自动逐个导入(串行,服务端解析是 CPU 活)
|
||||
// 每个文件独立状态(等待/导入中/成功N段/失败原因),单个失败不影响其余文件
|
||||
// 注意:.json 不在列表里——Go 解析器不支持,放进 accept 只会导致必然报错
|
||||
const ACCEPT = '.txt,.md,.markdown,.html,.htm,.pdf,.docx,.xlsx,.xls,.csv'
|
||||
const upload = reactive({
|
||||
maxLen: 500, overlap: 50,
|
||||
autoStart: true, // 选完文件自动开始(默认开,想先调分段参数可关掉)
|
||||
importing: false, // 批量导入进行中
|
||||
queue: [] // { uid, file, name, size, status, chunkCount, error }
|
||||
})
|
||||
// 队列汇总(进度条与按钮文案用)
|
||||
const queueStat = computed(() => {
|
||||
const q = upload.queue
|
||||
return {
|
||||
total: q.length,
|
||||
pending: q.filter(f => f.status === 'pending').length,
|
||||
success: q.filter(f => f.status === 'success').length,
|
||||
error: q.filter(f => f.status === 'error').length
|
||||
}
|
||||
})
|
||||
// beforeUpload 拦截自动上传,只入队;多选时每个文件各触发一次
|
||||
let autoStartTimer = null
|
||||
function queueFile(file) {
|
||||
// 去重:同名同大小视为重复选择
|
||||
if (upload.queue.some(f => f.name === file.name && f.size === file.size)) return false
|
||||
if (file.size > 50 * 1024 * 1024) {
|
||||
message.warning(`「${file.name}」超过 50MB,已跳过`)
|
||||
return false
|
||||
}
|
||||
upload.queue.push({
|
||||
uid: file.uid || `${Date.now()}_${file.name}`,
|
||||
file, name: file.name, size: file.size,
|
||||
status: 'pending', chunkCount: 0, error: ''
|
||||
})
|
||||
// 自动开始:等这一批 beforeUpload 全部入队后(防抖 400ms)再启动
|
||||
if (upload.autoStart && !upload.importing) {
|
||||
clearTimeout(autoStartTimer)
|
||||
autoStartTimer = setTimeout(startImport, 400)
|
||||
}
|
||||
return false
|
||||
}
|
||||
function removeQueued(item) {
|
||||
if (item.status === 'uploading') return
|
||||
upload.queue = upload.queue.filter(f => f.uid !== item.uid)
|
||||
}
|
||||
function clearQueue() {
|
||||
upload.queue = upload.queue.filter(f => f.status === 'uploading')
|
||||
}
|
||||
// 串行批量导入:一个失败记录原因继续下一个
|
||||
//
|
||||
// 循环实时从队列取"下一个待导入项"而不是启动时拍快照,一次解决三个问题:
|
||||
// 1. 导入中从队列删掉的文件不会被"隐身"继续上传
|
||||
// 2. 导入中新拖入的文件同一轮自动接上(不用等下次手动点开始)
|
||||
// 3. 目标库 ID 在启动时锁定——中途返回库列表也不影响剩余文件归属
|
||||
async function startImport() {
|
||||
if (upload.importing) return
|
||||
if (upload.overlap >= upload.maxLen) { message.warning('overlap 必须小于 max_len'); return }
|
||||
// 入口语义:失败项重置为待导入("开始导入 / 重试失败项"共用本函数)
|
||||
upload.queue.forEach(f => { if (f.status === 'error') { f.status = 'pending'; f.error = '' } })
|
||||
if (!upload.queue.some(f => f.status === 'pending')) return
|
||||
const libraryId = Number(route.query.lib)
|
||||
if (!libraryId) { message.warning('请先进入目标知识库'); return }
|
||||
upload.importing = true
|
||||
try {
|
||||
for (;;) {
|
||||
const item = upload.queue.find(f => f.status === 'pending')
|
||||
if (!item) break
|
||||
item.status = 'uploading'
|
||||
item.error = ''
|
||||
try {
|
||||
const fd = new FormData()
|
||||
fd.append('library_id', String(libraryId))
|
||||
fd.append('file', item.file)
|
||||
fd.append('max_len', String(upload.maxLen))
|
||||
fd.append('overlap', String(upload.overlap))
|
||||
const res = await importDoc(fd, { silent: true })
|
||||
item.status = 'success'
|
||||
item.chunkCount = res.data?.chunk_count ?? 0
|
||||
} catch (e) {
|
||||
item.status = 'error'
|
||||
item.error = e.message || '导入失败'
|
||||
}
|
||||
}
|
||||
const s = queueStat.value
|
||||
if (s.error) message.warning(`批量导入完成:成功 ${s.success}、失败 ${s.error}(失败项可点重试)`)
|
||||
else message.success(`批量导入完成:${s.success} 个文件全部成功`)
|
||||
loadDocs()
|
||||
loadLibraries()
|
||||
} finally { upload.importing = false }
|
||||
}
|
||||
// 单文件重试(失败项点重试按钮)
|
||||
async function retryOne(item) {
|
||||
item.status = 'pending'
|
||||
startImport()
|
||||
}
|
||||
function fmtSize(bytes) {
|
||||
if (bytes < 1024) return bytes + ' B'
|
||||
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(0) + ' KB'
|
||||
return (bytes / 1024 / 1024).toFixed(1) + ' MB'
|
||||
}
|
||||
|
||||
// ---------------- 抽屉/弹窗状态 ----------------
|
||||
const chunkOpen = ref(false)
|
||||
const previewOpen = ref(false)
|
||||
const rechunkOpen = ref(false)
|
||||
const activeDoc = ref(null)
|
||||
function openChunks(doc) { activeDoc.value = doc; chunkOpen.value = true }
|
||||
function openPreview(doc) { activeDoc.value = doc; previewOpen.value = true }
|
||||
function openRechunk(doc) { activeDoc.value = doc; rechunkOpen.value = true }
|
||||
|
||||
const docColumns = [
|
||||
{ title: '文档', key: 'title' },
|
||||
{ title: '类型', dataIndex: 'source_type', key: 'type', width: 80 },
|
||||
{ title: '分段', dataIndex: 'chunk_count', key: 'chunks', width: 70 },
|
||||
{ title: '导入时间', key: 'time', width: 130 },
|
||||
{ title: '操作', key: 'action', width: 230 }
|
||||
]
|
||||
|
||||
onMounted(() => { loadLibraries(); if (route.query.lib) loadDocs() })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<!-- ============ 库列表视图 ============ -->
|
||||
<template v-if="!currentLib">
|
||||
<PageHeader title="知识库管理" desc="库 → 文档 → 分段 三级结构;支持 txt/md/html/pdf/docx/xlsx/csv 多格式批量导入自动分段">
|
||||
<template #actions>
|
||||
<a-button @click="router.push('/kb-crawl')"><template #icon><CloudDownloadOutlined /></template>药品抓取</a-button>
|
||||
<a-button type="primary" @click="createOpen = true"><template #icon><PlusOutlined /></template>新建知识库</a-button>
|
||||
</template>
|
||||
</PageHeader>
|
||||
<HelpTip id="kb" text="点击库卡片进入文档管理;导入文档时可展开「分段参数」自定义切分粒度,导入后还能对单个文档重新分段。" />
|
||||
|
||||
<a-skeleton v-if="libLoading && !libraries.length" active :paragraph="{ rows: 4 }" />
|
||||
<EmptyHint v-else-if="!libraries.length" text="还没有知识库。建一个库并导入第一个文档,Agent 就能引用你的私有知识出方。"
|
||||
action-text="新建知识库" @action="createOpen = true" />
|
||||
<div v-else class="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4">
|
||||
<div v-for="lib in libraries" :key="lib.id"
|
||||
class="glass glass-hover p-4 cursor-pointer" @click="enterLibrary(lib)">
|
||||
<div class="flex items-start justify-between">
|
||||
<div class="text-sm font-medium truncate" style="color: var(--text-1)">{{ lib.name }}</div>
|
||||
<a-popconfirm title="删除库会连带删除全部文档与分段,确认?" ok-text="删除" cancel-text="取消"
|
||||
@confirm.stop="removeLibrary(lib)">
|
||||
<a-button size="small" type="text" danger @click.stop><template #icon><DeleteOutlined /></template></a-button>
|
||||
</a-popconfirm>
|
||||
</div>
|
||||
<div class="text-xs mt-1 h-8 overflow-hidden" style="color: var(--text-3)">{{ lib.description || '(无描述)' }}</div>
|
||||
<div class="flex items-center gap-4 mt-3 text-xs" style="color: var(--text-2)">
|
||||
<span class="flex items-center gap-1"><FileTextOutlined />{{ lib.doc_count }} 文档</span>
|
||||
<span class="flex items-center gap-1"><PartitionOutlined />{{ lib.chunk_count }} 分段</span>
|
||||
<a-tag :bordered="false" class="!ml-auto !mr-0">{{ lib.source }}</a-tag>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 虚线新建卡 -->
|
||||
<div class="p-4 rounded-xl cursor-pointer flex flex-col items-center justify-center min-h-[124px] transition-colors"
|
||||
style="border: 1.5px dashed var(--glass-border); color: var(--text-3)"
|
||||
@click="createOpen = true">
|
||||
<PlusOutlined class="text-xl mb-1" />
|
||||
<span class="text-xs">新建知识库</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- ============ 库内视图 ============ -->
|
||||
<template v-else>
|
||||
<PageHeader :title="currentLib.name" :desc="`${currentLib.doc_count} 文档 · ${currentLib.chunk_count} 分段 · 来源 ${currentLib.source}`">
|
||||
<template #actions>
|
||||
<a-button @click="backToList"><template #icon><ArrowLeftOutlined /></template>返回库列表</a-button>
|
||||
<a-button @click="router.push({ path: '/kb/search', query: { lib: currentLib.id } })">
|
||||
<template #icon><SearchOutlined /></template>去检索
|
||||
</a-button>
|
||||
</template>
|
||||
</PageHeader>
|
||||
|
||||
<!-- 批量上传区:多选/拖拽多文件 → 队列逐个导入,每个文件独立状态 -->
|
||||
<div class="glass p-4 mb-4">
|
||||
<a-upload-dragger :before-upload="queueFile" :show-upload-list="false" :accept="ACCEPT" multiple>
|
||||
<p class="!mb-1"><InboxOutlined class="text-2xl" style="color: var(--primary)" /></p>
|
||||
<p class="text-sm" style="color: var(--text-1)">点击或拖拽文件导入(可一次选多个)</p>
|
||||
<p class="text-xs" style="color: var(--text-3)">支持 txt / md / html / pdf / docx / xlsx / csv 混合批量,单文件 ≤ 50MB,自动分段</p>
|
||||
</a-upload-dragger>
|
||||
|
||||
<!-- 导入队列 -->
|
||||
<div v-if="upload.queue.length" class="mt-3">
|
||||
<div class="flex items-center gap-3 mb-2 flex-wrap">
|
||||
<span class="text-xs" style="color: var(--text-2)">
|
||||
队列 {{ queueStat.total }} 个:成功 <span style="color: var(--ok)">{{ queueStat.success }}</span>
|
||||
· 失败 <span style="color: var(--err)">{{ queueStat.error }}</span>
|
||||
· 待导入 {{ queueStat.pending }}
|
||||
</span>
|
||||
<a-progress v-if="upload.importing || queueStat.success + queueStat.error > 0" class="flex-1 min-w-[140px] !mb-0"
|
||||
:percent="Math.round(((queueStat.success + queueStat.error) / Math.max(queueStat.total, 1)) * 100)"
|
||||
size="small" :status="upload.importing ? 'active' : (queueStat.error ? 'exception' : 'success')" />
|
||||
<a-switch v-model:checked="upload.autoStart" size="small" checked-children="自动导入" un-checked-children="手动" />
|
||||
<a-button v-if="queueStat.pending || queueStat.error" size="small" type="primary"
|
||||
:loading="upload.importing" @click="startImport">
|
||||
{{ queueStat.error && !queueStat.pending ? '重试失败项' : '开始导入' }}
|
||||
</a-button>
|
||||
<a-button size="small" :disabled="upload.importing" @click="clearQueue">清空列表</a-button>
|
||||
</div>
|
||||
<div class="max-h-44 overflow-auto rounded-lg" style="border: 1px solid var(--glass-border)">
|
||||
<div v-for="item in upload.queue" :key="item.uid"
|
||||
class="flex items-center gap-2 px-3 py-1.5 text-xs" style="border-bottom: 1px solid var(--glass-border)">
|
||||
<FileTextOutlined style="color: var(--text-3)" />
|
||||
<span class="truncate max-w-[280px]" style="color: var(--text-1)">{{ item.name }}</span>
|
||||
<span class="mono" style="color: var(--text-3)">{{ fmtSize(item.size) }}</span>
|
||||
<span class="ml-auto flex items-center gap-2">
|
||||
<a-tag v-if="item.status === 'pending'" :bordered="false">等待</a-tag>
|
||||
<a-tag v-else-if="item.status === 'uploading'" color="processing" :bordered="false">导入中…</a-tag>
|
||||
<a-tag v-else-if="item.status === 'success'" color="success" :bordered="false">成功 · {{ item.chunkCount }} 段</a-tag>
|
||||
<a-tooltip v-else :title="item.error">
|
||||
<a-tag color="error" :bordered="false" class="cursor-help">失败</a-tag>
|
||||
</a-tooltip>
|
||||
<a-button v-if="item.status === 'error'" size="small" type="link" class="!px-0" @click="retryOne(item)">重试</a-button>
|
||||
<a-button v-if="item.status !== 'uploading'" size="small" type="text" class="!px-1" @click="removeQueued(item)">
|
||||
<DeleteOutlined style="font-size: 11px" />
|
||||
</a-button>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<a-collapse ghost size="small" class="mt-2">
|
||||
<a-collapse-panel key="params" header="分段参数(高级选项,默认 500/50 即可,对整批生效)">
|
||||
<div class="flex items-center gap-6 flex-wrap">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-xs" style="color: var(--text-3)">分段长度 max_len</span>
|
||||
<a-input-number v-model:value="upload.maxLen" :min="100" :max="2000" :step="50" size="small" />
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-xs" style="color: var(--text-3)">重叠 overlap</span>
|
||||
<a-input-number v-model:value="upload.overlap" :min="0" :max="200" :step="10" size="small" />
|
||||
</div>
|
||||
<span class="text-xs" style="color: var(--text-3)">条目类知识 300~500;长文 600~800;overlap 约取 10%;想改参数先关掉「自动导入」</span>
|
||||
</div>
|
||||
</a-collapse-panel>
|
||||
</a-collapse>
|
||||
</div>
|
||||
|
||||
<!-- 文档表 -->
|
||||
<div class="glass p-4">
|
||||
<a-table :columns="docColumns" :data-source="docs" :loading="docsLoading" row-key="id" size="small"
|
||||
:pagination="{ pageSize: 15, size: 'small', showTotal: t => `共 ${t} 篇` }">
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'title'">
|
||||
<div class="text-xs font-medium cursor-pointer hover:underline" style="color: var(--text-1)" @click="openChunks(record)">{{ record.title }}</div>
|
||||
<div class="text-[11px] mono" style="color: var(--text-3)">{{ record.source_file || '-' }}</div>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'type'"><a-tag :bordered="false">{{ record.source_type || 'text' }}</a-tag></template>
|
||||
<template v-else-if="column.key === 'chunks'"><span class="mono">{{ record.chunk_count }}</span></template>
|
||||
<template v-else-if="column.key === 'time'"><span class="mono text-xs" style="color: var(--text-3)">{{ fmtTime(record.created_at) }}</span></template>
|
||||
<template v-else-if="column.key === 'action'">
|
||||
<div class="flex gap-1">
|
||||
<a-button size="small" type="link" @click="openChunks(record)">分段</a-button>
|
||||
<a-button size="small" type="link" @click="openPreview(record)">原文</a-button>
|
||||
<a-button size="small" type="link" @click="openRechunk(record)"><ScissorOutlined />重切</a-button>
|
||||
<a-popconfirm title="删除文档及其全部分段?" ok-text="删除" cancel-text="取消" @confirm="removeDoc(record)">
|
||||
<a-button size="small" type="link" danger>删除</a-button>
|
||||
</a-popconfirm>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
<template #emptyText>
|
||||
<EmptyHint text="库还是空的,导入第一个文档让它变得有用。" action-text="" />
|
||||
</template>
|
||||
</a-table>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 弹窗与抽屉 -->
|
||||
<CreateLibraryModal v-model:open="createOpen" @created="loadLibraries" />
|
||||
<ChunkListDrawer v-model:open="chunkOpen" :doc="activeDoc" @changed="loadDocs(); loadLibraries()" />
|
||||
<DocPreviewDrawer v-model:open="previewOpen" :doc-id="activeDoc?.id || 0" />
|
||||
<RechunkModal v-model:open="rechunkOpen" :doc="activeDoc" @done="loadDocs(); loadLibraries()" />
|
||||
</div>
|
||||
</template>
|
||||
92
src/views/login/index.vue
Normal file
92
src/views/login/index.vue
Normal file
@@ -0,0 +1,92 @@
|
||||
<script setup>
|
||||
// 登录页:全屏暖色渐变背景 + 居中玻璃卡(左品牌区 + 右表单)
|
||||
// 账号/密码/验证码三框,回车提交,失败 shake + 模糊报错(防枚举,与后端一致)
|
||||
import { ref, reactive } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { message } from 'ant-design-vue'
|
||||
import { UserOutlined, LockOutlined, SafetyOutlined } from '@ant-design/icons-vue'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
|
||||
const userStore = useUserStore()
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
const form = reactive({ username: '', password: '', captcha: '' })
|
||||
const loading = ref(false)
|
||||
const shaking = ref(false)
|
||||
|
||||
async function submit() {
|
||||
if (!form.username || !form.password || !form.captcha) {
|
||||
message.warning('请填写账号、密码和验证码')
|
||||
return
|
||||
}
|
||||
loading.value = true
|
||||
try {
|
||||
await userStore.login(form)
|
||||
// 登录成功:回跳来源页或进仪表盘
|
||||
const redirect = route.query.redirect
|
||||
router.push(redirect && String(redirect).startsWith('/') ? String(redirect) : '/dashboard')
|
||||
} catch (e) {
|
||||
// 失败 shake 动效 + 模糊报错(后端不区分账号/密码错)
|
||||
shaking.value = true
|
||||
setTimeout(() => shaking.value = false, 500)
|
||||
message.error(e.message || '账号、密码或验证码错误')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="min-h-screen flex items-center justify-center p-4">
|
||||
<div class="glass-strong flex overflow-hidden max-w-3xl w-full" :class="{ shake: shaking }"
|
||||
style="border-radius: 16px">
|
||||
<!-- 左品牌区(窄屏隐藏) -->
|
||||
<div class="hidden md:flex flex-col justify-center gap-4 p-10 w-80"
|
||||
style="background: linear-gradient(150deg, rgba(245,158,11,0.14), rgba(251,146,60,0.05)); border-right: 1px solid var(--glass-border)">
|
||||
<div class="text-4xl">🌿</div>
|
||||
<h1 class="text-xl font-bold m-0" style="color: var(--primary)">TCM Agent 控制台</h1>
|
||||
<p class="text-xs leading-relaxed m-0" style="color: var(--text-2)">
|
||||
中医智能体独立管理面板<br />
|
||||
运行观测 · 模型管理 · 知识库 · 调试工具
|
||||
</p>
|
||||
<div class="flex gap-1.5 mt-2">
|
||||
<span class="w-8 h-1 rounded-full" style="background: var(--primary)"></span>
|
||||
<span class="w-3 h-1 rounded-full" style="background: var(--accent); opacity: .6"></span>
|
||||
<span class="w-1.5 h-1 rounded-full" style="background: var(--accent); opacity: .3"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 右表单区 -->
|
||||
<div class="flex-1 p-8 md:p-10">
|
||||
<h2 class="text-lg font-semibold mb-1" style="color: var(--text-1)">欢迎回来</h2>
|
||||
<p class="text-xs mb-6" style="color: var(--text-3)">请使用管理员账号登录</p>
|
||||
|
||||
<a-form layout="vertical" @keyup.enter="submit">
|
||||
<a-form-item label="账号">
|
||||
<a-input v-model:value="form.username" size="large" placeholder="请输入账号" autocomplete="username">
|
||||
<template #prefix><UserOutlined style="color: var(--text-3)" /></template>
|
||||
</a-input>
|
||||
</a-form-item>
|
||||
<a-form-item label="密码">
|
||||
<a-input-password v-model:value="form.password" size="large" placeholder="请输入密码" autocomplete="current-password">
|
||||
<template #prefix><LockOutlined style="color: var(--text-3)" /></template>
|
||||
</a-input-password>
|
||||
</a-form-item>
|
||||
<a-form-item label="验证码">
|
||||
<a-input v-model:value="form.captcha" size="large" placeholder="请输入验证码" :maxlength="6">
|
||||
<template #prefix><SafetyOutlined style="color: var(--text-3)" /></template>
|
||||
</a-input>
|
||||
</a-form-item>
|
||||
<a-button type="primary" size="large" block :loading="loading" @click="submit">
|
||||
登 录
|
||||
</a-button>
|
||||
</a-form>
|
||||
|
||||
<p class="text-xs mt-6 mb-0 text-center" style="color: var(--text-3)">
|
||||
内部系统 · 连续失败 5 次将锁定 10 分钟
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
253
src/views/models/index.vue
Normal file
253
src/views/models/index.vue
Normal file
@@ -0,0 +1,253 @@
|
||||
<script setup>
|
||||
// 模型管理(老面板没有的新页):
|
||||
// - 生效配置卡(脱敏)+ 失效缓存 + 连通测试
|
||||
// - 场景路由表 + 动态注册(热更新不落库)
|
||||
// - fallback 链可视化(主 → 降级1 → 降级2,当前生效节点高亮)
|
||||
// - 延迟基准测试:串行跑 N 次连通测试,输出 min/avg/p95 + 柱状分布
|
||||
import { ref, reactive, computed, onMounted } from 'vue'
|
||||
import { message, Modal } from 'ant-design-vue'
|
||||
import { ThunderboltOutlined, ReloadOutlined, PlusOutlined, ApiOutlined, ClearOutlined } from '@ant-design/icons-vue'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import { getModelRoutes, registerModelRoute, modelTest, invalidateModelCache, getActiveConfig } from '@/api/models'
|
||||
import { fmtMs, sceneLabel } from '@/utils/format'
|
||||
import PageHeader from '@/components/PageHeader.vue'
|
||||
import EChart from '@/components/EChart.vue'
|
||||
import HelpTip from '@/components/HelpTip.vue'
|
||||
|
||||
const app = useAppStore()
|
||||
|
||||
// ---------------- 生效配置 ----------------
|
||||
const active = ref(null)
|
||||
const activeLoading = ref(false)
|
||||
async function loadActive() {
|
||||
activeLoading.value = true
|
||||
try {
|
||||
const res = await getActiveConfig({ silent: true })
|
||||
active.value = res.data
|
||||
} finally { activeLoading.value = false }
|
||||
}
|
||||
async function doInvalidate() {
|
||||
await invalidateModelCache()
|
||||
message.success('LLM 配置缓存已失效,下次调用将重新读取数据库')
|
||||
loadActive()
|
||||
}
|
||||
|
||||
// ---------------- 场景路由 ----------------
|
||||
const routes = ref({})
|
||||
const fallbacks = ref({})
|
||||
const defaultProvider = ref('')
|
||||
const routesLoading = ref(false)
|
||||
async function loadRoutes() {
|
||||
routesLoading.value = true
|
||||
try {
|
||||
const res = await getModelRoutes({ silent: true })
|
||||
routes.value = res.routes || {}
|
||||
fallbacks.value = res.fallbacks || {}
|
||||
defaultProvider.value = res.default_provider || ''
|
||||
} finally { routesLoading.value = false }
|
||||
}
|
||||
const routeRows = computed(() => Object.entries(routes.value).map(([scene, provider]) => ({ scene, provider })))
|
||||
|
||||
// 单行连通测试
|
||||
const testingRow = ref('')
|
||||
async function testProvider(provider) {
|
||||
testingRow.value = provider
|
||||
try {
|
||||
const res = await modelTest({ provider })
|
||||
const d = res.data
|
||||
if (d.ok) message.success(`${d.provider}/${d.model} 连通正常(${fmtMs(d.duration_ms)})`)
|
||||
else message.error(`${d.provider} 连通失败:${d.error || '未知错误'}`)
|
||||
} finally { testingRow.value = '' }
|
||||
}
|
||||
|
||||
// 注册路由弹窗(热更新不落库)
|
||||
const regOpen = ref(false)
|
||||
const regForm = reactive({ scene: '', provider: '' })
|
||||
const regLoading = ref(false)
|
||||
async function submitRegister() {
|
||||
if (!regForm.scene.trim() || !regForm.provider.trim()) { message.warning('scene 与 provider 都必填'); return }
|
||||
regLoading.value = true
|
||||
try {
|
||||
await registerModelRoute({ scene: regForm.scene.trim(), provider: regForm.provider.trim() })
|
||||
message.success('路由已热更新(重启后失效,持久化请改 config.yaml)')
|
||||
regOpen.value = false
|
||||
loadRoutes()
|
||||
} finally { regLoading.value = false }
|
||||
}
|
||||
|
||||
// ---------------- fallback 链可视化 ----------------
|
||||
// chain: [当前生效 provider(高亮), ...fallback 序列去重]
|
||||
const chain = computed(() => {
|
||||
const cur = active.value?.provider || defaultProvider.value
|
||||
const fb = fallbacks.value[cur] || fallbacks.value[defaultProvider.value] || []
|
||||
const seen = new Set([cur])
|
||||
const rest = fb.filter(p => { if (seen.has(p)) return false; seen.add(p); return true })
|
||||
return cur ? [cur, ...rest] : rest
|
||||
})
|
||||
|
||||
// ---------------- 延迟基准测试 ----------------
|
||||
const bench = reactive({ provider: '', times: 5, running: false, done: 0, samples: [], error: '' })
|
||||
const providerChoices = computed(() => {
|
||||
const set = new Set([defaultProvider.value, ...Object.values(routes.value), ...chain.value].filter(Boolean))
|
||||
return [...set].map(p => ({ label: p, value: p }))
|
||||
})
|
||||
async function runBench() {
|
||||
if (!bench.provider) { message.warning('请选择要测试的 provider'); return }
|
||||
bench.running = true
|
||||
bench.samples = []
|
||||
bench.done = 0
|
||||
bench.error = ''
|
||||
try {
|
||||
for (let i = 0; i < bench.times; i++) {
|
||||
const res = await modelTest({ provider: bench.provider })
|
||||
if (!res.data.ok) {
|
||||
bench.error = `第 ${i + 1} 次失败:${res.data.error || '未知错误'}(已中断)`
|
||||
break
|
||||
}
|
||||
bench.samples.push(res.data.duration_ms)
|
||||
bench.done = i + 1
|
||||
}
|
||||
} catch (e) {
|
||||
bench.error = e.message || '测试中断'
|
||||
} finally { bench.running = false }
|
||||
}
|
||||
const benchStats = computed(() => {
|
||||
const s = [...bench.samples].sort((a, b) => a - b)
|
||||
if (!s.length) return null
|
||||
const avg = s.reduce((x, y) => x + y, 0) / s.length
|
||||
const p95 = s[Math.min(s.length - 1, Math.ceil(s.length * 0.95) - 1)]
|
||||
return { min: s[0], avg: Math.round(avg), p95, max: s[s.length - 1] }
|
||||
})
|
||||
const benchOption = computed(() => {
|
||||
const dark = app.theme === 'dark'
|
||||
return {
|
||||
backgroundColor: 'transparent',
|
||||
grid: { left: 48, right: 12, top: 20, bottom: 24 },
|
||||
tooltip: { trigger: 'axis', valueFormatter: v => fmtMs(v) },
|
||||
xAxis: { type: 'category', data: bench.samples.map((_, i) => `#${i + 1}`), axisLabel: { color: dark ? '#8d7c60' : '#98a1b3' } },
|
||||
yAxis: { type: 'value', name: 'ms', axisLabel: { color: dark ? '#8d7c60' : '#98a1b3' }, splitLine: { lineStyle: { color: 'rgba(128,128,128,0.1)' } } },
|
||||
series: [{ type: 'bar', data: bench.samples, barMaxWidth: 28, itemStyle: { color: dark ? '#f59e0b' : '#4361ee', borderRadius: [4, 4, 0, 0] } }]
|
||||
}
|
||||
})
|
||||
|
||||
const routeColumns = [
|
||||
{ title: '场景', dataIndex: 'scene', key: 'scene', width: 220 },
|
||||
{ title: 'Provider', dataIndex: 'provider', key: 'provider' },
|
||||
{ title: '操作', key: 'action', width: 120 }
|
||||
]
|
||||
|
||||
onMounted(() => { loadActive(); loadRoutes() })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<PageHeader title="模型管理" desc="生效配置、场景路由与降级链;路由注册为热更新(重启失效)">
|
||||
<template #actions>
|
||||
<a-button :loading="routesLoading" @click="loadRoutes(); loadActive()"><template #icon><ReloadOutlined /></template>刷新</a-button>
|
||||
<a-popconfirm title="确认失效 LLM 配置缓存?" ok-text="失效" cancel-text="取消" @confirm="doInvalidate">
|
||||
<a-button danger><template #icon><ClearOutlined /></template>失效缓存</a-button>
|
||||
</a-popconfirm>
|
||||
</template>
|
||||
</PageHeader>
|
||||
<HelpTip id="models" text="改了后台的模型配置后点「失效缓存」立即生效;「场景路由」决定每个业务场景用哪个模型(热更新,重启失效);不确定模型通不通就点「连通测试」或跑一次基准测试。" />
|
||||
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4 mb-4">
|
||||
<!-- 生效配置卡 -->
|
||||
<div class="glass p-4">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<span class="text-sm font-medium" style="color: var(--text-1)">当前生效配置(脱敏)</span>
|
||||
<a-button size="small" :loading="testingRow === (active?.provider || '')" @click="testProvider(active?.provider || '')">
|
||||
<template #icon><ApiOutlined /></template>连通测试
|
||||
</a-button>
|
||||
</div>
|
||||
<a-skeleton v-if="activeLoading && !active" active :paragraph="{ rows: 4 }" :title="false" />
|
||||
<div v-else-if="active" class="grid grid-cols-2 gap-y-2 text-xs">
|
||||
<span style="color: var(--text-3)">Provider</span><span class="mono text-right" style="color: var(--primary)">{{ active.provider }}</span>
|
||||
<span style="color: var(--text-3)">Model</span><span class="mono text-right">{{ active.model }}</span>
|
||||
<span style="color: var(--text-3)">API 地址</span><span class="mono text-right truncate" :title="active.api_url">{{ active.api_url || '-' }}</span>
|
||||
<span style="color: var(--text-3)">API Key</span><span class="mono text-right">{{ active.api_key_tail ? '****' + active.api_key_tail : '-' }}(#{{ active.api_key_id }})</span>
|
||||
<span style="color: var(--text-3)">配置来源</span>
|
||||
<span class="text-right"><a-tag :bordered="false" :color="active.source === 'db_active' ? 'processing' : 'default'">{{ active.source }}</a-tag></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- fallback 链可视化 -->
|
||||
<div class="glass p-4">
|
||||
<div class="text-sm font-medium mb-3" style="color: var(--text-1)">降级链(fallback)</div>
|
||||
<div v-if="chain.length" class="flex items-center gap-2 flex-wrap">
|
||||
<template v-for="(p, i) in chain" :key="p">
|
||||
<div class="px-3 py-2 rounded-lg text-xs mono"
|
||||
:style="i === 0
|
||||
? 'background: rgba(245,158,11,0.15); border: 1px solid var(--primary); color: var(--primary)'
|
||||
: 'background: rgba(128,128,128,0.08); border: 1px solid var(--glass-border); color: var(--text-2)'">
|
||||
<ThunderboltOutlined v-if="i === 0" class="mr-1" />{{ p }}
|
||||
<div class="text-[10px] mt-0.5" style="color: var(--text-3)">{{ i === 0 ? '当前生效' : `降级 ${i}` }}</div>
|
||||
</div>
|
||||
<span v-if="i < chain.length - 1" style="color: var(--text-3)">→</span>
|
||||
</template>
|
||||
</div>
|
||||
<div v-else class="text-xs" style="color: var(--text-3)">未配置降级链(config.yaml llm.fallbacks)</div>
|
||||
<div class="text-xs mt-3" style="color: var(--text-3)">主模型调用失败时按链依次降级重试,全部失败才返回错误。</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 场景路由表 -->
|
||||
<div class="glass p-4 mb-4">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<span class="text-sm font-medium" style="color: var(--text-1)">场景路由(scene → provider)</span>
|
||||
<a-button size="small" type="primary" @click="regOpen = true"><template #icon><PlusOutlined /></template>注册路由</a-button>
|
||||
</div>
|
||||
<a-table :columns="routeColumns" :data-source="routeRows" :loading="routesLoading"
|
||||
:pagination="false" size="small" row-key="scene">
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'scene'">
|
||||
<span>{{ sceneLabel(record.scene) }}</span>
|
||||
<span class="mono text-xs ml-1" style="color: var(--text-3)">{{ record.scene }}</span>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'provider'"><span class="mono">{{ record.provider }}</span></template>
|
||||
<template v-else-if="column.key === 'action'">
|
||||
<a-button size="small" type="link" :loading="testingRow === record.provider" @click="testProvider(record.provider)">测试</a-button>
|
||||
</template>
|
||||
</template>
|
||||
<template #emptyText>
|
||||
<div class="py-6 text-xs" style="color: var(--text-3)">未配置场景级路由,所有场景走默认 provider「{{ defaultProvider || '-' }}」</div>
|
||||
</template>
|
||||
</a-table>
|
||||
</div>
|
||||
|
||||
<!-- 延迟基准测试 -->
|
||||
<div class="glass p-4">
|
||||
<div class="text-sm font-medium mb-1" style="color: var(--text-1)">延迟基准测试</div>
|
||||
<div class="text-xs mb-3" style="color: var(--text-3)">串行真实调用 N 次(max_tokens 64),评估换模型前后的延迟差异;不要在生产高峰跑大批量</div>
|
||||
<div class="flex items-center gap-3 flex-wrap mb-3">
|
||||
<a-select v-model:value="bench.provider" :options="providerChoices" placeholder="选择 provider" style="width: 180px" />
|
||||
<a-radio-group v-model:value="bench.times" button-style="solid" size="small">
|
||||
<a-radio-button :value="5">5 次</a-radio-button>
|
||||
<a-radio-button :value="10">10 次</a-radio-button>
|
||||
</a-radio-group>
|
||||
<a-button type="primary" :loading="bench.running" @click="runBench">
|
||||
{{ bench.running ? `测试中 ${bench.done}/${bench.times}` : '开始测试' }}
|
||||
</a-button>
|
||||
</div>
|
||||
<a-alert v-if="bench.error" type="error" :message="bench.error" show-icon class="!mb-3" />
|
||||
<template v-if="benchStats">
|
||||
<div class="flex gap-6 mb-2 text-xs mono flex-wrap">
|
||||
<span>min <b style="color: var(--ok)">{{ fmtMs(benchStats.min) }}</b></span>
|
||||
<span>avg <b style="color: var(--primary)">{{ fmtMs(benchStats.avg) }}</b></span>
|
||||
<span>p95 <b style="color: var(--warn)">{{ fmtMs(benchStats.p95) }}</b></span>
|
||||
<span>max <b style="color: var(--err)">{{ fmtMs(benchStats.max) }}</b></span>
|
||||
</div>
|
||||
<EChart :option="benchOption" height="200px" />
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- 注册路由弹窗 -->
|
||||
<a-modal v-model:open="regOpen" title="注册场景路由" :confirm-loading="regLoading" @ok="submitRegister">
|
||||
<a-alert type="warning" show-icon message="热更新仅存于内存,服务重启后失效;持久化请修改 config.yaml 的 llm.model_routes" class="!mb-4" />
|
||||
<a-form layout="vertical">
|
||||
<a-form-item label="场景 scene" required><a-input v-model:value="regForm.scene" placeholder="如 medical_record / prescription" /></a-form-item>
|
||||
<a-form-item label="Provider" required><a-input v-model:value="regForm.provider" placeholder="如 spark / deepseek / qwen" /></a-form-item>
|
||||
</a-form>
|
||||
</a-modal>
|
||||
</div>
|
||||
</template>
|
||||
192
src/views/runs/index.vue
Normal file
192
src/views/runs/index.vue
Normal file
@@ -0,0 +1,192 @@
|
||||
<script setup>
|
||||
// 运行记录:多条件筛选 + 自动刷新 + 行点击详情抽屉 + 勾选 2 条对比 + 导出
|
||||
// 筛选条件映射到 URL query(刷新不丢、可直接分享定位问题);?run=id 直达详情
|
||||
import { ref, computed, watch, onMounted } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { message } from 'ant-design-vue'
|
||||
import { DownloadOutlined, SwapOutlined } from '@ant-design/icons-vue'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import { getRuns, getRunDetail } from '@/api/agent'
|
||||
import { usePolling } from '@/composables/usePolling'
|
||||
import { fmtTime, fmtMs, msTone, SCENE_OPTIONS, sceneLabel, estimateCost, fmtCost, downloadJSON, stepTypeLabel } from '@/utils/format'
|
||||
import PageHeader from '@/components/PageHeader.vue'
|
||||
import StatusTag from '@/components/StatusTag.vue'
|
||||
import SceneTag from '@/components/SceneTag.vue'
|
||||
import EmptyHint from '@/components/EmptyHint.vue'
|
||||
|
||||
const app = useAppStore()
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
// ---------------- 筛选(与 URL query 双向同步) ----------------
|
||||
const scene = ref(route.query.scene ? String(route.query.scene) : '')
|
||||
const status = ref(route.query.status ? Number(route.query.status) : 0)
|
||||
const keyword = ref('')
|
||||
const autoRefresh = ref(true)
|
||||
|
||||
const rows = ref([])
|
||||
const loading = ref(false)
|
||||
|
||||
async function load() {
|
||||
loading.value = rows.value.length === 0
|
||||
try {
|
||||
const res = await getRuns({ limit: 200, scene: scene.value, status: status.value }, { silent: true })
|
||||
rows.value = res.data || []
|
||||
} finally { loading.value = false }
|
||||
}
|
||||
const poller = usePolling(load, 5000)
|
||||
|
||||
// 自动刷新开关
|
||||
watch(autoRefresh, v => v ? poller.resume() : poller.pause())
|
||||
// 筛选变化:立即拉取 + 同步 URL
|
||||
watch([scene, status], () => {
|
||||
load()
|
||||
router.replace({ query: { ...route.query, scene: scene.value || undefined, status: status.value || undefined, run: undefined } })
|
||||
})
|
||||
|
||||
// 关键词客户端过滤(provider/model/错误信息)
|
||||
const filtered = computed(() => {
|
||||
const kw = keyword.value.trim().toLowerCase()
|
||||
if (!kw) return rows.value
|
||||
return rows.value.filter(r =>
|
||||
`${r.provider}/${r.model}`.toLowerCase().includes(kw) ||
|
||||
(r.error || '').toLowerCase().includes(kw) ||
|
||||
String(r.id) === kw
|
||||
)
|
||||
})
|
||||
|
||||
// ?run=id 直达详情抽屉(URL 驱动状态)
|
||||
onMounted(() => {
|
||||
if (route.query.run) app.openRun(Number(route.query.run))
|
||||
})
|
||||
|
||||
// ---------------- 勾选对比(最多 2 条) ----------------
|
||||
const selectedIds = ref([])
|
||||
function toggleSelect(id, checked) {
|
||||
if (checked) {
|
||||
if (selectedIds.value.length >= 2) { message.warning('最多选 2 条做对比'); return }
|
||||
selectedIds.value.push(id)
|
||||
} else {
|
||||
selectedIds.value = selectedIds.value.filter(i => i !== id)
|
||||
}
|
||||
}
|
||||
const compareOpen = ref(false)
|
||||
const compareData = ref([])
|
||||
async function openCompare() {
|
||||
if (selectedIds.value.length !== 2) return
|
||||
const [a, b] = await Promise.all(selectedIds.value.map(id => getRunDetail(id, { silent: true })))
|
||||
compareData.value = [a.data, b.data]
|
||||
compareOpen.value = true
|
||||
}
|
||||
|
||||
// ---------------- 导出 ----------------
|
||||
function exportAll() {
|
||||
downloadJSON(filtered.value, `agent-runs-${Date.now()}.json`)
|
||||
message.success(`已导出 ${filtered.value.length} 条`)
|
||||
}
|
||||
|
||||
const columns = [
|
||||
{ title: '', key: 'select', width: 36 },
|
||||
{ title: 'ID', key: 'id', width: 60 },
|
||||
{ title: '时间', key: 'time', width: 110 },
|
||||
{ title: '场景', key: 'scene', width: 100 },
|
||||
{ title: 'provider/model', key: 'pm', width: 150 },
|
||||
{ title: '耗时', key: 'ms', width: 80 },
|
||||
{ title: 'tokens', key: 'tokens', width: 90 },
|
||||
{ title: '成本', key: 'cost', width: 80 },
|
||||
{ title: '步骤', key: 'steps', width: 110 },
|
||||
{ title: '状态', key: 'status', width: 80 }
|
||||
]
|
||||
const stepDotColor = { 1: 'var(--ok)', 2: 'var(--err)', 0: 'var(--run)' }
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<PageHeader title="运行记录" desc="内存环形缓冲最近 200 次运行(重启清零);长期数据请看「历史记录」页">
|
||||
<template #actions>
|
||||
<a-button :disabled="selectedIds.length !== 2" @click="openCompare">
|
||||
<template #icon><SwapOutlined /></template>对比({{ selectedIds.length }}/2)
|
||||
</a-button>
|
||||
<a-button @click="exportAll"><template #icon><DownloadOutlined /></template>导出 JSON</a-button>
|
||||
</template>
|
||||
</PageHeader>
|
||||
|
||||
<!-- 筛选条 -->
|
||||
<div class="glass p-3 mb-4 flex items-center gap-3 flex-wrap">
|
||||
<a-select v-model:value="scene" style="width: 140px" placeholder="全部场景" allow-clear
|
||||
:options="[{ label: '全部场景', value: '' }, ...SCENE_OPTIONS]" />
|
||||
<a-segmented v-model:value="status"
|
||||
:options="[{ label: '全部', value: 0 }, { label: '成功', value: 1 }, { label: '失败', value: 2 }, { label: '拦截', value: 3 }]" />
|
||||
<a-input v-model:value="keyword" placeholder="搜 provider / 错误信息 / ID" style="width: 210px" allow-clear />
|
||||
<div class="ml-auto flex items-center gap-2 text-xs" style="color: var(--text-3)">
|
||||
自动刷新 <a-switch v-model:checked="autoRefresh" size="small" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 列表 -->
|
||||
<div class="glass overflow-hidden">
|
||||
<div class="grid items-center px-3 py-2 text-xs font-medium"
|
||||
style="grid-template-columns: 36px 60px 110px 100px minmax(120px,1.2fr) 80px 90px 80px minmax(90px,1fr) 80px; color: var(--text-3); border-bottom: 1px solid var(--glass-border)">
|
||||
<span v-for="c in columns" :key="c.key">{{ c.title }}</span>
|
||||
</div>
|
||||
<a-skeleton v-if="loading" active class="p-4" />
|
||||
<EmptyHint v-else-if="!filtered.length" text="没有符合条件的运行记录"
|
||||
action-text="去调试工具发一条测试请求" @action="router.push('/debug')" />
|
||||
<div v-else class="max-h-[62vh] overflow-auto">
|
||||
<div v-for="r in filtered" :key="r.id"
|
||||
class="grid items-center px-3 py-2 text-xs cursor-pointer glass-hover"
|
||||
style="grid-template-columns: 36px 60px 110px 100px minmax(120px,1.2fr) 80px 90px 80px minmax(90px,1fr) 80px; border-bottom: 1px solid var(--glass-border)"
|
||||
@click="app.openRun(r.id)">
|
||||
<span @click.stop>
|
||||
<a-checkbox :checked="selectedIds.includes(r.id)" @change="e => toggleSelect(r.id, e.target.checked)" />
|
||||
</span>
|
||||
<span class="mono" style="color: var(--text-3)">#{{ r.id }}</span>
|
||||
<span style="color: var(--text-2)">{{ fmtTime(r.started_at) }}</span>
|
||||
<span><SceneTag :scene="r.scene" :clickable="false" /></span>
|
||||
<span class="mono truncate pr-2" style="color: var(--text-2)" :title="r.error || ''">{{ r.provider }}/{{ r.model }}</span>
|
||||
<span class="mono" :style="{ color: msTone(r.total_ms) }">{{ fmtMs(r.total_ms) }}</span>
|
||||
<span class="mono" style="color: var(--text-3)">{{ r.total_tokens }}</span>
|
||||
<span class="mono" style="color: var(--text-3)">{{ fmtCost(estimateCost(r.provider, r.total_tokens * 0.7, r.total_tokens * 0.3)) }}</span>
|
||||
<span class="flex gap-1 items-center">
|
||||
<a-tooltip v-for="(b, i) in r.step_briefs || []" :key="i" :title="`${stepTypeLabel(b.step_type)} ${fmtMs(b.duration_ms)}`">
|
||||
<span class="w-2 h-2 rounded-full inline-block" :style="{ background: stepDotColor[b.status] || 'var(--text-3)' }"></span>
|
||||
</a-tooltip>
|
||||
</span>
|
||||
<span><StatusTag :status="r.status" /></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 运行对比(并排) -->
|
||||
<a-modal v-model:open="compareOpen" title="运行对比" :width="960" :footer="null">
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div v-for="d in compareData" :key="d.id" class="glass p-3">
|
||||
<div class="flex items-center gap-2 mb-2 flex-wrap">
|
||||
<span class="mono font-medium" style="color: var(--primary)">#{{ d.id }}</span>
|
||||
<StatusTag :status="d.status" />
|
||||
<span class="text-xs">{{ sceneLabel(d.scene) }}</span>
|
||||
<span class="mono text-xs" style="color: var(--text-3)">{{ d.provider }}/{{ d.model }}</span>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-y-1 text-xs mb-3">
|
||||
<span style="color: var(--text-3)">总耗时</span><span class="mono" :style="{ color: msTone(d.total_ms) }">{{ fmtMs(d.total_ms) }}</span>
|
||||
<span style="color: var(--text-3)">tokens</span><span class="mono">{{ d.prompt_tokens }}+{{ d.completion_tokens }}={{ d.total_tokens }}</span>
|
||||
<span style="color: var(--text-3)">成本估算</span><span class="mono">{{ fmtCost(estimateCost(d.provider, d.prompt_tokens, d.completion_tokens)) }}</span>
|
||||
<span style="color: var(--text-3)">配置来源</span><span>{{ d.cfg_source || '-' }}</span>
|
||||
</div>
|
||||
<!-- 步骤耗时瀑布:条宽按耗时占比 -->
|
||||
<div class="mb-2 text-xs font-medium" style="color: var(--text-2)">步骤耗时</div>
|
||||
<div v-for="(st, i) in d.steps || []" :key="i" class="mb-1.5">
|
||||
<div class="flex justify-between text-xs" style="color: var(--text-3)">
|
||||
<span>{{ stepTypeLabel(st.step_type) }}</span><span class="mono">{{ fmtMs(st.duration_ms) }}</span>
|
||||
</div>
|
||||
<div class="h-1.5 rounded-full overflow-hidden" style="background: rgba(0,0,0,0.2)">
|
||||
<div class="h-full rounded-full"
|
||||
:style="{ width: Math.max(2, (st.duration_ms / Math.max(d.total_ms, 1)) * 100) + '%', background: st.status === 2 ? 'var(--err)' : 'var(--primary)' }"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="d.error" class="text-xs mt-2" style="color: var(--err)">{{ d.error }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</a-modal>
|
||||
</div>
|
||||
</template>
|
||||
179
src/views/stats/index.vue
Normal file
179
src/views/stats/index.vue
Normal file
@@ -0,0 +1,179 @@
|
||||
<script setup>
|
||||
// 统计分析:runs 缓冲(最近 200 条)前端聚合出趋势 / 场景分布 / token 用量
|
||||
// 维度切换 1h / 6h / 24h;图表配色跟随主题
|
||||
import { ref, computed } from 'vue'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import { getRuns, getStats } from '@/api/agent'
|
||||
import { usePolling } from '@/composables/usePolling'
|
||||
import { sceneLabel, fmtMs, estimateCost, fmtCost } from '@/utils/format'
|
||||
import PageHeader from '@/components/PageHeader.vue'
|
||||
import StatCard from '@/components/StatCard.vue'
|
||||
import EChart from '@/components/EChart.vue'
|
||||
import EmptyHint from '@/components/EmptyHint.vue'
|
||||
|
||||
const app = useAppStore()
|
||||
const range = ref('6h')
|
||||
const runs = ref([])
|
||||
const stats = ref(null)
|
||||
const loading = ref(true)
|
||||
|
||||
async function load() {
|
||||
// allSettled:单接口失败不清空另一半数据,loading 一定解除(同仪表盘)
|
||||
const [r, s] = await Promise.allSettled([
|
||||
getRuns({ limit: 200 }, { silent: true }),
|
||||
getStats({ silent: true })
|
||||
])
|
||||
if (r.status === 'fulfilled') runs.value = r.value.data || []
|
||||
if (s.status === 'fulfilled') stats.value = s.value.data
|
||||
loading.value = false
|
||||
}
|
||||
usePolling(load, 30000)
|
||||
|
||||
// 主题相关配色(图表不吃 CSS 变量,手动映射)
|
||||
const chartColors = computed(() => {
|
||||
const dark = app.theme === 'dark'
|
||||
return {
|
||||
text: dark ? '#bfae8d' : '#5a6478',
|
||||
line: dark ? 'rgba(245,158,11,0.15)' : 'rgba(67,97,238,0.12)',
|
||||
primary: dark ? '#f59e0b' : '#4361ee',
|
||||
accent: dark ? '#fb923c' : '#7c3aed',
|
||||
ok: '#22c55e', err: '#ef4444',
|
||||
palette: dark
|
||||
? ['#f59e0b', '#fb923c', '#22c55e', '#3b82f6', '#a855f7', '#ef4444']
|
||||
: ['#4361ee', '#7c3aed', '#16a34a', '#2563eb', '#d97706', '#dc2626']
|
||||
}
|
||||
})
|
||||
|
||||
// 时间桶聚合:1h→5min 12桶 / 6h→30min 12桶 / 24h→2h 12桶
|
||||
const buckets = computed(() => {
|
||||
const now = Math.floor(Date.now() / 1000)
|
||||
const conf = { '1h': [3600, 300], '6h': [21600, 1800], '24h': [86400, 7200] }[range.value]
|
||||
const [span, step] = conf
|
||||
const start = now - span
|
||||
const n = Math.ceil(span / step)
|
||||
const out = Array.from({ length: n }, (_, i) => ({
|
||||
t: start + i * step, total: 0, ok: 0, prompt: 0, completion: 0
|
||||
}))
|
||||
for (const r of runs.value) {
|
||||
if (r.started_at < start) continue
|
||||
const idx = Math.min(Math.floor((r.started_at - start) / step), n - 1)
|
||||
out[idx].total++
|
||||
if (r.status === 1) out[idx].ok++
|
||||
// 摘要没有分项 token,用 total_tokens 估拆(7:3 经验比例)
|
||||
out[idx].prompt += Math.round((r.total_tokens || 0) * 0.7)
|
||||
out[idx].completion += Math.round((r.total_tokens || 0) * 0.3)
|
||||
}
|
||||
return out
|
||||
})
|
||||
const bucketLabels = computed(() => buckets.value.map(b => {
|
||||
const d = new Date(b.t * 1000)
|
||||
return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
|
||||
}))
|
||||
|
||||
// 图1:请求量 × 成功率双轴
|
||||
const trendOption = computed(() => {
|
||||
const c = chartColors.value
|
||||
return {
|
||||
backgroundColor: 'transparent',
|
||||
tooltip: { trigger: 'axis' },
|
||||
legend: { textStyle: { color: c.text }, top: 0 },
|
||||
grid: { left: 40, right: 44, top: 34, bottom: 24 },
|
||||
xAxis: { type: 'category', data: bucketLabels.value, axisLabel: { color: c.text }, axisLine: { lineStyle: { color: c.line } } },
|
||||
yAxis: [
|
||||
{ type: 'value', name: '请求量', axisLabel: { color: c.text }, splitLine: { lineStyle: { color: c.line } } },
|
||||
{ type: 'value', name: '成功率%', max: 100, axisLabel: { color: c.text }, splitLine: { show: false } }
|
||||
],
|
||||
series: [
|
||||
{ name: '请求量', type: 'bar', data: buckets.value.map(b => b.total), itemStyle: { color: c.primary, borderRadius: [3, 3, 0, 0] }, barMaxWidth: 18 },
|
||||
{ name: '成功率', type: 'line', yAxisIndex: 1, smooth: true,
|
||||
data: buckets.value.map(b => b.total ? Math.round((b.ok / b.total) * 100) : null),
|
||||
itemStyle: { color: c.ok }, connectNulls: true }
|
||||
]
|
||||
}
|
||||
})
|
||||
|
||||
// 图2:场景分布 donut(用全缓冲统计)
|
||||
const sceneOption = computed(() => {
|
||||
const c = chartColors.value
|
||||
const entries = Object.entries(stats.value?.scene_counts || {})
|
||||
return {
|
||||
backgroundColor: 'transparent',
|
||||
color: c.palette,
|
||||
tooltip: { trigger: 'item' },
|
||||
legend: { bottom: 0, textStyle: { color: c.text } },
|
||||
series: [{
|
||||
type: 'pie', radius: ['42%', '68%'], center: ['50%', '44%'],
|
||||
label: { color: c.text, formatter: '{b}\n{c} 次' },
|
||||
data: entries.map(([k, v]) => ({ name: sceneLabel(k), value: v }))
|
||||
}]
|
||||
}
|
||||
})
|
||||
|
||||
// 图3:token 用量堆叠面积
|
||||
const tokenOption = computed(() => {
|
||||
const c = chartColors.value
|
||||
return {
|
||||
backgroundColor: 'transparent',
|
||||
tooltip: { trigger: 'axis' },
|
||||
legend: { textStyle: { color: c.text }, top: 0 },
|
||||
grid: { left: 52, right: 20, top: 34, bottom: 24 },
|
||||
xAxis: { type: 'category', data: bucketLabels.value, axisLabel: { color: c.text }, axisLine: { lineStyle: { color: c.line } } },
|
||||
yAxis: { type: 'value', axisLabel: { color: c.text }, splitLine: { lineStyle: { color: c.line } } },
|
||||
series: [
|
||||
{ name: 'prompt(估)', type: 'line', stack: 'tok', areaStyle: { opacity: .35 }, smooth: true, itemStyle: { color: c.primary }, data: buckets.value.map(b => b.prompt) },
|
||||
{ name: 'completion(估)', type: 'line', stack: 'tok', areaStyle: { opacity: .35 }, smooth: true, itemStyle: { color: c.accent }, data: buckets.value.map(b => b.completion) }
|
||||
]
|
||||
}
|
||||
})
|
||||
|
||||
// 汇总卡
|
||||
const rangeRuns = computed(() => {
|
||||
const now = Math.floor(Date.now() / 1000)
|
||||
const span = { '1h': 3600, '6h': 21600, '24h': 86400 }[range.value]
|
||||
return runs.value.filter(r => r.started_at >= now - span)
|
||||
})
|
||||
const rangeTokens = computed(() => rangeRuns.value.reduce((s, r) => s + (r.total_tokens || 0), 0))
|
||||
const rangeCost = computed(() => rangeRuns.value.reduce((s, r) => s + estimateCost(r.provider, (r.total_tokens || 0) * 0.7, (r.total_tokens || 0) * 0.3), 0))
|
||||
const rangeAvgMs = computed(() => {
|
||||
const ok = rangeRuns.value.filter(r => r.status === 1)
|
||||
if (!ok.length) return null
|
||||
return Math.round(ok.reduce((s, r) => s + r.total_ms, 0) / ok.length)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<PageHeader title="统计分析" desc="基于内存缓冲(最近 200 次运行)的前端聚合;重启后清零,长期趋势看历史记录页">
|
||||
<template #actions>
|
||||
<a-segmented v-model:value="range" :options="[{ label: '1 小时', value: '1h' }, { label: '6 小时', value: '6h' }, { label: '24 小时', value: '24h' }]" />
|
||||
</template>
|
||||
</PageHeader>
|
||||
|
||||
<div class="grid grid-cols-2 lg:grid-cols-4 gap-4 mb-4">
|
||||
<StatCard label="区间运行数" :value="rangeRuns.length" :loading="loading" />
|
||||
<StatCard label="区间成功率" :loading="loading"
|
||||
:value="rangeRuns.length ? ((rangeRuns.filter(r => r.status === 1).length / rangeRuns.length) * 100).toFixed(1) : '-'" unit="%" />
|
||||
<StatCard label="区间平均耗时" :value="rangeAvgMs != null ? fmtMs(rangeAvgMs) : '-'" :loading="loading" />
|
||||
<StatCard label="区间 token / 成本" :value="rangeTokens" :hint="`估算 ${fmtCost(rangeCost)}`" :loading="loading" />
|
||||
</div>
|
||||
|
||||
<EmptyHint v-if="!loading && !runs.length" text="缓冲内没有运行数据,图表无从画起"
|
||||
action-text="去调试工具发一条测试请求" @action="$router.push('/debug')" />
|
||||
<template v-else>
|
||||
<div class="glass p-4 mb-4">
|
||||
<div class="text-sm font-medium mb-2" style="color: var(--text-1)">请求量 × 成功率</div>
|
||||
<EChart :option="trendOption" height="280px" />
|
||||
</div>
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
<div class="glass p-4">
|
||||
<div class="text-sm font-medium mb-2" style="color: var(--text-1)">场景分布(全缓冲)</div>
|
||||
<EChart :option="sceneOption" height="300px" />
|
||||
</div>
|
||||
<div class="glass p-4">
|
||||
<div class="text-sm font-medium mb-2" style="color: var(--text-1)">token 用量(堆叠,按 7:3 估拆输入/输出)</div>
|
||||
<EChart :option="tokenOption" height="300px" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
126
src/views/system/index.vue
Normal file
126
src/views/system/index.vue
Normal file
@@ -0,0 +1,126 @@
|
||||
<script setup>
|
||||
// 系统状态:进程 / 内存(含前端采样的 60 点迷你趋势)/ 并发 / 运行时开关(只读)
|
||||
// 5s 轮询;并发占用 >80% 时给告警条并联动运行记录
|
||||
import { ref, computed } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import { getSystem, getAgentConfig } from '@/api/agent'
|
||||
import { usePolling } from '@/composables/usePolling'
|
||||
import { fmtUptime, fmtTimeFull } from '@/utils/format'
|
||||
import PageHeader from '@/components/PageHeader.vue'
|
||||
import EChart from '@/components/EChart.vue'
|
||||
import GlassCard from '@/components/GlassCard.vue'
|
||||
import KvGrid from '@/components/KvGrid.vue'
|
||||
|
||||
const router = useRouter()
|
||||
const app = useAppStore()
|
||||
const sys = ref(null)
|
||||
const cfg = ref(null)
|
||||
// 内存趋势采样(前端保留最近 60 个点,5s 一个 ≈ 5 分钟窗口)
|
||||
const heapSamples = ref([])
|
||||
|
||||
async function load() {
|
||||
const res = await getSystem({ silent: true })
|
||||
sys.value = res.data
|
||||
heapSamples.value.push(Number(res.data.heap_alloc_mb.toFixed(1)))
|
||||
if (heapSamples.value.length > 60) heapSamples.value.shift()
|
||||
}
|
||||
usePolling(load, 5000)
|
||||
|
||||
// 运行时开关(低频,进页拉一次)
|
||||
getAgentConfig({ silent: true }).then(res => { cfg.value = res.data }).catch(() => {})
|
||||
|
||||
const concUsed = computed(() => sys.value?.enhance_concurrency?.used ?? 0)
|
||||
const concCap = computed(() => sys.value?.enhance_concurrency?.capacity ?? 16)
|
||||
const concPercent = computed(() => Math.round((concUsed.value / Math.max(concCap.value, 1)) * 100))
|
||||
|
||||
// 进程卡键值对(KvGrid 数据化渲染)
|
||||
const procItems = computed(() => {
|
||||
const s = sys.value
|
||||
if (!s) return []
|
||||
return [
|
||||
{ label: '启动时间', value: fmtTimeFull(s.started_at) },
|
||||
{ label: '运行时长', value: fmtUptime(s.uptime_seconds), color: 'var(--ok)' },
|
||||
{ label: 'Go 版本', value: s.go_version },
|
||||
{ label: 'goroutines', value: s.goroutines },
|
||||
{ label: 'RunLog 缓冲', value: `${s.runlog_usage?.used} / ${s.runlog_usage?.capacity}` }
|
||||
]
|
||||
})
|
||||
// 运行时开关键值对(中文值不用等宽字体)
|
||||
const switchItems = computed(() => {
|
||||
const c = cfg.value
|
||||
if (!c) return []
|
||||
return [
|
||||
{ label: 'ReAct 引擎', value: c.react?.enabled ? '开启' : '关闭', mono: false },
|
||||
{ label: '医疗守卫', value: c.medical_guard?.enabled ? '开启' : '关闭', mono: false },
|
||||
{ label: 'token 预算', value: c.token_budget?.enabled ? '开启' : '关闭', mono: false },
|
||||
{ label: 'debug 日志', value: c.debug?.log_request_body ? '开(含请求体)' : '关', mono: false,
|
||||
color: c.debug?.log_request_body ? 'var(--warn)' : undefined },
|
||||
{ label: 'KB 来源', value: c.kb?.source }
|
||||
]
|
||||
})
|
||||
|
||||
const heapOption = computed(() => {
|
||||
const dark = app.theme === 'dark'
|
||||
const c = dark ? '#f59e0b' : '#4361ee'
|
||||
return {
|
||||
backgroundColor: 'transparent',
|
||||
grid: { left: 36, right: 8, top: 8, bottom: 18 },
|
||||
tooltip: { trigger: 'axis' },
|
||||
xAxis: { type: 'category', data: heapSamples.value.map((_, i) => i), show: false },
|
||||
yAxis: { type: 'value', axisLabel: { color: dark ? '#8d7c60' : '#98a1b3', fontSize: 10 }, splitLine: { lineStyle: { color: 'rgba(128,128,128,0.1)' } } },
|
||||
series: [{ type: 'line', data: heapSamples.value, smooth: true, showSymbol: false, areaStyle: { opacity: .25 }, itemStyle: { color: c } }]
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<PageHeader title="系统状态" desc="Go 进程运行时指标(5s 轮询,零外部依赖)" />
|
||||
|
||||
<!-- 并发告警(互通:直达运行记录) -->
|
||||
<a-alert v-if="concPercent >= 80" type="warning" show-icon class="!mb-4"
|
||||
:message="`enhance 并发已占用 ${concUsed}/${concCap}(${concPercent}%),接近限流阈值`">
|
||||
<template #action>
|
||||
<a-button size="small" @click="router.push('/runs')">查看运行中请求</a-button>
|
||||
</template>
|
||||
</a-alert>
|
||||
|
||||
<div class="grid grid-cols-1 lg:grid-cols-3 gap-4 mb-4">
|
||||
<!-- 进程卡 -->
|
||||
<GlassCard title="进程">
|
||||
<KvGrid v-if="sys" :items="procItems" />
|
||||
<a-skeleton v-else active :paragraph="{ rows: 4 }" :title="false" />
|
||||
</GlassCard>
|
||||
|
||||
<!-- 并发卡(居中环形图,非 KV 结构,仅换外壳) -->
|
||||
<GlassCard title="enhance 并发信号量">
|
||||
<div class="flex flex-col items-center justify-center">
|
||||
<a-progress type="circle" :percent="concPercent" :size="120"
|
||||
:stroke-color="concPercent >= 80 ? 'var(--err)' : concPercent >= 50 ? 'var(--warn)' : 'var(--ok)'">
|
||||
<template #format><span class="mono text-sm">{{ concUsed }}/{{ concCap }}</span></template>
|
||||
</a-progress>
|
||||
<div class="text-xs mt-3" style="color: var(--text-3)">满载时新请求快速失败(503),PHP 端自动降级直连</div>
|
||||
</div>
|
||||
</GlassCard>
|
||||
|
||||
<!-- 运行时开关(只读) -->
|
||||
<GlassCard title="运行时开关">
|
||||
<template #extra><a-tag :bordered="false" color="default">只读</a-tag></template>
|
||||
<KvGrid v-if="cfg" :items="switchItems" />
|
||||
<a-skeleton v-else active :paragraph="{ rows: 4 }" :title="false" />
|
||||
<a-button size="small" type="link" class="!px-0 mt-2" @click="router.push('/config')">完整配置 →</a-button>
|
||||
</GlassCard>
|
||||
</div>
|
||||
|
||||
<!-- 内存卡 + 趋势 -->
|
||||
<GlassCard title="堆内存趋势(前端采样 5 分钟窗口)">
|
||||
<template #extra>
|
||||
<span v-if="sys" class="text-xs mono" style="color: var(--text-2)">
|
||||
heap {{ sys.heap_alloc_mb?.toFixed(1) }} MB · OS {{ sys.sys_mb?.toFixed(0) }} MB · GC {{ sys.num_gc }} 次
|
||||
</span>
|
||||
</template>
|
||||
<EChart :option="heapOption" height="200px" />
|
||||
</GlassCard>
|
||||
</div>
|
||||
</template>
|
||||
@@ -3,8 +3,12 @@ import vue from '@vitejs/plugin-vue'
|
||||
import tailwindcss from "@tailwindcss/vite"
|
||||
import path from 'path'
|
||||
|
||||
// https://vite.dev/config/
|
||||
// TCM Agent 管理前端构建配置
|
||||
// - base 固定 /admin/:Go 端把 SPA 挂在 /admin 路径下(router.go 的 NoRoute 回退)
|
||||
// - outDir 直接输出到 Go 项目的 view/admin-dist:单二进制 + 静态目录部署,无需拷贝
|
||||
// - 开发代理指向本机 Go 服务 18123(不 rewrite,Go 的路由本身就带 /api 前缀)
|
||||
export default defineConfig({
|
||||
base: '/admin/',
|
||||
plugins: [
|
||||
vue(),
|
||||
tailwindcss()
|
||||
@@ -14,14 +18,21 @@ export default defineConfig({
|
||||
'@': path.resolve(__dirname, './src')
|
||||
}
|
||||
},
|
||||
build: {
|
||||
outDir: path.resolve(__dirname, '../nl-tcm-agent/view/admin-dist'),
|
||||
emptyOutDir: true,
|
||||
chunkSizeWarningLimit: 1500
|
||||
},
|
||||
server: {
|
||||
port: 8888,
|
||||
port: 8890,
|
||||
proxy: {
|
||||
'/api': {
|
||||
// target: 'http://127.0.0.1:18007/api/',
|
||||
target: 'http://127.0.0.1:18001/api/',
|
||||
changeOrigin: true,
|
||||
rewrite: (path) => path.replace(/^\/api/, '')
|
||||
target: 'http://127.0.0.1:18123',
|
||||
changeOrigin: true
|
||||
},
|
||||
'/health': {
|
||||
target: 'http://127.0.0.1:18123',
|
||||
changeOrigin: true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user