From ba3393d8975178bb044c7b033ad13b7a0d84aea0 Mon Sep 17 00:00:00 2001 From: wanghanlin <1533525126@qq.com> Date: Mon, 27 Jul 2026 17:13:06 +0800 Subject: [PATCH] =?UTF-8?q?=E9=87=8D=E6=9E=84=E5=90=8E=E5=8F=B0=E5=89=8D?= =?UTF-8?q?=E7=AB=AF=E9=A1=B9=E7=9B=AE=EF=BC=8C=E6=95=B4=E4=BD=93=E8=BF=81?= =?UTF-8?q?=E7=A7=BB=E5=88=B0TDesign=20Starter=20for=20Vue=E6=9E=B6?= =?UTF-8?q?=E6=9E=84=E4=B8=8B=EF=BC=8C=E5=B9=B6=E5=B7=B2=E5=85=A8=E9=83=A8?= =?UTF-8?q?=E6=B5=8B=E8=AF=95=E9=80=9A=E8=BF=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 4 + frontend/.gitignore | 3 + frontend/auto-imports.d.ts | 10 + frontend/components.d.ts | 47 + frontend/cp-to-static.mjs | 27 + frontend/index.html | 13 + frontend/package-lock.json | 3823 +++++++++++++++++ frontend/package.json | 33 + frontend/public/favicon.svg | 23 + frontend/src/App.vue | 48 + frontend/src/api/account.ts | 8 + frontend/src/api/api-key.ts | 13 + frontend/src/api/auth.ts | 17 + frontend/src/api/category.ts | 8 + frontend/src/api/chat.ts | 53 + frontend/src/api/conversation.ts | 21 + frontend/src/api/dashboard.ts | 9 + frontend/src/api/document.ts | 134 + frontend/src/api/faq.ts | 17 + frontend/src/api/feedback.ts | 14 + frontend/src/api/mcp-server.ts | 11 + frontend/src/api/model-config.ts | 23 + frontend/src/api/request.ts | 41 + frontend/src/api/role.ts | 11 + frontend/src/api/sensitive-word.ts | 18 + frontend/src/api/system-config.ts | 9 + frontend/src/api/upload.ts | 49 + frontend/src/api/user.ts | 19 + frontend/src/api/webhook.ts | 8 + frontend/src/components/FormDialog.vue | 37 + frontend/src/components/MessageSources.vue | 47 + frontend/src/composables/useConfirm.ts | 38 + frontend/src/composables/useCrudForm.ts | 70 + frontend/src/composables/useDebounce.ts | 33 + frontend/src/composables/useLoading.ts | 36 + frontend/src/composables/usePagination.ts | 51 + frontend/src/layouts/MainLayout.vue | 68 + frontend/src/layouts/Sidebar.vue | 104 + frontend/src/layouts/Topbar.vue | 52 + frontend/src/main.ts | 17 + frontend/src/router/index.ts | 76 + frontend/src/shims-vue.d.ts | 7 + frontend/src/stores/auth.ts | 59 + frontend/src/stores/category.ts | 32 + frontend/src/stores/dialog.ts | 40 + frontend/src/stores/document.ts | 27 + frontend/src/stores/navigation.ts | 44 + frontend/src/styles/shared.css | 49 + frontend/src/types/api.ts | 23 + frontend/src/types/models.ts | 234 + frontend/src/types/page.ts | 3 + frontend/src/types/sse.ts | 8 + frontend/src/utils/format.ts | 24 + frontend/src/utils/markdown.ts | 22 + .../js/utils.js => frontend/src/utils/sse.ts | 196 +- frontend/src/utils/toast.ts | 8 + frontend/src/utils/token.ts | 46 + frontend/src/views/AccountManager.vue | 58 + frontend/src/views/ApiKeyManager.vue | 108 + frontend/src/views/AuditLogManager.vue | 39 + frontend/src/views/CategoryManager.vue | 124 + frontend/src/views/ChatPanel.vue | 684 +++ frontend/src/views/ConversationManager.vue | 83 + frontend/src/views/DashboardPanel.vue | 144 + frontend/src/views/DocDetail.vue | 130 + frontend/src/views/DocList.vue | 264 ++ frontend/src/views/DocSearch.vue | 33 + frontend/src/views/DocStats.vue | 19 + frontend/src/views/DocUpload.vue | 147 + frontend/src/views/FaqManager.vue | 157 + frontend/src/views/LoginPage.vue | 78 + frontend/src/views/McpServerManager.vue | 117 + frontend/src/views/ModelConfigManager.vue | 719 ++++ frontend/src/views/PipelineFlow.vue | 197 + frontend/src/views/RoleManager.vue | 89 + frontend/src/views/SensitiveWordManager.vue | 104 + frontend/src/views/SystemConfigManager.vue | 94 + frontend/src/views/UserManager.vue | 117 + frontend/src/views/WebhookManager.vue | 80 + frontend/test_automation.py | 259 ++ frontend/test_interact.py | 192 + frontend/test_targeted.py | 61 + frontend/tsconfig.json | 26 + frontend/vite.config.ts | 78 + pom.xml | 52 + .../com/wok/supportbot/app/AssistantApp.java | 62 +- .../supportbot/security/SdkAuthFilter.java | 64 +- .../security/SdkJwtTokenProvider.java | 4 +- .../supportbot/security/SecurityConfig.java | 18 +- .../supportbot/service/DocumentService.java | 8 +- .../static/components/AccountManager.js | 155 - .../static/components/ApiKeyManager.js | 365 -- .../static/components/AuditLogManager.js | 103 - .../static/components/CategoryManager.js | 322 -- .../resources/static/components/ChatPanel.js | 529 --- .../static/components/ConversationManager.js | 255 -- .../static/components/DashboardPanel.js | 340 -- .../resources/static/components/DocDetail.js | 186 - .../resources/static/components/DocList.js | 431 -- .../resources/static/components/DocSearch.js | 105 - .../resources/static/components/DocStats.js | 39 - .../resources/static/components/DocUpload.js | 469 -- .../resources/static/components/FaqManager.js | 340 -- .../resources/static/components/LoginPage.js | 71 - .../static/components/McpServerManager.js | 524 --- .../static/components/MessageSources.js | 63 - .../static/components/ModelConfigManager.js | 1251 ------ .../static/components/PipelineFlow.js | 150 - .../static/components/RoleManager.js | 188 - .../static/components/SensitiveWordManager.js | 243 -- .../static/components/SystemConfigManager.js | 273 -- .../static/components/UserManager.js | 315 -- .../static/components/WebhookManager.js | 216 - src/main/resources/static/css/main.css | 585 --- src/main/resources/static/favicon.svg | 23 + src/main/resources/static/index.html | 35 +- src/main/resources/static/js/api.js | 1158 ----- src/main/resources/static/js/app.js | 355 -- src/main/resources/static/js/store.js | 257 -- src/main/resources/static/sdk/chatbot-sdk.js | 6 + 120 files changed, 10208 insertions(+), 9518 deletions(-) create mode 100644 frontend/.gitignore create mode 100644 frontend/auto-imports.d.ts create mode 100644 frontend/components.d.ts create mode 100644 frontend/cp-to-static.mjs create mode 100644 frontend/index.html create mode 100644 frontend/package-lock.json create mode 100644 frontend/package.json create mode 100644 frontend/public/favicon.svg create mode 100644 frontend/src/App.vue create mode 100644 frontend/src/api/account.ts create mode 100644 frontend/src/api/api-key.ts create mode 100644 frontend/src/api/auth.ts create mode 100644 frontend/src/api/category.ts create mode 100644 frontend/src/api/chat.ts create mode 100644 frontend/src/api/conversation.ts create mode 100644 frontend/src/api/dashboard.ts create mode 100644 frontend/src/api/document.ts create mode 100644 frontend/src/api/faq.ts create mode 100644 frontend/src/api/feedback.ts create mode 100644 frontend/src/api/mcp-server.ts create mode 100644 frontend/src/api/model-config.ts create mode 100644 frontend/src/api/request.ts create mode 100644 frontend/src/api/role.ts create mode 100644 frontend/src/api/sensitive-word.ts create mode 100644 frontend/src/api/system-config.ts create mode 100644 frontend/src/api/upload.ts create mode 100644 frontend/src/api/user.ts create mode 100644 frontend/src/api/webhook.ts create mode 100644 frontend/src/components/FormDialog.vue create mode 100644 frontend/src/components/MessageSources.vue create mode 100644 frontend/src/composables/useConfirm.ts create mode 100644 frontend/src/composables/useCrudForm.ts create mode 100644 frontend/src/composables/useDebounce.ts create mode 100644 frontend/src/composables/useLoading.ts create mode 100644 frontend/src/composables/usePagination.ts create mode 100644 frontend/src/layouts/MainLayout.vue create mode 100644 frontend/src/layouts/Sidebar.vue create mode 100644 frontend/src/layouts/Topbar.vue create mode 100644 frontend/src/main.ts create mode 100644 frontend/src/router/index.ts create mode 100644 frontend/src/shims-vue.d.ts create mode 100644 frontend/src/stores/auth.ts create mode 100644 frontend/src/stores/category.ts create mode 100644 frontend/src/stores/dialog.ts create mode 100644 frontend/src/stores/document.ts create mode 100644 frontend/src/stores/navigation.ts create mode 100644 frontend/src/styles/shared.css create mode 100644 frontend/src/types/api.ts create mode 100644 frontend/src/types/models.ts create mode 100644 frontend/src/types/page.ts create mode 100644 frontend/src/types/sse.ts create mode 100644 frontend/src/utils/format.ts create mode 100644 frontend/src/utils/markdown.ts rename src/main/resources/static/js/utils.js => frontend/src/utils/sse.ts (50%) create mode 100644 frontend/src/utils/toast.ts create mode 100644 frontend/src/utils/token.ts create mode 100644 frontend/src/views/AccountManager.vue create mode 100644 frontend/src/views/ApiKeyManager.vue create mode 100644 frontend/src/views/AuditLogManager.vue create mode 100644 frontend/src/views/CategoryManager.vue create mode 100644 frontend/src/views/ChatPanel.vue create mode 100644 frontend/src/views/ConversationManager.vue create mode 100644 frontend/src/views/DashboardPanel.vue create mode 100644 frontend/src/views/DocDetail.vue create mode 100644 frontend/src/views/DocList.vue create mode 100644 frontend/src/views/DocSearch.vue create mode 100644 frontend/src/views/DocStats.vue create mode 100644 frontend/src/views/DocUpload.vue create mode 100644 frontend/src/views/FaqManager.vue create mode 100644 frontend/src/views/LoginPage.vue create mode 100644 frontend/src/views/McpServerManager.vue create mode 100644 frontend/src/views/ModelConfigManager.vue create mode 100644 frontend/src/views/PipelineFlow.vue create mode 100644 frontend/src/views/RoleManager.vue create mode 100644 frontend/src/views/SensitiveWordManager.vue create mode 100644 frontend/src/views/SystemConfigManager.vue create mode 100644 frontend/src/views/UserManager.vue create mode 100644 frontend/src/views/WebhookManager.vue create mode 100644 frontend/test_automation.py create mode 100644 frontend/test_interact.py create mode 100644 frontend/test_targeted.py create mode 100644 frontend/tsconfig.json create mode 100644 frontend/vite.config.ts delete mode 100644 src/main/resources/static/components/AccountManager.js delete mode 100644 src/main/resources/static/components/ApiKeyManager.js delete mode 100644 src/main/resources/static/components/AuditLogManager.js delete mode 100644 src/main/resources/static/components/CategoryManager.js delete mode 100644 src/main/resources/static/components/ChatPanel.js delete mode 100644 src/main/resources/static/components/ConversationManager.js delete mode 100644 src/main/resources/static/components/DashboardPanel.js delete mode 100644 src/main/resources/static/components/DocDetail.js delete mode 100644 src/main/resources/static/components/DocList.js delete mode 100644 src/main/resources/static/components/DocSearch.js delete mode 100644 src/main/resources/static/components/DocStats.js delete mode 100644 src/main/resources/static/components/DocUpload.js delete mode 100644 src/main/resources/static/components/FaqManager.js delete mode 100644 src/main/resources/static/components/LoginPage.js delete mode 100644 src/main/resources/static/components/McpServerManager.js delete mode 100644 src/main/resources/static/components/MessageSources.js delete mode 100644 src/main/resources/static/components/ModelConfigManager.js delete mode 100644 src/main/resources/static/components/PipelineFlow.js delete mode 100644 src/main/resources/static/components/RoleManager.js delete mode 100644 src/main/resources/static/components/SensitiveWordManager.js delete mode 100644 src/main/resources/static/components/SystemConfigManager.js delete mode 100644 src/main/resources/static/components/UserManager.js delete mode 100644 src/main/resources/static/components/WebhookManager.js delete mode 100644 src/main/resources/static/css/main.css create mode 100644 src/main/resources/static/favicon.svg delete mode 100644 src/main/resources/static/js/api.js delete mode 100644 src/main/resources/static/js/app.js delete mode 100644 src/main/resources/static/js/store.js diff --git a/.gitignore b/.gitignore index 564cf01..06262a5 100644 --- a/.gitignore +++ b/.gitignore @@ -37,4 +37,8 @@ build/ /client/node_modules/ /client/node/ /client/dist/ +/frontend/node_modules/ +/frontend/node/ +/frontend/dist/ /.claude/ +/src/main/resources/static/assets/ diff --git a/frontend/.gitignore b/frontend/.gitignore new file mode 100644 index 0000000..f18861b --- /dev/null +++ b/frontend/.gitignore @@ -0,0 +1,3 @@ +node_modules/ +dist/ +node/ diff --git a/frontend/auto-imports.d.ts b/frontend/auto-imports.d.ts new file mode 100644 index 0000000..9d24007 --- /dev/null +++ b/frontend/auto-imports.d.ts @@ -0,0 +1,10 @@ +/* eslint-disable */ +/* prettier-ignore */ +// @ts-nocheck +// noinspection JSUnusedGlobalSymbols +// Generated by unplugin-auto-import +// biome-ignore lint: disable +export {} +declare global { + +} diff --git a/frontend/components.d.ts b/frontend/components.d.ts new file mode 100644 index 0000000..426aa5e --- /dev/null +++ b/frontend/components.d.ts @@ -0,0 +1,47 @@ +/* eslint-disable */ +// @ts-nocheck +// Generated by unplugin-vue-components +// Read more: https://github.com/vuejs/core/pull/3399 +export {} + +/* prettier-ignore */ +declare module 'vue' { + export interface GlobalComponents { + FormDialog: typeof import('./src/components/FormDialog.vue')['default'] + MessageSources: typeof import('./src/components/MessageSources.vue')['default'] + RouterLink: typeof import('vue-router')['RouterLink'] + RouterView: typeof import('vue-router')['RouterView'] + TButton: typeof import('tdesign-vue-next')['Button'] + TCard: typeof import('tdesign-vue-next')['Card'] + TCheckbox: typeof import('tdesign-vue-next')['Checkbox'] + TCheckboxGroup: typeof import('tdesign-vue-next')['CheckboxGroup'] + TCol: typeof import('tdesign-vue-next')['Col'] + TCollapse: typeof import('tdesign-vue-next')['Collapse'] + TCollapsePanel: typeof import('tdesign-vue-next')['CollapsePanel'] + TDatePicker: typeof import('tdesign-vue-next')['DatePicker'] + TDialog: typeof import('tdesign-vue-next')['Dialog'] + TDivider: typeof import('tdesign-vue-next')['Divider'] + TEmpty: typeof import('tdesign-vue-next')['Empty'] + TForm: typeof import('tdesign-vue-next')['Form'] + TFormItem: typeof import('tdesign-vue-next')['FormItem'] + TInput: typeof import('tdesign-vue-next')['Input'] + TInputNumber: typeof import('tdesign-vue-next')['InputNumber'] + TLoading: typeof import('tdesign-vue-next')['Loading'] + TMenu: typeof import('tdesign-vue-next')['Menu'] + TMenuItem: typeof import('tdesign-vue-next')['MenuItem'] + TRadioButton: typeof import('tdesign-vue-next')['RadioButton'] + TRadioGroup: typeof import('tdesign-vue-next')['RadioGroup'] + TRow: typeof import('tdesign-vue-next')['Row'] + TSelect: typeof import('tdesign-vue-next')['Select'] + TSpace: typeof import('tdesign-vue-next')['Space'] + TStatistic: typeof import('tdesign-vue-next')['Statistic'] + TSubmenu: typeof import('tdesign-vue-next')['Submenu'] + TSwitch: typeof import('tdesign-vue-next')['Switch'] + TTable: typeof import('tdesign-vue-next')['Table'] + TTabPanel: typeof import('tdesign-vue-next')['TabPanel'] + TTabs: typeof import('tdesign-vue-next')['Tabs'] + TTag: typeof import('tdesign-vue-next')['Tag'] + TTextarea: typeof import('tdesign-vue-next')['Textarea'] + TUpload: typeof import('tdesign-vue-next')['Upload'] + } +} diff --git a/frontend/cp-to-static.mjs b/frontend/cp-to-static.mjs new file mode 100644 index 0000000..be7e4d4 --- /dev/null +++ b/frontend/cp-to-static.mjs @@ -0,0 +1,27 @@ +/** + * Vite build 后自动将产物复制到 Spring Boot 静态资源目录。 + * 由 npm postbuild 钩子触发。 + */ +import { cpSync, existsSync, readdirSync, rmSync } from 'node:fs'; +import { join } from 'node:path'; + +const DIST = 'dist'; +const TARGET = '../src/main/resources/static'; + +// 1. 清理上一次 Vite 构建的产物(保留 sdk/ 目录) +if (existsSync(TARGET)) { + for (const entry of readdirSync(TARGET)) { + if (entry !== 'sdk') { + rmSync(join(TARGET, entry), { recursive: true, force: true }); + } + } +} + +// 2. 复制新构建产物 +if (existsSync(DIST)) { + cpSync(DIST, TARGET, { recursive: true }); + console.log('✓ 已复制到', TARGET); +} else { + console.error('✗ dist 目录不存在,请先执行 vite build'); + process.exit(1); +} diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..7e66bf8 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,13 @@ + + + + + + + AI 智能客服系统 - Support Bot + + +
+ + + diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..ea53cd0 --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,3823 @@ +{ + "name": "support-bot-admin", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "support-bot-admin", + "version": "1.0.0", + "dependencies": { + "axios": "^1.7.9", + "chart.js": "^4.4.7", + "dompurify": "^3.2.4", + "marked": "^12.0.2", + "mermaid": "^11.4.1", + "pinia": "^2.3.1", + "tdesign-icons-vue-next": "^0.3.6", + "tdesign-vue-next": "^1.10.6", + "vue": "^3.5.13", + "vue-router": "^4.5.0" + }, + "devDependencies": { + "@types/dompurify": "^3.2.0", + "@vitejs/plugin-vue": "^5.2.1", + "typescript": "~5.6.3", + "unplugin-auto-import": "^0.19.0", + "unplugin-vue-components": "^0.28.0", + "vite": "^5.4.21", + "vue-tsc": "^2.2.0" + } + }, + "node_modules/@antfu/install-pkg": { + "version": "1.1.0", + "resolved": "https://registry.npmmirror.com/@antfu/install-pkg/-/install-pkg-1.1.0.tgz", + "integrity": "sha512-MGQsmw10ZyI+EJo45CdSER4zEb+p31LpDAFp2Z3gkSd1yqVZGi0Ebx++YTEMonJy4oChEMLsxZ64j8FH6sSqtQ==", + "license": "MIT", + "dependencies": { + "package-manager-detector": "^1.3.0", + "tinyexec": "^1.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/@antfu/utils": { + "version": "0.7.10", + "resolved": "https://registry.npmmirror.com/@antfu/utils/-/utils-0.7.10.tgz", + "integrity": "sha512-+562v9k4aI80m1+VuMHehNJWLOFjBnXn3tdOitzD0il5b7smkSBal4+a3oKiQTbrwMmN/TBUMDvbdoWDehgOww==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmmirror.com/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmmirror.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.7", + "resolved": "https://registry.npmmirror.com/@babel/parser/-/parser-7.29.7.tgz", + "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.7" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.29.7", + "resolved": "https://registry.npmmirror.com/@babel/runtime/-/runtime-7.29.7.tgz", + "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.7", + "resolved": "https://registry.npmmirror.com/@babel/types/-/types-7.29.7.tgz", + "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@braintree/sanitize-url": { + "version": "7.1.2", + "resolved": "https://registry.npmmirror.com/@braintree/sanitize-url/-/sanitize-url-7.1.2.tgz", + "integrity": "sha512-jigsZK+sMF/cuiB7sERuo9V7N9jx+dhmHHnQyDSVdpZwVutaBu7WvNYqMDLSgFgfB30n452TP3vjDAvFC973mA==", + "license": "MIT" + }, + "node_modules/@chevrotain/types": { + "version": "11.1.2", + "resolved": "https://registry.npmmirror.com/@chevrotain/types/-/types-11.1.2.tgz", + "integrity": "sha512-U+HFai5+zmJCkK86QsaJtoITlboZHBqrVketcO2ROv865xfCMSFpELQoz1GkX5GzME8pTa+3kbKrZHQtI0gdbw==", + "license": "Apache-2.0" + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@iconify/types": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/@iconify/types/-/types-2.0.0.tgz", + "integrity": "sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg==", + "license": "MIT" + }, + "node_modules/@iconify/utils": { + "version": "3.1.4", + "resolved": "https://registry.npmmirror.com/@iconify/utils/-/utils-3.1.4.tgz", + "integrity": "sha512-b1S7B1k9ohZ+iNTi2ATxbRYG9fTrJmUT0rc46bvVnNxqNRGW7dyo/vRREwyniI5IRN2RSJHDcm+s3BjWrSAjHw==", + "license": "MIT", + "dependencies": { + "@antfu/install-pkg": "^1.1.0", + "@iconify/types": "^2.0.0", + "import-meta-resolve": "^4.2.0" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmmirror.com/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmmirror.com/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmmirror.com/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmmirror.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmmirror.com/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@kurkle/color": { + "version": "0.3.4", + "resolved": "https://registry.npmmirror.com/@kurkle/color/-/color-0.3.4.tgz", + "integrity": "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==", + "license": "MIT" + }, + "node_modules/@mermaid-js/parser": { + "version": "1.2.0", + "resolved": "https://registry.npmmirror.com/@mermaid-js/parser/-/parser-1.2.0.tgz", + "integrity": "sha512-oYPyv8A4As1yH5Bx+04iQEQxXuIQDe0GKCNSRgao6z8AM9jixXIfP0vsppRLvGf+nKIOb9/LdpWA4YuJiVvESA==", + "license": "MIT", + "dependencies": { + "@chevrotain/types": "~11.1.2" + } + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmmirror.com/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmmirror.com/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmmirror.com/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@popperjs/core": { + "version": "2.11.8", + "resolved": "https://registry.npmmirror.com/@popperjs/core/-/core-2.11.8.tgz", + "integrity": "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/popperjs" + } + }, + "node_modules/@rollup/pluginutils": { + "version": "5.4.0", + "resolved": "https://registry.npmmirror.com/@rollup/pluginutils/-/pluginutils-5.4.0.tgz", + "integrity": "sha512-MfPp06CjRLfXQ3wY0R8vJDYBy/MvVcc9OulEfR0B8Iv9ko+GCNaRZ+EpJYFl27LhKsZK0o420sYCRHCjfCgeUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "estree-walker": "^2.0.2", + "picomatch": "^4.0.2" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.62.3", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.3.tgz", + "integrity": "sha512-c0wdcekXtQvvn5Tsrk/+op/gUArrbWaFduBnTLP2l1cKLSQs4diMWjJw3m6A0DdzT8dAAX95KpkJ3qynCePbmw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.62.3", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.3.tgz", + "integrity": "sha512-3YjElDdWN+qXAFbJ/CzPV+0wspLqh54k/I6GfdYtEJRqg7buSgc1yPM3B+93j1M4neobtkATHZTmxK2AMVGfnA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.62.3", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.3.tgz", + "integrity": "sha512-Pch2pFNOxxz1hTjypIdPyRTR6riiwRl84+VcN9djS680fw+Co1nAJINrdpqp7KV0NvyuU8ilZXZCjd7ykJl1GQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.62.3", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.3.tgz", + "integrity": "sha512-LEuncFUHFiF8t4yZVZvvZA1wk0pjAscRnsrn1EfTEmN4HXotBi2YtcnLRyaK6UbuczW7xZS5ES+81Rdz8Z0T6g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.62.3", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.3.tgz", + "integrity": "sha512-zvBUvsQUpOWALdDsk6qbS8bXf2VxmPisuudNDrY7x0p0jBdsoZl8HsHczIOgkQiZldmcacMKtBzpoGVNeIe2bQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.62.3", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.3.tgz", + "integrity": "sha512-C2KmNrcSem/AMg984H/dev+si0lieQGdXdR/lYGJnuumXnFb9Y7QdiI62obFdLlxRYLBv4P0eUVIDbD4c1vVvw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.62.3", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.3.tgz", + "integrity": "sha512-ggXnsTAEzNQx74XpunRsiZ9aBZDsI7XIa0hm2nzR9f4WzH5/f/d73ZSDaC5ejJ8YLY4NW+V3wr0tjOaeCq8hqA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.62.3", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.3.tgz", + "integrity": "sha512-2vng+FlzNUhKZxtej3IUqJgbZoQk2M/dwQM20+ULV0R/E/8tr9/P6uEf2iiGIk4HL0zMKh5Jry7mUHdUOvyGgA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.62.3", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.3.tgz", + "integrity": "sha512-LLLFZKt4/Nraf9rxDkhiU8QVgLF4WmCkfr0L4fj0fPfIZFBib0DeiFk1hhaYKd03LFAFJcxHslhDFlNJLylf5Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.62.3", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.3.tgz", + "integrity": "sha512-WJkdQCvS9sWNOUBJZfQRKpZGFBztRzcowI+nndmflKgU4XY+3a420FgTOSKTsVqJbnzSxeT4vaJalpOaPo2YCQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.62.3", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.3.tgz", + "integrity": "sha512-PwHXCCS2n64/1Ot6rP1YEYA02MGYBcQlr8CSZZyrUG2O7NH6NklYmvr9v3Jy+5e/eDeNchc/ukmKJi9LuflMIQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.62.3", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.3.tgz", + "integrity": "sha512-vUjxINQu3RC8NZS3ykk1gN65gIz8pAopOq2HXuZhiIxHdx7TFvDG+jgrdSgInu1Eza4/Rfi2VzZgyIgEH4WOaw==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.62.3", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.3.tgz", + "integrity": "sha512-wzko4aJ13+0G3kGnviCg5gnXFKd40izKsrf2uOw12US4XqprkDrmwOpeW14aSNa37V8bfPcz5Fkob6LZ3BAPmA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.62.3", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.3.tgz", + "integrity": "sha512-8120ue0JUMSwy11stlwnfdX3pPd+WZYGCDBwEHWtIHi6pOpZmsEF5QKB7a/UN+XFdqvobxz98kv8RTqikyCEBw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.62.3", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.3.tgz", + "integrity": "sha512-XLFHnR3tXMjbOCh2vtVJHmxt+995uJsTERQyseFDRA0xxMxyTZPLa3OIUlyFaO4mF/Lu0FjmWHCuPXJT1n/IOg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.62.3", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.3.tgz", + "integrity": "sha512-se6yXvNGMIl0f+RQzyh7XAmia8/9kplQx424wnG2w0C1oi6XgO6Y8otKhdXFHbHs88Ihavzmvh1NWjuovE76BQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.62.3", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.3.tgz", + "integrity": "sha512-gNoxRefktVIiGflpONuxWWXZAzIQG++z9qHO3xKwk4WdDMuQja3JHGfE1u0i3PfPDyvhypdk+WrgIJqLhGG7sg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.62.3", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.3.tgz", + "integrity": "sha512-V4KtWtQfAFMU7+9/A/VDps/VI8CHd3cYz0L8sgJzz8qK7eY7wI4ruFD82UYIYvW9Z4DtlTfhQcsl4XyPHW5uSg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.62.3", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.3.tgz", + "integrity": "sha512-LBx9LYXvj2CBkMkjLdNAWLwH0MLMin7do2VcVo9kVPibGLkY0BQQut2fv7NVqkXqZ/CrAu9LqDHVV1xHCMpCPw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.62.3", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.3.tgz", + "integrity": "sha512-ABVf3Q0RCu7NcyCCOZQI0pJ3GuSdfSl8EXcy88QtdceIMIoCUdfhsJChZ64L9zVM2aJHjde1Bhn5uqSRcX9ySA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.62.3", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.3.tgz", + "integrity": "sha512-+2Cy/ldweGBLlPIKsQLF8U5N44a0KDdbrk1rAjHOM9M2K+kGdIVjHLmmrZIcx+9Ny3ke/1JomCsDI1ocb11+sg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.62.3", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.3.tgz", + "integrity": "sha512-dtZvzc8BedpSaFNy75x6uiWwAGTH+aZHDtdrqP6qk+WcLJrfti6sGje1ZJ9UxyzDLF23d/mV+PaMwuC0hL7UVA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.62.3", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.3.tgz", + "integrity": "sha512-Rj8Ra4noo+aYy7sKBggCx0407mws34kAb1ySyWuq5DAtFBQdkSwnsjCgPrhPe9cvgBKZIukpE+CVHvORCS93kQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.62.3", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.3.tgz", + "integrity": "sha512-vp7N084ew/odXn2gi/mzm9mUkQu9l6AiN6dt4IeUM2Uvm9o+cVmP+YkqbMOteLbiGgqBBlJZjIMYVCfOOIVbVQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.62.3", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.3.tgz", + "integrity": "sha512-MOG/3gTOn4Fwf574RVOaY61I5o6P90legkFADiTyn1hyjNydT+cerU2rLUwPdZkKKyJ+iT+K9p7WXK4LM1Ka6g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/d3": { + "version": "7.4.3", + "resolved": "https://registry.npmmirror.com/@types/d3/-/d3-7.4.3.tgz", + "integrity": "sha512-lZXZ9ckh5R8uiFVt8ogUNf+pIrK4EsWrx2Np75WvF/eTpJ0FMHNhjXk8CKEx/+gpHbNQyJWehbFaTvqmHWB3ww==", + "license": "MIT", + "dependencies": { + "@types/d3-array": "*", + "@types/d3-axis": "*", + "@types/d3-brush": "*", + "@types/d3-chord": "*", + "@types/d3-color": "*", + "@types/d3-contour": "*", + "@types/d3-delaunay": "*", + "@types/d3-dispatch": "*", + "@types/d3-drag": "*", + "@types/d3-dsv": "*", + "@types/d3-ease": "*", + "@types/d3-fetch": "*", + "@types/d3-force": "*", + "@types/d3-format": "*", + "@types/d3-geo": "*", + "@types/d3-hierarchy": "*", + "@types/d3-interpolate": "*", + "@types/d3-path": "*", + "@types/d3-polygon": "*", + "@types/d3-quadtree": "*", + "@types/d3-random": "*", + "@types/d3-scale": "*", + "@types/d3-scale-chromatic": "*", + "@types/d3-selection": "*", + "@types/d3-shape": "*", + "@types/d3-time": "*", + "@types/d3-time-format": "*", + "@types/d3-timer": "*", + "@types/d3-transition": "*", + "@types/d3-zoom": "*" + } + }, + "node_modules/@types/d3-array": { + "version": "3.2.2", + "resolved": "https://registry.npmmirror.com/@types/d3-array/-/d3-array-3.2.2.tgz", + "integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==", + "license": "MIT" + }, + "node_modules/@types/d3-axis": { + "version": "3.0.6", + "resolved": "https://registry.npmmirror.com/@types/d3-axis/-/d3-axis-3.0.6.tgz", + "integrity": "sha512-pYeijfZuBd87T0hGn0FO1vQ/cgLk6E1ALJjfkC0oJ8cbwkZl3TpgS8bVBLZN+2jjGgg38epgxb2zmoGtSfvgMw==", + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-brush": { + "version": "3.0.6", + "resolved": "https://registry.npmmirror.com/@types/d3-brush/-/d3-brush-3.0.6.tgz", + "integrity": "sha512-nH60IZNNxEcrh6L1ZSMNA28rj27ut/2ZmI3r96Zd+1jrZD++zD3LsMIjWlvg4AYrHn/Pqz4CF3veCxGjtbqt7A==", + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-chord": { + "version": "3.0.6", + "resolved": "https://registry.npmmirror.com/@types/d3-chord/-/d3-chord-3.0.6.tgz", + "integrity": "sha512-LFYWWd8nwfwEmTZG9PfQxd17HbNPksHBiJHaKuY1XeqscXacsS2tyoo6OdRsjf+NQYeB6XrNL3a25E3gH69lcg==", + "license": "MIT" + }, + "node_modules/@types/d3-color": { + "version": "3.1.3", + "resolved": "https://registry.npmmirror.com/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", + "license": "MIT" + }, + "node_modules/@types/d3-contour": { + "version": "3.0.6", + "resolved": "https://registry.npmmirror.com/@types/d3-contour/-/d3-contour-3.0.6.tgz", + "integrity": "sha512-BjzLgXGnCWjUSYGfH1cpdo41/hgdWETu4YxpezoztawmqsvCeep+8QGfiY6YbDvfgHz/DkjeIkkZVJavB4a3rg==", + "license": "MIT", + "dependencies": { + "@types/d3-array": "*", + "@types/geojson": "*" + } + }, + "node_modules/@types/d3-delaunay": { + "version": "6.0.4", + "resolved": "https://registry.npmmirror.com/@types/d3-delaunay/-/d3-delaunay-6.0.4.tgz", + "integrity": "sha512-ZMaSKu4THYCU6sV64Lhg6qjf1orxBthaC161plr5KuPHo3CNm8DTHiLw/5Eq2b6TsNP0W0iJrUOFscY6Q450Hw==", + "license": "MIT" + }, + "node_modules/@types/d3-dispatch": { + "version": "3.0.7", + "resolved": "https://registry.npmmirror.com/@types/d3-dispatch/-/d3-dispatch-3.0.7.tgz", + "integrity": "sha512-5o9OIAdKkhN1QItV2oqaE5KMIiXAvDWBDPrD85e58Qlz1c1kI/J0NcqbEG88CoTwJrYe7ntUCVfeUl2UJKbWgA==", + "license": "MIT" + }, + "node_modules/@types/d3-drag": { + "version": "3.0.7", + "resolved": "https://registry.npmmirror.com/@types/d3-drag/-/d3-drag-3.0.7.tgz", + "integrity": "sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==", + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-dsv": { + "version": "3.0.7", + "resolved": "https://registry.npmmirror.com/@types/d3-dsv/-/d3-dsv-3.0.7.tgz", + "integrity": "sha512-n6QBF9/+XASqcKK6waudgL0pf/S5XHPPI8APyMLLUHd8NqouBGLsU8MgtO7NINGtPBtk9Kko/W4ea0oAspwh9g==", + "license": "MIT" + }, + "node_modules/@types/d3-ease": { + "version": "3.0.2", + "resolved": "https://registry.npmmirror.com/@types/d3-ease/-/d3-ease-3.0.2.tgz", + "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==", + "license": "MIT" + }, + "node_modules/@types/d3-fetch": { + "version": "3.0.7", + "resolved": "https://registry.npmmirror.com/@types/d3-fetch/-/d3-fetch-3.0.7.tgz", + "integrity": "sha512-fTAfNmxSb9SOWNB9IoG5c8Hg6R+AzUHDRlsXsDZsNp6sxAEOP0tkP3gKkNSO/qmHPoBFTxNrjDprVHDQDvo5aA==", + "license": "MIT", + "dependencies": { + "@types/d3-dsv": "*" + } + }, + "node_modules/@types/d3-force": { + "version": "3.0.10", + "resolved": "https://registry.npmmirror.com/@types/d3-force/-/d3-force-3.0.10.tgz", + "integrity": "sha512-ZYeSaCF3p73RdOKcjj+swRlZfnYpK1EbaDiYICEEp5Q6sUiqFaFQ9qgoshp5CzIyyb/yD09kD9o2zEltCexlgw==", + "license": "MIT" + }, + "node_modules/@types/d3-format": { + "version": "3.0.4", + "resolved": "https://registry.npmmirror.com/@types/d3-format/-/d3-format-3.0.4.tgz", + "integrity": "sha512-fALi2aI6shfg7vM5KiR1wNJnZ7r6UuggVqtDA+xiEdPZQwy/trcQaHnwShLuLdta2rTymCNpxYTiMZX/e09F4g==", + "license": "MIT" + }, + "node_modules/@types/d3-geo": { + "version": "3.1.0", + "resolved": "https://registry.npmmirror.com/@types/d3-geo/-/d3-geo-3.1.0.tgz", + "integrity": "sha512-856sckF0oP/diXtS4jNsiQw/UuK5fQG8l/a9VVLeSouf1/PPbBE1i1W852zVwKwYCBkFJJB7nCFTbk6UMEXBOQ==", + "license": "MIT", + "dependencies": { + "@types/geojson": "*" + } + }, + "node_modules/@types/d3-hierarchy": { + "version": "3.1.7", + "resolved": "https://registry.npmmirror.com/@types/d3-hierarchy/-/d3-hierarchy-3.1.7.tgz", + "integrity": "sha512-tJFtNoYBtRtkNysX1Xq4sxtjK8YgoWUNpIiUee0/jHGRwqvzYxkq0hGVbbOGSz+JgFxxRu4K8nb3YpG3CMARtg==", + "license": "MIT" + }, + "node_modules/@types/d3-interpolate": { + "version": "3.0.4", + "resolved": "https://registry.npmmirror.com/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", + "license": "MIT", + "dependencies": { + "@types/d3-color": "*" + } + }, + "node_modules/@types/d3-path": { + "version": "3.1.1", + "resolved": "https://registry.npmmirror.com/@types/d3-path/-/d3-path-3.1.1.tgz", + "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==", + "license": "MIT" + }, + "node_modules/@types/d3-polygon": { + "version": "3.0.2", + "resolved": "https://registry.npmmirror.com/@types/d3-polygon/-/d3-polygon-3.0.2.tgz", + "integrity": "sha512-ZuWOtMaHCkN9xoeEMr1ubW2nGWsp4nIql+OPQRstu4ypeZ+zk3YKqQT0CXVe/PYqrKpZAi+J9mTs05TKwjXSRA==", + "license": "MIT" + }, + "node_modules/@types/d3-quadtree": { + "version": "3.0.6", + "resolved": "https://registry.npmmirror.com/@types/d3-quadtree/-/d3-quadtree-3.0.6.tgz", + "integrity": "sha512-oUzyO1/Zm6rsxKRHA1vH0NEDG58HrT5icx/azi9MF1TWdtttWl0UIUsjEQBBh+SIkrpd21ZjEv7ptxWys1ncsg==", + "license": "MIT" + }, + "node_modules/@types/d3-random": { + "version": "3.0.4", + "resolved": "https://registry.npmmirror.com/@types/d3-random/-/d3-random-3.0.4.tgz", + "integrity": "sha512-UHYId5WTCx4L4YNel7NU00XUXXgvgpgZOvp10PuvsQENjMDXhh2RyFc0KBjO7B45ne4Ha1yVH7ii0vnzKkuzWA==", + "license": "MIT" + }, + "node_modules/@types/d3-scale": { + "version": "4.0.9", + "resolved": "https://registry.npmmirror.com/@types/d3-scale/-/d3-scale-4.0.9.tgz", + "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==", + "license": "MIT", + "dependencies": { + "@types/d3-time": "*" + } + }, + "node_modules/@types/d3-scale-chromatic": { + "version": "3.1.0", + "resolved": "https://registry.npmmirror.com/@types/d3-scale-chromatic/-/d3-scale-chromatic-3.1.0.tgz", + "integrity": "sha512-iWMJgwkK7yTRmWqRB5plb1kadXyQ5Sj8V/zYlFGMUBbIPKQScw+Dku9cAAMgJG+z5GYDoMjWGLVOvjghDEFnKQ==", + "license": "MIT" + }, + "node_modules/@types/d3-selection": { + "version": "3.0.11", + "resolved": "https://registry.npmmirror.com/@types/d3-selection/-/d3-selection-3.0.11.tgz", + "integrity": "sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==", + "license": "MIT" + }, + "node_modules/@types/d3-shape": { + "version": "3.1.8", + "resolved": "https://registry.npmmirror.com/@types/d3-shape/-/d3-shape-3.1.8.tgz", + "integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==", + "license": "MIT", + "dependencies": { + "@types/d3-path": "*" + } + }, + "node_modules/@types/d3-time": { + "version": "3.0.4", + "resolved": "https://registry.npmmirror.com/@types/d3-time/-/d3-time-3.0.4.tgz", + "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==", + "license": "MIT" + }, + "node_modules/@types/d3-time-format": { + "version": "4.0.3", + "resolved": "https://registry.npmmirror.com/@types/d3-time-format/-/d3-time-format-4.0.3.tgz", + "integrity": "sha512-5xg9rC+wWL8kdDj153qZcsJ0FWiFt0J5RB6LYUNZjwSnesfblqrI/bJ1wBdJ8OQfncgbJG5+2F+qfqnqyzYxyg==", + "license": "MIT" + }, + "node_modules/@types/d3-timer": { + "version": "3.0.2", + "resolved": "https://registry.npmmirror.com/@types/d3-timer/-/d3-timer-3.0.2.tgz", + "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", + "license": "MIT" + }, + "node_modules/@types/d3-transition": { + "version": "3.0.9", + "resolved": "https://registry.npmmirror.com/@types/d3-transition/-/d3-transition-3.0.9.tgz", + "integrity": "sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==", + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-zoom": { + "version": "3.0.8", + "resolved": "https://registry.npmmirror.com/@types/d3-zoom/-/d3-zoom-3.0.8.tgz", + "integrity": "sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==", + "license": "MIT", + "dependencies": { + "@types/d3-interpolate": "*", + "@types/d3-selection": "*" + } + }, + "node_modules/@types/dompurify": { + "version": "3.2.0", + "resolved": "https://registry.npmmirror.com/@types/dompurify/-/dompurify-3.2.0.tgz", + "integrity": "sha512-Fgg31wv9QbLDA0SpTOXO3MaxySc4DKGLi8sna4/Utjo4r3ZRPdCt4UQee8BWr+Q5z21yifghREPJGYaEOEIACg==", + "deprecated": "This is a stub types definition. dompurify provides its own type definitions, so you do not need this installed.", + "dev": true, + "license": "MIT", + "dependencies": { + "dompurify": "*" + } + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmmirror.com/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/geojson": { + "version": "7946.0.16", + "resolved": "https://registry.npmmirror.com/@types/geojson/-/geojson-7946.0.16.tgz", + "integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==", + "license": "MIT" + }, + "node_modules/@types/lodash": { + "version": "4.17.24", + "resolved": "https://registry.npmmirror.com/@types/lodash/-/lodash-4.17.24.tgz", + "integrity": "sha512-gIW7lQLZbue7lRSWEFql49QJJWThrTFFeIMJdp3eH4tKoxm1OvEPg02rm4wCCSHS0cL3/Fizimb35b7k8atwsQ==", + "license": "MIT" + }, + "node_modules/@types/lodash-es": { + "version": "4.17.12", + "resolved": "https://registry.npmmirror.com/@types/lodash-es/-/lodash-es-4.17.12.tgz", + "integrity": "sha512-0NgftHUcV4v34VhXm8QBSftKVXtbkBG3ViCjs6+eJ5a6y6Mi/jiFGPc1sC7QK+9BFhWrURE3EOggmWaSxL9OzQ==", + "license": "MIT", + "dependencies": { + "@types/lodash": "*" + } + }, + "node_modules/@types/sortablejs": { + "version": "1.15.9", + "resolved": "https://registry.npmmirror.com/@types/sortablejs/-/sortablejs-1.15.9.tgz", + "integrity": "sha512-7HP+rZGE2p886PKV9c9OJzLBI6BBJu1O7lJGYnPyG3fS4/duUCcngkNCjsLwIMV+WMqANe3tt4irrXHSIe68OQ==", + "license": "MIT" + }, + "node_modules/@types/tinycolor2": { + "version": "1.4.6", + "resolved": "https://registry.npmmirror.com/@types/tinycolor2/-/tinycolor2-1.4.6.tgz", + "integrity": "sha512-iEN8J0BoMnsWBqjVbWH/c0G0Hh7O21lpR2/+PrvAVgWdzL7eexIFm4JN/Wn10PTcmNdtS6U67r499mlWMXOxNw==", + "license": "MIT" + }, + "node_modules/@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://registry.npmmirror.com/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "license": "MIT", + "optional": true + }, + "node_modules/@types/validator": { + "version": "13.15.10", + "resolved": "https://registry.npmmirror.com/@types/validator/-/validator-13.15.10.tgz", + "integrity": "sha512-T8L6i7wCuyoK8A/ZeLYt1+q0ty3Zb9+qbSSvrIVitzT3YjZqkTZ40IbRsPanlB4h1QB3JVL1SYCdR6ngtFYcuA==", + "license": "MIT" + }, + "node_modules/@upsetjs/venn.js": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/@upsetjs/venn.js/-/venn.js-2.0.0.tgz", + "integrity": "sha512-WbBhLrooyePuQ1VZxrJjtLvTc4NVfpOyKx0sKqioq9bX1C1m7Jgykkn8gLrtwumBioXIqam8DLxp88Adbue6Hw==", + "license": "MIT", + "optionalDependencies": { + "d3-selection": "^3.0.0", + "d3-transition": "^3.0.1" + } + }, + "node_modules/@vitejs/plugin-vue": { + "version": "5.2.4", + "resolved": "https://registry.npmmirror.com/@vitejs/plugin-vue/-/plugin-vue-5.2.4.tgz", + "integrity": "sha512-7Yx/SXSOcQq5HiiV3orevHUFn+pmMB4cgbEkDYgnkUWb0WfeQ/wa2yFv6D5ICiCQOVpjA7vYDXrC7AGO8yjDHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "peerDependencies": { + "vite": "^5.0.0 || ^6.0.0", + "vue": "^3.2.25" + } + }, + "node_modules/@volar/language-core": { + "version": "2.4.15", + "resolved": "https://registry.npmmirror.com/@volar/language-core/-/language-core-2.4.15.tgz", + "integrity": "sha512-3VHw+QZU0ZG9IuQmzT68IyN4hZNd9GchGPhbD9+pa8CVv7rnoOZwo7T8weIbrRmihqy3ATpdfXFnqRrfPVK6CA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/source-map": "2.4.15" + } + }, + "node_modules/@volar/source-map": { + "version": "2.4.15", + "resolved": "https://registry.npmmirror.com/@volar/source-map/-/source-map-2.4.15.tgz", + "integrity": "sha512-CPbMWlUN6hVZJYGcU/GSoHu4EnCHiLaXI9n8c9la6RaI9W5JHX+NqG+GSQcB0JdC2FIBLdZJwGsfKyBB71VlTg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@volar/typescript": { + "version": "2.4.15", + "resolved": "https://registry.npmmirror.com/@volar/typescript/-/typescript-2.4.15.tgz", + "integrity": "sha512-2aZ8i0cqPGjXb4BhkMsPYDkkuc2ZQ6yOpqwAuNwUoncELqoy5fRgOQtLR9gB0g902iS0NAkvpIzs27geVyVdPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/language-core": "2.4.15", + "path-browserify": "^1.0.1", + "vscode-uri": "^3.0.8" + } + }, + "node_modules/@vue/compiler-core": { + "version": "3.5.40", + "resolved": "https://registry.npmmirror.com/@vue/compiler-core/-/compiler-core-3.5.40.tgz", + "integrity": "sha512-39E8IgOhTbVDnoJFMKc2DvYnypcZwUqgUhQkccva/0m6FUwtIKSGV7n1hpVmYcFaoRAwf9pBcwnKlCEsN63ZEQ==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.7", + "@vue/shared": "3.5.40", + "entities": "^7.0.1", + "estree-walker": "^2.0.2", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-dom": { + "version": "3.5.40", + "resolved": "https://registry.npmmirror.com/@vue/compiler-dom/-/compiler-dom-3.5.40.tgz", + "integrity": "sha512-pwkx4vqlqOspFstrcmzwkKLePVMD3PT65imRzLhanU2V1Fj4K13g6OXjanOyzw3aTAuRk84BOmY8f3rEHqPaVA==", + "license": "MIT", + "dependencies": { + "@vue/compiler-core": "3.5.40", + "@vue/shared": "3.5.40" + } + }, + "node_modules/@vue/compiler-sfc": { + "version": "3.5.40", + "resolved": "https://registry.npmmirror.com/@vue/compiler-sfc/-/compiler-sfc-3.5.40.tgz", + "integrity": "sha512-gIf497P4kpuALcvs5n3AEg1Vdn0pSY4XbjASIfHNYF1/MP3T2Mf2STERTubysBxCRxzJGJYtF/O7vwJrxFB3Vw==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.7", + "@vue/compiler-core": "3.5.40", + "@vue/compiler-dom": "3.5.40", + "@vue/compiler-ssr": "3.5.40", + "@vue/shared": "3.5.40", + "estree-walker": "^2.0.2", + "magic-string": "^0.30.21", + "postcss": "^8.5.19", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-ssr": { + "version": "3.5.40", + "resolved": "https://registry.npmmirror.com/@vue/compiler-ssr/-/compiler-ssr-3.5.40.tgz", + "integrity": "sha512-rrE5xiXG663+vHCHa3J9p2z5OcBRjXmoqenprJxAFQxg5pSshzeBiCE6pu46axapRJ2Adk0YDA2BRZVjiHXnhg==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.40", + "@vue/shared": "3.5.40" + } + }, + "node_modules/@vue/compiler-vue2": { + "version": "2.7.16", + "resolved": "https://registry.npmmirror.com/@vue/compiler-vue2/-/compiler-vue2-2.7.16.tgz", + "integrity": "sha512-qYC3Psj9S/mfu9uVi5WvNZIzq+xnXMhOwbTFKKDD7b1lhpnn71jXSFdTQ+WsIEk0ONCd7VV2IMm7ONl6tbQ86A==", + "dev": true, + "license": "MIT", + "dependencies": { + "de-indent": "^1.0.2", + "he": "^1.2.0" + } + }, + "node_modules/@vue/devtools-api": { + "version": "6.6.4", + "resolved": "https://registry.npmmirror.com/@vue/devtools-api/-/devtools-api-6.6.4.tgz", + "integrity": "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==", + "license": "MIT" + }, + "node_modules/@vue/language-core": { + "version": "2.2.12", + "resolved": "https://registry.npmmirror.com/@vue/language-core/-/language-core-2.2.12.tgz", + "integrity": "sha512-IsGljWbKGU1MZpBPN+BvPAdr55YPkj2nB/TBNGNC32Vy2qLG25DYu/NBN2vNtZqdRbTRjaoYrahLrToim2NanA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/language-core": "2.4.15", + "@vue/compiler-dom": "^3.5.0", + "@vue/compiler-vue2": "^2.7.16", + "@vue/shared": "^3.5.0", + "alien-signals": "^1.0.3", + "minimatch": "^9.0.3", + "muggle-string": "^0.4.1", + "path-browserify": "^1.0.1" + }, + "peerDependencies": { + "typescript": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@vue/reactivity": { + "version": "3.5.40", + "resolved": "https://registry.npmmirror.com/@vue/reactivity/-/reactivity-3.5.40.tgz", + "integrity": "sha512-B7ot9UlUZOi1zbq61/LvE88ZLTV8IlajTdiZTAEiDQgrnIMIZoPr9kGw0Zw46ObW62O9+H/Be3kMbfb7kYPQZA==", + "license": "MIT", + "dependencies": { + "@vue/shared": "3.5.40" + } + }, + "node_modules/@vue/runtime-core": { + "version": "3.5.40", + "resolved": "https://registry.npmmirror.com/@vue/runtime-core/-/runtime-core-3.5.40.tgz", + "integrity": "sha512-KAZLweuZ6uUJPK1PMSQPgBU5gCjgrrfjUhSglmU9NhH+Zjepa8cnwSydPWDWHDwOgY4g3VcZ+PljbiHlURNCbw==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.40", + "@vue/shared": "3.5.40" + } + }, + "node_modules/@vue/runtime-dom": { + "version": "3.5.40", + "resolved": "https://registry.npmmirror.com/@vue/runtime-dom/-/runtime-dom-3.5.40.tgz", + "integrity": "sha512-ZfrX8ssZQds900L9pr8AuK05ddnMsR4MPMZr8cPN9GoqoPWcXLhjvvbIA2SMv+7a97sJ1vv9pj/zxK0Cq/eEFQ==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.40", + "@vue/runtime-core": "3.5.40", + "@vue/shared": "3.5.40", + "csstype": "^3.2.3" + } + }, + "node_modules/@vue/server-renderer": { + "version": "3.5.40", + "resolved": "https://registry.npmmirror.com/@vue/server-renderer/-/server-renderer-3.5.40.tgz", + "integrity": "sha512-XNJym9WpevhTVt1HuwOrCRJ5Q+9z4BjTMrDtjTrvx74SmUll8spNTw6whWJa9mEkO4PKn5TihI/bm/8ds2QVJw==", + "license": "MIT", + "dependencies": { + "@vue/compiler-ssr": "3.5.40", + "@vue/runtime-dom": "3.5.40", + "@vue/shared": "3.5.40" + } + }, + "node_modules/@vue/shared": { + "version": "3.5.40", + "resolved": "https://registry.npmmirror.com/@vue/shared/-/shared-3.5.40.tgz", + "integrity": "sha512-WxnBtruIqOoV3rA4jeKDWzrYI5h7Cp4+pjwDi8kWGHz+IslhiN+wguLVVhtv2l8VoU02rzDCVfDjgCl1lNpZVg==", + "license": "MIT" + }, + "node_modules/acorn": { + "version": "8.17.0", + "resolved": "https://registry.npmmirror.com/acorn/-/acorn-8.17.0.tgz", + "integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmmirror.com/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "license": "MIT", + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/alien-signals": { + "version": "1.0.13", + "resolved": "https://registry.npmmirror.com/alien-signals/-/alien-signals-1.0.13.tgz", + "integrity": "sha512-OGj9yyTnJEttvzhTUWuscOvtqxq5vrhF7vL9oS0xJ2mK0ItPYP1/y+vCFebfxoEyAz0++1AIwJ5CMr+Fk3nDmg==", + "dev": true, + "license": "MIT" + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmmirror.com/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/anymatch/node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmmirror.com/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmmirror.com/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/axios": { + "version": "1.18.1", + "resolved": "https://registry.npmmirror.com/axios/-/axios-1.18.1.tgz", + "integrity": "sha512-3nTvFlvpn9Zu/RkHUqtc7/+al4UpRW5az71ap5zccp6e8RAYEzhMTecX8Dz1wWDYrPpUoB1HAQEGEAEvUr7S9g==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.16.0", + "form-data": "^4.0.5", + "https-proxy-agent": "^5.0.1", + "proxy-from-env": "^2.1.0" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmmirror.com/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/brace-expansion": { + "version": "2.1.2", + "resolved": "https://registry.npmmirror.com/brace-expansion/-/brace-expansion-2.1.2.tgz", + "integrity": "sha512-w5JZcKgdhDOgOwm8H+KgbosopHMuGcl6qbulwjtz3SM7I7P3yW1eAjzMPLrIE+NQ9vjgANKHWeMHnrT0OXW1oA==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmmirror.com/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/chart.js": { + "version": "4.5.1", + "resolved": "https://registry.npmmirror.com/chart.js/-/chart.js-4.5.1.tgz", + "integrity": "sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==", + "license": "MIT", + "dependencies": { + "@kurkle/color": "^0.3.0" + }, + "engines": { + "pnpm": ">=8" + } + }, + "node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmmirror.com/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmmirror.com/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/commander": { + "version": "7.2.0", + "resolved": "https://registry.npmmirror.com/commander/-/commander-7.2.0.tgz", + "integrity": "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==", + "license": "MIT", + "engines": { + "node": ">= 10" + } + }, + "node_modules/confbox": { + "version": "0.1.8", + "resolved": "https://registry.npmmirror.com/confbox/-/confbox-0.1.8.tgz", + "integrity": "sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/cose-base": { + "version": "1.0.3", + "resolved": "https://registry.npmmirror.com/cose-base/-/cose-base-1.0.3.tgz", + "integrity": "sha512-s9whTXInMSgAp/NVXVNuVxVKzGH2qck3aQlVHxDCdAEPgtMKwc4Wq6/QKhgdEdgbLSi9rBTAcPoRa6JpiG4ksg==", + "license": "MIT", + "dependencies": { + "layout-base": "^1.0.0" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmmirror.com/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT" + }, + "node_modules/cytoscape": { + "version": "3.34.0", + "resolved": "https://registry.npmmirror.com/cytoscape/-/cytoscape-3.34.0.tgz", + "integrity": "sha512-62rNSrioXw93uliKFBwjukeQyeWwH2PqDrTac31r2P6464u3AUvTk0xS4LVvT251g7IgkFunrI48ZEZGjywSOg==", + "license": "MIT", + "engines": { + "node": ">=0.10" + } + }, + "node_modules/cytoscape-cose-bilkent": { + "version": "4.1.0", + "resolved": "https://registry.npmmirror.com/cytoscape-cose-bilkent/-/cytoscape-cose-bilkent-4.1.0.tgz", + "integrity": "sha512-wgQlVIUJF13Quxiv5e1gstZ08rnZj2XaLHGoFMYXz7SkNfCDOOteKBE6SYRfA9WxxI/iBc3ajfDoc6hb/MRAHQ==", + "license": "MIT", + "dependencies": { + "cose-base": "^1.0.0" + }, + "peerDependencies": { + "cytoscape": "^3.2.0" + } + }, + "node_modules/cytoscape-fcose": { + "version": "2.2.0", + "resolved": "https://registry.npmmirror.com/cytoscape-fcose/-/cytoscape-fcose-2.2.0.tgz", + "integrity": "sha512-ki1/VuRIHFCzxWNrsshHYPs6L7TvLu3DL+TyIGEsRcvVERmxokbf5Gdk7mFxZnTdiGtnA4cfSmjZJMviqSuZrQ==", + "license": "MIT", + "dependencies": { + "cose-base": "^2.2.0" + }, + "peerDependencies": { + "cytoscape": "^3.2.0" + } + }, + "node_modules/cytoscape-fcose/node_modules/cose-base": { + "version": "2.2.0", + "resolved": "https://registry.npmmirror.com/cose-base/-/cose-base-2.2.0.tgz", + "integrity": "sha512-AzlgcsCbUMymkADOJtQm3wO9S3ltPfYOFD5033keQn9NJzIbtnZj+UdBJe7DYml/8TdbtHJW3j58SOnKhWY/5g==", + "license": "MIT", + "dependencies": { + "layout-base": "^2.0.0" + } + }, + "node_modules/cytoscape-fcose/node_modules/layout-base": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/layout-base/-/layout-base-2.0.1.tgz", + "integrity": "sha512-dp3s92+uNI1hWIpPGH3jK2kxE2lMjdXdr+DH8ynZHpd6PUlH6x6cbuXnoMmiNumznqaNO31xu9e79F0uuZ0JFg==", + "license": "MIT" + }, + "node_modules/d3": { + "version": "7.9.0", + "resolved": "https://registry.npmmirror.com/d3/-/d3-7.9.0.tgz", + "integrity": "sha512-e1U46jVP+w7Iut8Jt8ri1YsPOvFpg46k+K8TpCb0P+zjCkjkPnV7WzfDJzMHy1LnA+wj5pLT1wjO901gLXeEhA==", + "license": "ISC", + "dependencies": { + "d3-array": "3", + "d3-axis": "3", + "d3-brush": "3", + "d3-chord": "3", + "d3-color": "3", + "d3-contour": "4", + "d3-delaunay": "6", + "d3-dispatch": "3", + "d3-drag": "3", + "d3-dsv": "3", + "d3-ease": "3", + "d3-fetch": "3", + "d3-force": "3", + "d3-format": "3", + "d3-geo": "3", + "d3-hierarchy": "3", + "d3-interpolate": "3", + "d3-path": "3", + "d3-polygon": "3", + "d3-quadtree": "3", + "d3-random": "3", + "d3-scale": "4", + "d3-scale-chromatic": "3", + "d3-selection": "3", + "d3-shape": "3", + "d3-time": "3", + "d3-time-format": "4", + "d3-timer": "3", + "d3-transition": "3", + "d3-zoom": "3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-array": { + "version": "3.2.4", + "resolved": "https://registry.npmmirror.com/d3-array/-/d3-array-3.2.4.tgz", + "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", + "license": "ISC", + "dependencies": { + "internmap": "1 - 2" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-axis": { + "version": "3.0.0", + "resolved": "https://registry.npmmirror.com/d3-axis/-/d3-axis-3.0.0.tgz", + "integrity": "sha512-IH5tgjV4jE/GhHkRV0HiVYPDtvfjHQlQfJHs0usq7M30XcSBvOotpmH1IgkcXsO/5gEQZD43B//fc7SRT5S+xw==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-brush": { + "version": "3.0.0", + "resolved": "https://registry.npmmirror.com/d3-brush/-/d3-brush-3.0.0.tgz", + "integrity": "sha512-ALnjWlVYkXsVIGlOsuWH1+3udkYFI48Ljihfnh8FZPF2QS9o+PzGLBslO0PjzVoHLZ2KCVgAM8NVkXPJB2aNnQ==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-drag": "2 - 3", + "d3-interpolate": "1 - 3", + "d3-selection": "3", + "d3-transition": "3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-chord": { + "version": "3.0.1", + "resolved": "https://registry.npmmirror.com/d3-chord/-/d3-chord-3.0.1.tgz", + "integrity": "sha512-VE5S6TNa+j8msksl7HwjxMHDM2yNK3XCkusIlpX5kwauBfXuyLAtNg9jCp/iHH61tgI4sb6R/EIMWCqEIdjT/g==", + "license": "ISC", + "dependencies": { + "d3-path": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmmirror.com/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-contour": { + "version": "4.0.2", + "resolved": "https://registry.npmmirror.com/d3-contour/-/d3-contour-4.0.2.tgz", + "integrity": "sha512-4EzFTRIikzs47RGmdxbeUvLWtGedDUNkTcmzoeyg4sP/dvCexO47AaQL7VKy/gul85TOxw+IBgA8US2xwbToNA==", + "license": "ISC", + "dependencies": { + "d3-array": "^3.2.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-delaunay": { + "version": "6.0.4", + "resolved": "https://registry.npmmirror.com/d3-delaunay/-/d3-delaunay-6.0.4.tgz", + "integrity": "sha512-mdjtIZ1XLAM8bm/hx3WwjfHt6Sggek7qH043O8KEjDXN40xi3vx/6pYSVTwLjEgiXQTbvaouWKynLBiUZ6SK6A==", + "license": "ISC", + "dependencies": { + "delaunator": "5" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-dispatch": { + "version": "3.0.1", + "resolved": "https://registry.npmmirror.com/d3-dispatch/-/d3-dispatch-3.0.1.tgz", + "integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-drag": { + "version": "3.0.0", + "resolved": "https://registry.npmmirror.com/d3-drag/-/d3-drag-3.0.0.tgz", + "integrity": "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-selection": "3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-dsv": { + "version": "3.0.1", + "resolved": "https://registry.npmmirror.com/d3-dsv/-/d3-dsv-3.0.1.tgz", + "integrity": "sha512-UG6OvdI5afDIFP9w4G0mNq50dSOsXHJaRE8arAS5o9ApWnIElp8GZw1Dun8vP8OyHOZ/QJUKUJwxiiCCnUwm+Q==", + "license": "ISC", + "dependencies": { + "commander": "7", + "iconv-lite": "0.6", + "rw": "1" + }, + "bin": { + "csv2json": "bin/dsv2json.js", + "csv2tsv": "bin/dsv2dsv.js", + "dsv2dsv": "bin/dsv2dsv.js", + "dsv2json": "bin/dsv2json.js", + "json2csv": "bin/json2dsv.js", + "json2dsv": "bin/json2dsv.js", + "json2tsv": "bin/json2dsv.js", + "tsv2csv": "bin/dsv2dsv.js", + "tsv2json": "bin/dsv2json.js" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmmirror.com/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-fetch": { + "version": "3.0.1", + "resolved": "https://registry.npmmirror.com/d3-fetch/-/d3-fetch-3.0.1.tgz", + "integrity": "sha512-kpkQIM20n3oLVBKGg6oHrUchHM3xODkTzjMoj7aWQFq5QEM+R6E4WkzT5+tojDY7yjez8KgCBRoj4aEr99Fdqw==", + "license": "ISC", + "dependencies": { + "d3-dsv": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-force": { + "version": "3.0.0", + "resolved": "https://registry.npmmirror.com/d3-force/-/d3-force-3.0.0.tgz", + "integrity": "sha512-zxV/SsA+U4yte8051P4ECydjD/S+qeYtnaIyAs9tgHCqfguma/aAQDjo85A9Z6EKhBirHRJHXIgJUlffT4wdLg==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-quadtree": "1 - 3", + "d3-timer": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-format": { + "version": "3.1.2", + "resolved": "https://registry.npmmirror.com/d3-format/-/d3-format-3.1.2.tgz", + "integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-geo": { + "version": "3.1.1", + "resolved": "https://registry.npmmirror.com/d3-geo/-/d3-geo-3.1.1.tgz", + "integrity": "sha512-637ln3gXKXOwhalDzinUgY83KzNWZRKbYubaG+fGVuc/dxO64RRljtCTnf5ecMyE1RIdtqpkVcq0IbtU2S8j2Q==", + "license": "ISC", + "dependencies": { + "d3-array": "2.5.0 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-hierarchy": { + "version": "3.1.2", + "resolved": "https://registry.npmmirror.com/d3-hierarchy/-/d3-hierarchy-3.1.2.tgz", + "integrity": "sha512-FX/9frcub54beBdugHjDCdikxThEqjnR93Qt7PvQTOHxyiNCAlvMrHhclk3cD5VeAaq9fxmfRp+CnWw9rEMBuA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmmirror.com/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-path": { + "version": "3.1.0", + "resolved": "https://registry.npmmirror.com/d3-path/-/d3-path-3.1.0.tgz", + "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-polygon": { + "version": "3.0.1", + "resolved": "https://registry.npmmirror.com/d3-polygon/-/d3-polygon-3.0.1.tgz", + "integrity": "sha512-3vbA7vXYwfe1SYhED++fPUQlWSYTTGmFmQiany/gdbiWgU/iEyQzyymwL9SkJjFFuCS4902BSzewVGsHHmHtXg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-quadtree": { + "version": "3.0.1", + "resolved": "https://registry.npmmirror.com/d3-quadtree/-/d3-quadtree-3.0.1.tgz", + "integrity": "sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-random": { + "version": "3.0.1", + "resolved": "https://registry.npmmirror.com/d3-random/-/d3-random-3.0.1.tgz", + "integrity": "sha512-FXMe9GfxTxqd5D6jFsQ+DJ8BJS4E/fT5mqqdjovykEB2oFbTMDVdg1MGFxfQW+FBOGoB++k8swBrgwSHT1cUXQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-sankey": { + "version": "0.12.3", + "resolved": "https://registry.npmmirror.com/d3-sankey/-/d3-sankey-0.12.3.tgz", + "integrity": "sha512-nQhsBRmM19Ax5xEIPLMY9ZmJ/cDvd1BG3UVvt5h3WRxKg5zGRbvnteTyWAbzeSvlh3tW7ZEmq4VwR5mB3tutmQ==", + "license": "BSD-3-Clause", + "dependencies": { + "d3-array": "1 - 2", + "d3-shape": "^1.2.0" + } + }, + "node_modules/d3-sankey/node_modules/d3-array": { + "version": "2.12.1", + "resolved": "https://registry.npmmirror.com/d3-array/-/d3-array-2.12.1.tgz", + "integrity": "sha512-B0ErZK/66mHtEsR1TkPEEkwdy+WDesimkM5gpZr5Dsg54BiTA5RXtYW5qTLIAcekaS9xfZrzBLF/OAkB3Qn1YQ==", + "license": "BSD-3-Clause", + "dependencies": { + "internmap": "^1.0.0" + } + }, + "node_modules/d3-sankey/node_modules/d3-path": { + "version": "1.0.9", + "resolved": "https://registry.npmmirror.com/d3-path/-/d3-path-1.0.9.tgz", + "integrity": "sha512-VLaYcn81dtHVTjEHd8B+pbe9yHWpXKZUC87PzoFmsFrJqgFwDe/qxfp5MlfsfM1V5E/iVt0MmEbWQ7FVIXh/bg==", + "license": "BSD-3-Clause" + }, + "node_modules/d3-sankey/node_modules/d3-shape": { + "version": "1.3.7", + "resolved": "https://registry.npmmirror.com/d3-shape/-/d3-shape-1.3.7.tgz", + "integrity": "sha512-EUkvKjqPFUAZyOlhY5gzCxCeI0Aep04LwIRpsZ/mLFelJiUfnK56jo5JMDSE7yyP2kLSb6LtF+S5chMk7uqPqw==", + "license": "BSD-3-Clause", + "dependencies": { + "d3-path": "1" + } + }, + "node_modules/d3-sankey/node_modules/internmap": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/internmap/-/internmap-1.0.1.tgz", + "integrity": "sha512-lDB5YccMydFBtasVtxnZ3MRBHuaoE8GKsppq+EchKL2U4nK/DmEpPHNH8MZe5HkMtpSiTSOZwfN0tzYjO/lJEw==", + "license": "ISC" + }, + "node_modules/d3-scale": { + "version": "4.0.2", + "resolved": "https://registry.npmmirror.com/d3-scale/-/d3-scale-4.0.2.tgz", + "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", + "license": "ISC", + "dependencies": { + "d3-array": "2.10.0 - 3", + "d3-format": "1 - 3", + "d3-interpolate": "1.2.0 - 3", + "d3-time": "2.1.1 - 3", + "d3-time-format": "2 - 4" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-scale-chromatic": { + "version": "3.1.0", + "resolved": "https://registry.npmmirror.com/d3-scale-chromatic/-/d3-scale-chromatic-3.1.0.tgz", + "integrity": "sha512-A3s5PWiZ9YCXFye1o246KoscMWqf8BsD9eRiJ3He7C9OBaxKhAd5TFCdEx/7VbKtxxTsu//1mMJFrEt572cEyQ==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3", + "d3-interpolate": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-selection": { + "version": "3.0.0", + "resolved": "https://registry.npmmirror.com/d3-selection/-/d3-selection-3.0.0.tgz", + "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-shape": { + "version": "3.2.0", + "resolved": "https://registry.npmmirror.com/d3-shape/-/d3-shape-3.2.0.tgz", + "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==", + "license": "ISC", + "dependencies": { + "d3-path": "^3.1.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time": { + "version": "3.1.0", + "resolved": "https://registry.npmmirror.com/d3-time/-/d3-time-3.1.0.tgz", + "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", + "license": "ISC", + "dependencies": { + "d3-array": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time-format": { + "version": "4.1.0", + "resolved": "https://registry.npmmirror.com/d3-time-format/-/d3-time-format-4.1.0.tgz", + "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", + "license": "ISC", + "dependencies": { + "d3-time": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmmirror.com/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-transition": { + "version": "3.0.1", + "resolved": "https://registry.npmmirror.com/d3-transition/-/d3-transition-3.0.1.tgz", + "integrity": "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3", + "d3-dispatch": "1 - 3", + "d3-ease": "1 - 3", + "d3-interpolate": "1 - 3", + "d3-timer": "1 - 3" + }, + "engines": { + "node": ">=12" + }, + "peerDependencies": { + "d3-selection": "2 - 3" + } + }, + "node_modules/d3-zoom": { + "version": "3.0.0", + "resolved": "https://registry.npmmirror.com/d3-zoom/-/d3-zoom-3.0.0.tgz", + "integrity": "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-drag": "2 - 3", + "d3-interpolate": "1 - 3", + "d3-selection": "2 - 3", + "d3-transition": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/dagre-d3-es": { + "version": "7.0.14", + "resolved": "https://registry.npmmirror.com/dagre-d3-es/-/dagre-d3-es-7.0.14.tgz", + "integrity": "sha512-P4rFMVq9ESWqmOgK+dlXvOtLwYg0i7u0HBGJER0LZDJT2VHIPAMZ/riPxqJceWMStH5+E61QxFra9kIS3AqdMg==", + "license": "MIT", + "dependencies": { + "d3": "^7.9.0", + "lodash-es": "^4.17.21" + } + }, + "node_modules/dayjs": { + "version": "1.11.21", + "resolved": "https://registry.npmmirror.com/dayjs/-/dayjs-1.11.21.tgz", + "integrity": "sha512-98IT+HOahAisibz/yjKbzuOBwYcjJ7BCLPzARyHiyEBmRz4fatF+KPJszEHXsGYjUG234aH/cOjW1wwTbKUZlA==", + "license": "MIT" + }, + "node_modules/de-indent": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/de-indent/-/de-indent-1.0.2.tgz", + "integrity": "sha512-e/1zu3xH5MQryN2zdVaF0OrdNLUbvWxzMbi+iNA6Bky7l1RoP8a2fIbRocyHclXt/arDrrR6lL3TqFD9pMQTsg==", + "dev": true, + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmmirror.com/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/delaunator": { + "version": "5.1.0", + "resolved": "https://registry.npmmirror.com/delaunator/-/delaunator-5.1.0.tgz", + "integrity": "sha512-AGrQ4QSgssa1NGmWmLPqN5NY2KajF5MqxetNEO+o0n3ZwZZeTmt7bBnvzHWrmkZFxGgr4HdyFgelzgi06otLuQ==", + "license": "ISC", + "dependencies": { + "robust-predicates": "^3.0.2" + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmmirror.com/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/dompurify": { + "version": "3.4.12", + "resolved": "https://registry.npmmirror.com/dompurify/-/dompurify-3.4.12.tgz", + "integrity": "sha512-zQvGet8Z2sWbQhCmfFz/T5QWH2oBmjnqK3qvOjaqaNLrLEF912WamU+ohnTp0TCep/MFVHpdJuCZEdFOdTnEFg==", + "license": "(MPL-2.0 OR Apache-2.0)", + "optionalDependencies": { + "@types/trusted-types": "^2.0.7" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/entities": { + "version": "7.0.1", + "resolved": "https://registry.npmmirror.com/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmmirror.com/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmmirror.com/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmmirror.com/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-toolkit": { + "version": "1.50.0", + "resolved": "https://registry.npmmirror.com/es-toolkit/-/es-toolkit-1.50.0.tgz", + "integrity": "sha512-OyZKhUVvEep9ITEiwHn8GKnMRQIVqoSIX7WnRbkWgJkllCujilqP2rD0u979tkl8wqyc8ICwlc1UBVv/Sl1G6w==", + "license": "MIT", + "workspaces": [ + "docs", + "benchmarks", + "tests/types" + ] + }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/escape-string-regexp": { + "version": "5.0.0", + "resolved": "https://registry.npmmirror.com/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz", + "integrity": "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmmirror.com/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "license": "MIT" + }, + "node_modules/exsolve": { + "version": "1.1.0", + "resolved": "https://registry.npmmirror.com/exsolve/-/exsolve-1.1.0.tgz", + "integrity": "sha512-D+42+T12DdIlJM3uepa55qGiL3sYdLBOxIl2ifQCzCHz4c7eiolaHsi3BIqEr7JxBzxv2pYZQX9kw16ziMcEmw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-glob": { + "version": "3.3.3", + "resolved": "https://registry.npmmirror.com/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fastq": { + "version": "1.20.1", + "resolved": "https://registry.npmmirror.com/fastq/-/fastq-1.20.1.tgz", + "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", + "dev": true, + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmmirror.com/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/follow-redirects": { + "version": "1.16.0", + "resolved": "https://registry.npmmirror.com/follow-redirects/-/follow-redirects-1.16.0.tgz", + "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/form-data": { + "version": "4.0.6", + "resolved": "https://registry.npmmirror.com/form-data/-/form-data-4.0.6.tgz", + "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.4", + "mime-types": "^2.1.35" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmmirror.com/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmmirror.com/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmmirror.com/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmmirror.com/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmmirror.com/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hachure-fill": { + "version": "0.5.2", + "resolved": "https://registry.npmmirror.com/hachure-fill/-/hachure-fill-0.5.2.tgz", + "integrity": "sha512-3GKBOn+m2LX9iq+JC1064cSFprJY4jL1jCXTcpnfER5HYE2l/4EfWSGzkPa/ZDBmYI0ZOEj5VHV/eKnPGkHuOg==", + "license": "MIT" + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmmirror.com/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmmirror.com/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/he": { + "version": "1.2.0", + "resolved": "https://registry.npmmirror.com/he/-/he-1.2.0.tgz", + "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==", + "dev": true, + "license": "MIT", + "bin": { + "he": "bin/he" + } + }, + "node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmmirror.com/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "license": "MIT", + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmmirror.com/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/import-meta-resolve": { + "version": "4.2.0", + "resolved": "https://registry.npmmirror.com/import-meta-resolve/-/import-meta-resolve-4.2.0.tgz", + "integrity": "sha512-Iqv2fzaTQN28s/FwZAoFq0ZSs/7hMAHJVX+w8PZl3cY19Pxk6jFFalxQoIfW2826i/fDLXv8IiEZRIT0lDuWcg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/internmap": { + "version": "2.0.3", + "resolved": "https://registry.npmmirror.com/internmap/-/internmap-2.0.3.tgz", + "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmmirror.com/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmmirror.com/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmmirror.com/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmmirror.com/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/js-tokens": { + "version": "9.0.1", + "resolved": "https://registry.npmmirror.com/js-tokens/-/js-tokens-9.0.1.tgz", + "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/katex": { + "version": "0.16.47", + "resolved": "https://registry.npmmirror.com/katex/-/katex-0.16.47.tgz", + "integrity": "sha512-Eeo8Ys1doU1z+x8AZsPpQu+p/QcZBI5PeOo7QGQdy2x2m0MU/hYagBbGOmXwr5KVbEfVuWv9LpnQWeehogurjg==", + "funding": [ + "https://opencollective.com/katex", + "https://github.com/sponsors/katex" + ], + "license": "MIT", + "dependencies": { + "commander": "^8.3.0" + }, + "bin": { + "katex": "cli.js" + } + }, + "node_modules/katex/node_modules/commander": { + "version": "8.3.0", + "resolved": "https://registry.npmmirror.com/commander/-/commander-8.3.0.tgz", + "integrity": "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/khroma": { + "version": "2.1.0", + "resolved": "https://registry.npmmirror.com/khroma/-/khroma-2.1.0.tgz", + "integrity": "sha512-Ls993zuzfayK269Svk9hzpeGUKob/sIgZzyHYdjQoAdQetRKpOLj+k/QQQ/6Qi0Yz65mlROrfd+Ev+1+7dz9Kw==" + }, + "node_modules/layout-base": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/layout-base/-/layout-base-1.0.2.tgz", + "integrity": "sha512-8h2oVEZNktL4BH2JCOI90iD1yXwL6iNW7KcCKT2QZgQJR2vbqDsldCTPRU9NifTCqHZci57XvQQ15YTu+sTYPg==", + "license": "MIT" + }, + "node_modules/local-pkg": { + "version": "0.5.1", + "resolved": "https://registry.npmmirror.com/local-pkg/-/local-pkg-0.5.1.tgz", + "integrity": "sha512-9rrA30MRRP3gBD3HTGnC6cDFpaE1kVDWxWgqWJUN0RvDNAo+Nz/9GxB+nHOH0ifbVFy0hSA1V6vFDvnx54lTEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "mlly": "^1.7.3", + "pkg-types": "^1.2.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/lodash-es": { + "version": "4.18.1", + "resolved": "https://registry.npmmirror.com/lodash-es/-/lodash-es-4.18.1.tgz", + "integrity": "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==", + "license": "MIT" + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmmirror.com/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/marked": { + "version": "12.0.2", + "resolved": "https://registry.npmmirror.com/marked/-/marked-12.0.2.tgz", + "integrity": "sha512-qXUm7e/YKFoqFPYPa3Ukg9xlI5cyAtGmyEIzMfW//m6kXwCy2Ps9DYf5ioijFKQ8qyuscrHoY04iJGctu2Kg0Q==", + "license": "MIT", + "bin": { + "marked": "bin/marked.js" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmmirror.com/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmmirror.com/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/mermaid": { + "version": "11.16.0", + "resolved": "https://registry.npmmirror.com/mermaid/-/mermaid-11.16.0.tgz", + "integrity": "sha512-Zvm3kbstgdpvIJPPItlL7fppIZ3kibvc1oZIGxdvk9t6UFz6flv+Jw7FtRGKwfcI8OckmH04LqG6LlS6X4B1pA==", + "license": "MIT", + "dependencies": { + "@braintree/sanitize-url": "^7.1.2", + "@iconify/utils": "^3.0.2", + "@mermaid-js/parser": "^1.2.0", + "@types/d3": "^7.4.3", + "@upsetjs/venn.js": "^2.0.0", + "cytoscape": "^3.33.3", + "cytoscape-cose-bilkent": "^4.1.0", + "cytoscape-fcose": "^2.2.0", + "d3": "^7.9.0", + "d3-sankey": "^0.12.3", + "dagre-d3-es": "7.0.14", + "dayjs": "^1.11.20", + "dompurify": "^3.3.3", + "es-toolkit": "^1.45.1", + "katex": "^0.16.45", + "khroma": "^2.1.0", + "marked": "^16.3.0", + "roughjs": "^4.6.6", + "stylis": "^4.3.6", + "ts-dedent": "^2.2.0", + "uuid": "^11.1.0 || ^12 || ^13 || ^14.0.0" + } + }, + "node_modules/mermaid/node_modules/marked": { + "version": "16.4.2", + "resolved": "https://registry.npmmirror.com/marked/-/marked-16.4.2.tgz", + "integrity": "sha512-TI3V8YYWvkVf3KJe1dRkpnjs68JUPyEa5vjKrp1XEEJUAOaQc+Qj+L1qWbPd0SJuAdQkFU0h73sXXqwDYxsiDA==", + "license": "MIT", + "bin": { + "marked": "bin/marked.js" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmmirror.com/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/micromatch/node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmmirror.com/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmmirror.com/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmmirror.com/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/minimatch": { + "version": "9.0.9", + "resolved": "https://registry.npmmirror.com/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.2" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/mitt": { + "version": "3.0.1", + "resolved": "https://registry.npmmirror.com/mitt/-/mitt-3.0.1.tgz", + "integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==", + "license": "MIT" + }, + "node_modules/mlly": { + "version": "1.8.2", + "resolved": "https://registry.npmmirror.com/mlly/-/mlly-1.8.2.tgz", + "integrity": "sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA==", + "dev": true, + "license": "MIT", + "dependencies": { + "acorn": "^8.16.0", + "pathe": "^2.0.3", + "pkg-types": "^1.3.1", + "ufo": "^1.6.3" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmmirror.com/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/muggle-string": { + "version": "0.4.1", + "resolved": "https://registry.npmmirror.com/muggle-string/-/muggle-string-0.4.1.tgz", + "integrity": "sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.16", + "resolved": "https://registry.npmmirror.com/nanoid/-/nanoid-3.3.16.tgz", + "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmmirror.com/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/package-manager-detector": { + "version": "1.8.0", + "resolved": "https://registry.npmmirror.com/package-manager-detector/-/package-manager-detector-1.8.0.tgz", + "integrity": "sha512-yQA4H19AmPEoMUeavPMDIe1higySl/gH/yaQrkT/s07Qp+7pp2hYz30N3z2l5BkjVkF9Ow6o0wjJamm2y7Sn0A==", + "license": "MIT" + }, + "node_modules/path-browserify": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/path-browserify/-/path-browserify-1.0.1.tgz", + "integrity": "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==", + "dev": true, + "license": "MIT" + }, + "node_modules/path-data-parser": { + "version": "0.1.0", + "resolved": "https://registry.npmmirror.com/path-data-parser/-/path-data-parser-0.1.0.tgz", + "integrity": "sha512-NOnmBpt5Y2RWbuv0LMzsayp3lVylAHLPUTut412ZA3l+C4uw4ZVkQbjShYCQ8TCpUMdPapr4YjUqLYD6v68j+w==", + "license": "MIT" + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmmirror.com/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmmirror.com/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmmirror.com/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pinia": { + "version": "2.3.1", + "resolved": "https://registry.npmmirror.com/pinia/-/pinia-2.3.1.tgz", + "integrity": "sha512-khUlZSwt9xXCaTbbxFYBKDc/bWAGWJjOgvxETwkTN7KRm66EeT1ZdZj6i2ceh9sP2Pzqsbc704r2yngBrxBVug==", + "license": "MIT", + "dependencies": { + "@vue/devtools-api": "^6.6.3", + "vue-demi": "^0.14.10" + }, + "funding": { + "url": "https://github.com/sponsors/posva" + }, + "peerDependencies": { + "typescript": ">=4.4.4", + "vue": "^2.7.0 || ^3.5.11" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/pkg-types": { + "version": "1.3.1", + "resolved": "https://registry.npmmirror.com/pkg-types/-/pkg-types-1.3.1.tgz", + "integrity": "sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "confbox": "^0.1.8", + "mlly": "^1.7.4", + "pathe": "^2.0.1" + } + }, + "node_modules/points-on-curve": { + "version": "0.2.0", + "resolved": "https://registry.npmmirror.com/points-on-curve/-/points-on-curve-0.2.0.tgz", + "integrity": "sha512-0mYKnYYe9ZcqMCWhUjItv/oHjvgEsfKvnUTg8sAtnHr3GVy7rGkXCb6d5cSyqrWqL4k81b9CPg3urd+T7aop3A==", + "license": "MIT" + }, + "node_modules/points-on-path": { + "version": "0.2.1", + "resolved": "https://registry.npmmirror.com/points-on-path/-/points-on-path-0.2.1.tgz", + "integrity": "sha512-25ClnWWuw7JbWZcgqY/gJ4FQWadKxGWk+3kR/7kD0tCaDtPPMj7oHu2ToLaVhfpnHrZzYby2w6tUA0eOIuUg8g==", + "license": "MIT", + "dependencies": { + "path-data-parser": "0.1.0", + "points-on-curve": "0.2.0" + } + }, + "node_modules/postcss": { + "version": "8.5.23", + "resolved": "https://registry.npmmirror.com/postcss/-/postcss-8.5.23.tgz", + "integrity": "sha512-g50586zr4bZmwFiTlflMu8E0bDTb5I5gertgwAKmsdUlTQIhZtunzUlD1WSzwcVWPoAVpsrA6vlfCD7oXvRwgg==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.16", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/proxy-from-env": { + "version": "2.1.0", + "resolved": "https://registry.npmmirror.com/proxy-from-env/-/proxy-from-env-2.1.0.tgz", + "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/quansync": { + "version": "0.2.11", + "resolved": "https://registry.npmmirror.com/quansync/-/quansync-0.2.11.tgz", + "integrity": "sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/antfu" + }, + { + "type": "individual", + "url": "https://github.com/sponsors/sxzz" + } + ], + "license": "MIT" + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmmirror.com/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmmirror.com/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/readdirp/node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmmirror.com/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmmirror.com/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/robust-predicates": { + "version": "3.0.3", + "resolved": "https://registry.npmmirror.com/robust-predicates/-/robust-predicates-3.0.3.tgz", + "integrity": "sha512-NS3levdsRIUOmiJ8FZWCP7LG3QpJyrs/TE0Zpf1yvZu8cAJJ6QMW92H1c7kWpdIHo8RvmLxN/o2JXTKHp74lUA==", + "license": "Unlicense" + }, + "node_modules/rollup": { + "version": "4.62.3", + "resolved": "https://registry.npmmirror.com/rollup/-/rollup-4.62.3.tgz", + "integrity": "sha512-Gu0c0iH9FzgX1L1t7ByIbbS3Vmdz+6KHm/EsqmmC71gUQ82yvZRkTK6XzrFObSka91WUVdynqp6nsfilzr5k6Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.62.3", + "@rollup/rollup-android-arm64": "4.62.3", + "@rollup/rollup-darwin-arm64": "4.62.3", + "@rollup/rollup-darwin-x64": "4.62.3", + "@rollup/rollup-freebsd-arm64": "4.62.3", + "@rollup/rollup-freebsd-x64": "4.62.3", + "@rollup/rollup-linux-arm-gnueabihf": "4.62.3", + "@rollup/rollup-linux-arm-musleabihf": "4.62.3", + "@rollup/rollup-linux-arm64-gnu": "4.62.3", + "@rollup/rollup-linux-arm64-musl": "4.62.3", + "@rollup/rollup-linux-loong64-gnu": "4.62.3", + "@rollup/rollup-linux-loong64-musl": "4.62.3", + "@rollup/rollup-linux-ppc64-gnu": "4.62.3", + "@rollup/rollup-linux-ppc64-musl": "4.62.3", + "@rollup/rollup-linux-riscv64-gnu": "4.62.3", + "@rollup/rollup-linux-riscv64-musl": "4.62.3", + "@rollup/rollup-linux-s390x-gnu": "4.62.3", + "@rollup/rollup-linux-x64-gnu": "4.62.3", + "@rollup/rollup-linux-x64-musl": "4.62.3", + "@rollup/rollup-openbsd-x64": "4.62.3", + "@rollup/rollup-openharmony-arm64": "4.62.3", + "@rollup/rollup-win32-arm64-msvc": "4.62.3", + "@rollup/rollup-win32-ia32-msvc": "4.62.3", + "@rollup/rollup-win32-x64-gnu": "4.62.3", + "@rollup/rollup-win32-x64-msvc": "4.62.3", + "fsevents": "~2.3.2" + } + }, + "node_modules/roughjs": { + "version": "4.6.6", + "resolved": "https://registry.npmmirror.com/roughjs/-/roughjs-4.6.6.tgz", + "integrity": "sha512-ZUz/69+SYpFN/g/lUlo2FXcIjRkSu3nDarreVdGGndHEBJ6cXPdKguS8JGxwj5HA5xIbVKSmLgr5b3AWxtRfvQ==", + "license": "MIT", + "dependencies": { + "hachure-fill": "^0.5.2", + "path-data-parser": "^0.1.0", + "points-on-curve": "^0.2.0", + "points-on-path": "^0.2.1" + } + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmmirror.com/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/rw": { + "version": "1.3.3", + "resolved": "https://registry.npmmirror.com/rw/-/rw-1.3.3.tgz", + "integrity": "sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==", + "license": "BSD-3-Clause" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmmirror.com/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/scule": { + "version": "1.3.0", + "resolved": "https://registry.npmmirror.com/scule/-/scule-1.3.0.tgz", + "integrity": "sha512-6FtHJEvt+pVMIB9IBY+IcCJ6Z5f1iQnytgyfKMhDKgmzYG+TeH/wx1y3l27rshSbLiSanrR9ffZDrEsmjlQF2g==", + "dev": true, + "license": "MIT" + }, + "node_modules/sortablejs": { + "version": "1.15.7", + "resolved": "https://registry.npmmirror.com/sortablejs/-/sortablejs-1.15.7.tgz", + "integrity": "sha512-Kk8wLQPlS+yi1ZEf48a4+fzHa4yxjC30M/Sr2AnQu+f/MPwvvX9XjZ6OWejiz8crBsLwSq8GHqaxaET7u6ux0A==", + "license": "MIT" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmmirror.com/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/strip-literal": { + "version": "2.1.1", + "resolved": "https://registry.npmmirror.com/strip-literal/-/strip-literal-2.1.1.tgz", + "integrity": "sha512-631UJ6O00eNGfMiWG78ck80dfBab8X6IVFB51jZK5Icd7XAs60Z5y7QdSd/wGIklnWvRbUNloVzhOKKmutxQ6Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "js-tokens": "^9.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/stylis": { + "version": "4.4.0", + "resolved": "https://registry.npmmirror.com/stylis/-/stylis-4.4.0.tgz", + "integrity": "sha512-5Z9ZpRzfuH6l/UAvCPAPUo3665Nk2wLaZU3x+TLHKVzIz33+sbJqbtrYoC3KD4/uVOr2Zp+L0LySezP9OHV9yA==", + "license": "MIT" + }, + "node_modules/tdesign-icons-vue-next": { + "version": "0.3.7", + "resolved": "https://registry.npmmirror.com/tdesign-icons-vue-next/-/tdesign-icons-vue-next-0.3.7.tgz", + "integrity": "sha512-Q5ebVty/TCqhBa0l/17kkhjC0pBAOGvn7C35MAt1xS+johKVM9QEDOy9R6XEl332AiwQ37MwqioczqjYC30ckw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.16.3" + }, + "peerDependencies": { + "vue": "^3.0.0" + } + }, + "node_modules/tdesign-vue-next": { + "version": "1.20.5", + "resolved": "https://registry.npmmirror.com/tdesign-vue-next/-/tdesign-vue-next-1.20.5.tgz", + "integrity": "sha512-FVCmDBoXOKUDE77ZvRzpaSSs10PXbb4Tikma4wMCJHRqF9NilgkbLh4ympeOKt57wxOtOXsQfs7zU+AcCpsZXw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.22.6", + "@popperjs/core": "^2.11.8", + "@types/lodash-es": "^4.17.12", + "@types/sortablejs": "^1.15.1", + "@types/tinycolor2": "^1.4.3", + "@types/validator": "^13.7.17", + "dayjs": "^1.11.10", + "lodash-es": "^4.18.1", + "mitt": "^3.0.1", + "sortablejs": "^1.15.0", + "tdesign-icons-vue-next": "~0.4.7", + "tinycolor2": "^1.6.0", + "validator": "^13.15.23" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "vue": ">=3.1.0" + } + }, + "node_modules/tdesign-vue-next/node_modules/tdesign-icons-vue-next": { + "version": "0.4.7", + "resolved": "https://registry.npmmirror.com/tdesign-icons-vue-next/-/tdesign-icons-vue-next-0.4.7.tgz", + "integrity": "sha512-0TRVH0L9O7vAMQ7j2vU/VYBMzS5FI+OVf7bWjwAVMmWXArsAyJI8mzpNWThZok96B6hsRY+2hK/NSOFZy8hMqA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.16.5" + }, + "peerDependencies": { + "vue": "^3.0.0" + } + }, + "node_modules/tinycolor2": { + "version": "1.6.0", + "resolved": "https://registry.npmmirror.com/tinycolor2/-/tinycolor2-1.6.0.tgz", + "integrity": "sha512-XPaBkWQJdsf3pLKJV9p4qN/S+fm2Oj8AIPo1BTUhg5oxkvm9+SVEGFdhyOz7tTdUTfvxMiAs4sp6/eZO2Ew+pw==", + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.2.4", + "resolved": "https://registry.npmmirror.com/tinyexec/-/tinyexec-1.2.4.tgz", + "integrity": "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmmirror.com/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/ts-dedent": { + "version": "2.3.0", + "resolved": "https://registry.npmmirror.com/ts-dedent/-/ts-dedent-2.3.0.tgz", + "integrity": "sha512-JfJeIHke7y2egdGGgRAvpCwYFUsHlM2gPcrVOxFkznt/4uzQ7HFmvE63iFHVLBJNDuyDOQgijDK/tXH/f6Msjg==", + "license": "MIT", + "engines": { + "node": ">=6.10" + } + }, + "node_modules/typescript": { + "version": "5.6.3", + "resolved": "https://registry.npmmirror.com/typescript/-/typescript-5.6.3.tgz", + "integrity": "sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw==", + "devOptional": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/ufo": { + "version": "1.6.4", + "resolved": "https://registry.npmmirror.com/ufo/-/ufo-1.6.4.tgz", + "integrity": "sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA==", + "dev": true, + "license": "MIT" + }, + "node_modules/unimport": { + "version": "3.14.6", + "resolved": "https://registry.npmmirror.com/unimport/-/unimport-3.14.6.tgz", + "integrity": "sha512-CYvbDaTT04Rh8bmD8jz3WPmHYZRG/NnvYVzwD6V1YAlvvKROlAeNDUBhkBGzNav2RKaeuXvlWYaa1V4Lfi/O0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rollup/pluginutils": "^5.1.4", + "acorn": "^8.14.0", + "escape-string-regexp": "^5.0.0", + "estree-walker": "^3.0.3", + "fast-glob": "^3.3.3", + "local-pkg": "^1.0.0", + "magic-string": "^0.30.17", + "mlly": "^1.7.4", + "pathe": "^2.0.1", + "picomatch": "^4.0.2", + "pkg-types": "^1.3.0", + "scule": "^1.3.0", + "strip-literal": "^2.1.1", + "unplugin": "^1.16.1" + } + }, + "node_modules/unimport/node_modules/confbox": { + "version": "0.2.4", + "resolved": "https://registry.npmmirror.com/confbox/-/confbox-0.2.4.tgz", + "integrity": "sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/unimport/node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmmirror.com/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/unimport/node_modules/local-pkg": { + "version": "1.2.1", + "resolved": "https://registry.npmmirror.com/local-pkg/-/local-pkg-1.2.1.tgz", + "integrity": "sha512-++gUqRDEvcnN6Zhqrr+y/CkVEHhlrR96vZn3nZZPYzMcBUyBtTKzB9NadClFIsIVSsu+3i9tfk/erqy9kAmt7Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "mlly": "^1.7.4", + "pkg-types": "^2.3.0", + "quansync": "^0.2.11" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/unimport/node_modules/local-pkg/node_modules/pkg-types": { + "version": "2.3.1", + "resolved": "https://registry.npmmirror.com/pkg-types/-/pkg-types-2.3.1.tgz", + "integrity": "sha512-y+ichcgc2LrADuhLNAx8DFjVfgz91pRxfZdI3UDhxHvcVEZsenLO+7XaU5vOp0u/7V/wZ+plyuQxtrDlZJ+yeg==", + "dev": true, + "license": "MIT", + "dependencies": { + "confbox": "^0.2.4", + "exsolve": "^1.0.8", + "pathe": "^2.0.3" + } + }, + "node_modules/unimport/node_modules/unplugin": { + "version": "1.16.1", + "resolved": "https://registry.npmmirror.com/unplugin/-/unplugin-1.16.1.tgz", + "integrity": "sha512-4/u/j4FrCKdi17jaxuJA0jClGxB1AvU2hw/IuayPc4ay1XGaJs/rbb4v5WKwAjNifjmXK9PIFyuPiaK8azyR9w==", + "dev": true, + "license": "MIT", + "dependencies": { + "acorn": "^8.14.0", + "webpack-virtual-modules": "^0.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/unplugin": { + "version": "2.3.11", + "resolved": "https://registry.npmmirror.com/unplugin/-/unplugin-2.3.11.tgz", + "integrity": "sha512-5uKD0nqiYVzlmCRs01Fhs2BdkEgBS3SAVP6ndrBsuK42iC2+JHyxM05Rm9G8+5mkmRtzMZGY8Ct5+mliZxU/Ww==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "acorn": "^8.15.0", + "picomatch": "^4.0.3", + "webpack-virtual-modules": "^0.6.2" + }, + "engines": { + "node": ">=18.12.0" + } + }, + "node_modules/unplugin-auto-import": { + "version": "0.19.0", + "resolved": "https://registry.npmmirror.com/unplugin-auto-import/-/unplugin-auto-import-0.19.0.tgz", + "integrity": "sha512-W97gTDEWu/L1EcKCXY5Ni8bsMW1E9kv12wYQv3mYpd7zcFctXYlLKsqeva6sbCQbzS8t9AG/XdU5/WkEJKPlFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@antfu/utils": "^0.7.10", + "@rollup/pluginutils": "^5.1.3", + "local-pkg": "^0.5.1", + "magic-string": "^0.30.15", + "picomatch": "^4.0.2", + "unimport": "^3.14.5", + "unplugin": "^2.1.0" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "@nuxt/kit": "^3.2.2", + "@vueuse/core": "*" + }, + "peerDependenciesMeta": { + "@nuxt/kit": { + "optional": true + }, + "@vueuse/core": { + "optional": true + } + } + }, + "node_modules/unplugin-vue-components": { + "version": "0.28.0", + "resolved": "https://registry.npmmirror.com/unplugin-vue-components/-/unplugin-vue-components-0.28.0.tgz", + "integrity": "sha512-jiTGtJ3JsRFBjgvyilfrX7yUoGKScFgbdNw+6p6kEXU+Spf/rhxzgvdfuMcvhCcLmflB/dY3pGQshYBVGOUx7Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@antfu/utils": "^0.7.10", + "@rollup/pluginutils": "^5.1.4", + "chokidar": "^3.6.0", + "debug": "^4.4.0", + "fast-glob": "^3.3.2", + "local-pkg": "^0.5.1", + "magic-string": "^0.30.15", + "minimatch": "^9.0.5", + "mlly": "^1.7.3", + "unplugin": "^2.1.0" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "@babel/parser": "^7.15.8", + "@nuxt/kit": "^3.2.2", + "vue": "2 || 3" + }, + "peerDependenciesMeta": { + "@babel/parser": { + "optional": true + }, + "@nuxt/kit": { + "optional": true + } + } + }, + "node_modules/uuid": { + "version": "14.0.1", + "resolved": "https://registry.npmmirror.com/uuid/-/uuid-14.0.1.tgz", + "integrity": "sha512-6ZxzVpzDXDa3bJWaHilVayA+BH/1zmxCJoVgvmqJnid/gPoKHxUrS/aC/T6LGQtNHT+XHG9fXPJB4d+IrU30Ew==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist-node/bin/uuid" + } + }, + "node_modules/validator": { + "version": "13.15.35", + "resolved": "https://registry.npmmirror.com/validator/-/validator-13.15.35.tgz", + "integrity": "sha512-TQ5pAGhd5whStmqWvYF4OjQROlmv9SMFVt37qoCBdqRffuuklWYQlCNnEs2ZaIBD1kZRNnikiZOS1eqgkar0iw==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmmirror.com/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/vscode-uri": { + "version": "3.1.0", + "resolved": "https://registry.npmmirror.com/vscode-uri/-/vscode-uri-3.1.0.tgz", + "integrity": "sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/vue": { + "version": "3.5.40", + "resolved": "https://registry.npmmirror.com/vue/-/vue-3.5.40.tgz", + "integrity": "sha512-+8PJ4SJXdn/cHGImF4CKdxlWHIN5Dkt7DoufRREM6h6uVCx2m7QxgcEQmmzyOK8A9mcafg7sFbJFYsdFVubTig==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.40", + "@vue/compiler-sfc": "3.5.40", + "@vue/runtime-dom": "3.5.40", + "@vue/server-renderer": "3.5.40", + "@vue/shared": "3.5.40" + }, + "peerDependencies": { + "typescript": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/vue-demi": { + "version": "0.14.10", + "resolved": "https://registry.npmmirror.com/vue-demi/-/vue-demi-0.14.10.tgz", + "integrity": "sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==", + "hasInstallScript": true, + "license": "MIT", + "bin": { + "vue-demi-fix": "bin/vue-demi-fix.js", + "vue-demi-switch": "bin/vue-demi-switch.js" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "@vue/composition-api": "^1.0.0-rc.1", + "vue": "^3.0.0-0 || ^2.6.0" + }, + "peerDependenciesMeta": { + "@vue/composition-api": { + "optional": true + } + } + }, + "node_modules/vue-router": { + "version": "4.6.4", + "resolved": "https://registry.npmmirror.com/vue-router/-/vue-router-4.6.4.tgz", + "integrity": "sha512-Hz9q5sa33Yhduglwz6g9skT8OBPii+4bFn88w6J+J4MfEo4KRRpmiNG/hHHkdbRFlLBOqxN8y8gf2Fb0MTUgVg==", + "license": "MIT", + "dependencies": { + "@vue/devtools-api": "^6.6.4" + }, + "funding": { + "url": "https://github.com/sponsors/posva" + }, + "peerDependencies": { + "vue": "^3.5.0" + } + }, + "node_modules/vue-tsc": { + "version": "2.2.12", + "resolved": "https://registry.npmmirror.com/vue-tsc/-/vue-tsc-2.2.12.tgz", + "integrity": "sha512-P7OP77b2h/Pmk+lZdJ0YWs+5tJ6J2+uOQPo7tlBnY44QqQSPYvS0qVT4wqDJgwrZaLe47etJLLQRFia71GYITw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/typescript": "2.4.15", + "@vue/language-core": "2.2.12" + }, + "bin": { + "vue-tsc": "bin/vue-tsc.js" + }, + "peerDependencies": { + "typescript": ">=5.0.0" + } + }, + "node_modules/webpack-virtual-modules": { + "version": "0.6.2", + "resolved": "https://registry.npmmirror.com/webpack-virtual-modules/-/webpack-virtual-modules-0.6.2.tgz", + "integrity": "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==", + "dev": true, + "license": "MIT" + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..b5ac6d2 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,33 @@ +{ + "name": "support-bot-admin", + "private": true, + "version": "1.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "postbuild": "node cp-to-static.mjs", + "preview": "vite preview" + }, + "dependencies": { + "axios": "^1.7.9", + "chart.js": "^4.4.7", + "dompurify": "^3.2.4", + "marked": "^12.0.2", + "mermaid": "^11.4.1", + "pinia": "^2.3.1", + "tdesign-icons-vue-next": "^0.3.6", + "tdesign-vue-next": "^1.10.6", + "vue": "^3.5.13", + "vue-router": "^4.5.0" + }, + "devDependencies": { + "@types/dompurify": "^3.2.0", + "@vitejs/plugin-vue": "^5.2.1", + "typescript": "~5.6.3", + "unplugin-auto-import": "^0.19.0", + "unplugin-vue-components": "^0.28.0", + "vite": "^5.4.21", + "vue-tsc": "^2.2.0" + } +} diff --git a/frontend/public/favicon.svg b/frontend/public/favicon.svg new file mode 100644 index 0000000..7c16b21 --- /dev/null +++ b/frontend/public/favicon.svg @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/src/App.vue b/frontend/src/App.vue new file mode 100644 index 0000000..3679acc --- /dev/null +++ b/frontend/src/App.vue @@ -0,0 +1,48 @@ + + + diff --git a/frontend/src/api/account.ts b/frontend/src/api/account.ts new file mode 100644 index 0000000..eeeb88f --- /dev/null +++ b/frontend/src/api/account.ts @@ -0,0 +1,8 @@ +import request from './request' +import type { ApiResponse } from '@/types/api' + +export function getAccountList(): Promise { return request.get('/account/list').then(r => r.data) } +export function getAllAccounts(): Promise { return request.get('/account/all').then(r => r.data) } +export function createAccount(account: any): Promise { return request.post('/account', account).then(r => r.data) } +export function updateAccount(accountId: string, account: any): Promise { return request.put(`/account/${accountId}`, account).then(r => r.data) } +export function deleteAccount(accountId: string): Promise { return request.delete(`/account/${accountId}`).then(r => r.data) } diff --git a/frontend/src/api/api-key.ts b/frontend/src/api/api-key.ts new file mode 100644 index 0000000..151d6a8 --- /dev/null +++ b/frontend/src/api/api-key.ts @@ -0,0 +1,13 @@ +import request from './request' +import type { ApiResponse } from '@/types/api' + +export function listApiKeys(page = 1, size = 20): Promise { + return request.get(`/api-key/list?page=${page}&size=${size}`).then(r => r.data) +} +export function createApiKey(data: any): Promise { return request.post('/api-key', data).then(r => r.data) } +export function revokeApiKey(id: string): Promise { return request.put(`/api-key/${id}/revoke`).then(r => r.data) } +export function enableApiKey(id: string): Promise { return request.put(`/api-key/${id}/enable`).then(r => r.data) } +export function deleteApiKey(id: string): Promise { return request.delete(`/api-key/${id}`).then(r => r.data) } +export function updateApiKeyRoles(id: string, roleIds: string[]): Promise { + return request.put(`/api-key/${id}/roles`, { roleIds }).then(r => r.data) +} diff --git a/frontend/src/api/auth.ts b/frontend/src/api/auth.ts new file mode 100644 index 0000000..8bec31d --- /dev/null +++ b/frontend/src/api/auth.ts @@ -0,0 +1,17 @@ +import request from './request' +import type { ApiResponse, TokenResponse } from '@/types/api' + +/** 用户登录 */ +export function login(username: string, password: string): Promise> { + return request.post('/auth/login', { username, password }).then(r => r.data) +} + +/** 刷新 Token */ +export function refreshToken(refreshToken: string): Promise> { + return request.post('/auth/refresh', { refreshToken }).then(r => r.data) +} + +/** 获取当前登录用户信息 */ +export function getCurrentUser(): Promise> { + return request.get('/auth/me').then(r => r.data) +} diff --git a/frontend/src/api/category.ts b/frontend/src/api/category.ts new file mode 100644 index 0000000..0d9a54a --- /dev/null +++ b/frontend/src/api/category.ts @@ -0,0 +1,8 @@ +import request from './request' +import type { ApiResponse } from '@/types/api' + +export function getCategoryTree(): Promise { return request.get('/category/tree').then(r => r.data) } +export function getCategoryList(): Promise { return request.get('/category/list').then(r => r.data) } +export function createCategory(data: any): Promise { return request.post('/category', data).then(r => r.data) } +export function updateCategory(id: string, data: any): Promise { return request.put(`/category/${id}`, data).then(r => r.data) } +export function deleteCategory(id: string): Promise { return request.delete(`/category/${id}`).then(r => r.data) } diff --git a/frontend/src/api/chat.ts b/frontend/src/api/chat.ts new file mode 100644 index 0000000..c3b12e9 --- /dev/null +++ b/frontend/src/api/chat.ts @@ -0,0 +1,53 @@ +import request from './request' +import { getToken } from '@/utils/token' +import type { ApiResponse } from '@/types/api' + +const API_BASE = '' + +/** 构建带认证的 fetch 请求头 */ +function authHeaders(): Record { + const token = getToken() + const headers: Record = {} + if (token) headers['Authorization'] = `Bearer ${token}` + return headers +} + +/** 同步对话 */ +export function chatSync(message: string, chatId: string, roleId?: string, accountId?: string): Promise { + let url = `${API_BASE}/ai/assistant_app/chat/sync?message=${encodeURIComponent(message)}&chatId=${encodeURIComponent(chatId)}` + if (roleId) url += `&roleId=${encodeURIComponent(roleId)}` + if (accountId) url += `&accountId=${encodeURIComponent(accountId)}` + return fetch(url, { headers: authHeaders() }).then(res => res.text()) +} + +/** RAG 同步对话 */ +export function chatRagSync(message: string, chatId: string, strategy: string, roleId?: string, accountId?: string): Promise { + let url = `${API_BASE}/ai/assistant_app/chat/rag/sync?message=${encodeURIComponent(message)}&chatId=${encodeURIComponent(chatId)}&rewriteStrategy=${encodeURIComponent(strategy)}` + if (roleId) url += `&roleId=${encodeURIComponent(roleId)}` + if (accountId) url += `&accountId=${encodeURIComponent(accountId)}` + return fetch(url, { headers: authHeaders() }).then(res => res.text()) +} + +/** 获取普通 SSE 流式对话 URL */ +export function chatSSEUrl(message: string, chatId: string, roleId?: string, accountId?: string): string { + let url = `${API_BASE}/ai/assistant_app/chat/sse?message=${encodeURIComponent(message)}&chatId=${encodeURIComponent(chatId)}` + if (roleId) url += `&roleId=${encodeURIComponent(roleId)}` + if (accountId) url += `&accountId=${encodeURIComponent(accountId)}` + return url +} + +/** 获取 RAG 流式对话 URL */ +export function chatRagSSEUrl(message: string, chatId: string, strategy: string, roleId?: string, accountId?: string): string { + let url = `${API_BASE}/ai/assistant_app/chat/rag/sse?message=${encodeURIComponent(message)}&chatId=${encodeURIComponent(chatId)}&rewriteStrategy=${encodeURIComponent(strategy)}` + if (roleId) url += `&roleId=${encodeURIComponent(roleId)}` + if (accountId) url += `&accountId=${encodeURIComponent(accountId)}` + return url +} + +/** 获取 RAG 回答命中的知识库片段 */ +export function ragSources(message: string, chatId: string, strategy: string, roleId?: string, accountId?: string): Promise { + let path = `/ai/assistant_app/rag/sources?message=${encodeURIComponent(message)}&chatId=${encodeURIComponent(chatId)}&rewriteStrategy=${encodeURIComponent(strategy)}` + if (roleId) path += `&roleId=${encodeURIComponent(roleId)}` + if (accountId) path += `&accountId=${encodeURIComponent(accountId)}` + return request.get(path).then(r => r.data) +} diff --git a/frontend/src/api/conversation.ts b/frontend/src/api/conversation.ts new file mode 100644 index 0000000..c73069c --- /dev/null +++ b/frontend/src/api/conversation.ts @@ -0,0 +1,21 @@ +import request from './request' +import type { ApiResponse } from '@/types/api' + +export function listConversations(page = 1, size = 10, keyword?: string, accountId?: string, roleId?: string): Promise { + let path = `/conversation/list?page=${page}&size=${size}` + if (keyword) path += `&keyword=${encodeURIComponent(keyword)}` + if (accountId) path += `&accountId=${encodeURIComponent(accountId)}` + if (roleId) path += `&roleId=${encodeURIComponent(roleId)}` + return request.get(path).then(r => r.data) +} +export function getConversationDetail(conversationId: string): Promise { return request.get(`/conversation/${conversationId}`).then(r => r.data) } +export function getConversationMessages(conversationId: string): Promise { return request.get(`/conversation/${conversationId}/messages`).then(r => r.data) } +export function deleteConversation(conversationId: string): Promise { return request.delete(`/conversation/${conversationId}`).then(r => r.data) } +export function exportConversation(conversationId: string): Promise { + const token = localStorage.getItem('sb_token') + return fetch(`/conversation/${conversationId}/export`, { headers: token ? { Authorization: `Bearer ${token}` } : {} }).then(res => res.text()) +} +export function getConversationStats(): Promise { return request.get('/conversation/stats').then(r => r.data) } +export function truncateConversation(conversationId: string, userTurn: number): Promise { + return request.post(`/conversation/${conversationId}/truncate`, { userTurn }).then(r => r.data) +} diff --git a/frontend/src/api/dashboard.ts b/frontend/src/api/dashboard.ts new file mode 100644 index 0000000..231623c --- /dev/null +++ b/frontend/src/api/dashboard.ts @@ -0,0 +1,9 @@ +import request from './request' +import type { ApiResponse } from '@/types/api' + +export function getDashboardOverview(): Promise { return request.get('/dashboard/overview').then(r => r.data) } +export function getDashboardTrend(days = 7): Promise { return request.get(`/dashboard/trend?days=${days}`).then(r => r.data) } +export function getDashboardKnowledge(): Promise { return request.get('/dashboard/knowledge-analysis').then(r => r.data) } +export function getDashboardCustom(startDate: string, endDate: string): Promise { + return request.get(`/dashboard/custom?startDate=${encodeURIComponent(startDate)}&endDate=${encodeURIComponent(endDate)}`).then(r => r.data) +} diff --git a/frontend/src/api/document.ts b/frontend/src/api/document.ts new file mode 100644 index 0000000..53167ec --- /dev/null +++ b/frontend/src/api/document.ts @@ -0,0 +1,134 @@ +import request from './request' +import type { ApiResponse } from '@/types/api' +import { getToken } from '@/utils/token' + +/** Token 相关辅助 —— 旧版 api.js 中 handleUnauthorized 行为已由 request.ts 拦截器接管 */ + +function authHeaders(): Record { + const token = getToken() + return token ? { Authorization: `Bearer ${token}` } : {} +} + +// ==================== 文档 CRUD ==================== + +/** 文档列表(分页 + 过滤 + 搜索) */ +export function listDocuments(page = 1, size = 10, categoryId?: string, status?: string, keyword?: string, tag?: string): Promise { + let path = `/document/list?page=${page}&size=${size}` + if (categoryId) path += `&categoryId=${categoryId}` + if (status) path += `&status=${status}` + if (keyword) path += `&keyword=${encodeURIComponent(keyword)}` + if (tag) path += `&tag=${encodeURIComponent(tag)}` + return request.get(path).then(r => r.data) +} + +/** 文档详情 */ +export function getDocumentDetail(id: string): Promise { + return request.get(`/document/${id}`).then(r => r.data) +} + +/** 文档分块列表 */ +export function getDocumentChunks(id: string): Promise { + return request.get(`/document/${id}/chunks`).then(r => r.data) +} + +/** 下载文档原始文件 */ +export async function downloadDocument(id: string): Promise { + const response = await fetch(`/document/download/${id}`, { headers: authHeaders() }) + if (!response.ok) { + const error = await response.json().catch(() => ({ message: '下载失败' })) + throw new Error(error.message || `HTTP ${response.status}`) + } + const disposition = response.headers.get('Content-Disposition') + let filename = 'download' + if (disposition) { + const utf8Match = disposition.match(/filename\*=UTF-8''(.+?)(?:;|$)/i) + if (utf8Match) { + filename = decodeURIComponent(utf8Match[1]) + } else { + const quotedMatch = disposition.match(/filename="(.+?)"/i) + if (quotedMatch) filename = decodeURIComponent(quotedMatch[1]) + } + } + const blob = await response.blob() + const url = window.URL.createObjectURL(blob) + const a = document.createElement('a') + a.href = url + a.download = filename + document.body.appendChild(a) + a.click() + document.body.removeChild(a) + window.URL.revokeObjectURL(url) +} + +/** 删除文档 */ +export function deleteDocument(id: string): Promise { + return request.delete(`/document/${id}`).then(r => r.data) +} + +/** 更新文档元信息 */ +export function updateDocument(id: string, title: string, categoryId: string, tags?: string[]): Promise { + return request.put(`/document/${id}`, { title, categoryId, tags }).then(r => r.data) +} + +/** 重新处理文档 */ +export function reprocessDocument(id: string): Promise { + return request.put(`/document/${id}/reprocess`).then(r => r.data) +} + +/** 批量删除文档 */ +export function batchDeleteDocuments(ids: string[]): Promise { + return request.post('/document/batch/delete', { ids }).then(r => r.data) +} + +/** 批量重新处理文档 */ +export function batchReprocessDocuments(ids: string[]): Promise { + return request.post('/document/batch/reprocess', { ids }).then(r => r.data) +} + +/** 切换文档启用/禁用 */ +export function toggleDocument(id: string): Promise { + return request.put(`/document/${id}/toggle`).then(r => r.data) +} + +/** 批量启用/禁用 */ +export function batchToggleDocuments(ids: string[], enabled: boolean): Promise { + return request.post('/document/batch/toggle', { ids, enabled }).then(r => r.data) +} + +/** 批量移动分类 */ +export function batchMoveDocuments(ids: string[], categoryId: string): Promise { + return request.post('/document/batch/move', { ids, categoryId }).then(r => r.data) +} + +/** 更新单个分块内容 */ +export function updateChunk(docId: string, chunkIndex: number, content: string): Promise { + return request.put(`/document/${docId}/chunk/${chunkIndex}`, { content }).then(r => r.data) +} + +/** 删除单个分块 */ +export function deleteChunk(docId: string, chunkIndex: number): Promise { + return request.delete(`/document/${docId}/chunk/${chunkIndex}`).then(r => r.data) +} + +/** 语义搜索 */ +export function searchDocuments(query: string, topK?: number, similarityThreshold?: number, categoryId?: string, categoryIds?: string[], searchMode?: string): Promise { + const body: any = { query, topK, similarityThreshold } + if (categoryIds && categoryIds.length) body.categoryIds = categoryIds + if (categoryId) body.categoryId = categoryId + if (searchMode) body.searchMode = searchMode + return request.post('/document/search', body).then(r => r.data) +} + +// ==================== 统计 ==================== + +/** 知识库统计 */ +export function getStats(): Promise { + return request.get('/document/stats').then(r => r.data) +} + +// ==================== 标签 ==================== + +/** 获取标签列表 */ +export function getTagList(): Promise { + return request.get('/tag/list').then(r => r.data) +} diff --git a/frontend/src/api/faq.ts b/frontend/src/api/faq.ts new file mode 100644 index 0000000..f4c80f5 --- /dev/null +++ b/frontend/src/api/faq.ts @@ -0,0 +1,17 @@ +import request from './request' +import type { ApiResponse } from '@/types/api' + +export function listFaqs(page = 1, size = 20, keyword?: string, categoryId?: string, status?: string): Promise { + let path = `/faq/list?page=${page}&size=${size}` + if (keyword) path += `&keyword=${encodeURIComponent(keyword)}` + if (categoryId) path += `&categoryId=${categoryId}` + if (status) path += `&status=${encodeURIComponent(status)}` + return request.get(path).then(r => r.data) +} +export function createFaq(data: any): Promise { return request.post('/faq', data).then(r => r.data) } +export function updateFaq(id: string, data: any): Promise { return request.put(`/faq/${id}`, data).then(r => r.data) } +export function deleteFaq(id: string): Promise { return request.delete(`/faq/${id}`).then(r => r.data) } +export function toggleFaqStatus(id: string, status: string): Promise { return request.put(`/faq/${id}/toggle?status=${encodeURIComponent(status)}`).then(r => r.data) } +export function batchImportFaqs(data: { faqs: any[] }): Promise { return request.post('/faq/batch-import', data).then(r => r.data) } +export function exportFaqs(): Promise { return request.get('/faq/export').then(r => r.data) } +export function getFaqStats(): Promise { return request.get('/faq/stats').then(r => r.data) } diff --git a/frontend/src/api/feedback.ts b/frontend/src/api/feedback.ts new file mode 100644 index 0000000..ed5d8e8 --- /dev/null +++ b/frontend/src/api/feedback.ts @@ -0,0 +1,14 @@ +import request from './request' +import type { ApiResponse } from '@/types/api' + +export function submitFeedback(feedback: any): Promise { return request.post('/feedback', feedback).then(r => r.data) } +export function getFeedbackStats(startDate?: string, endDate?: string): Promise { + let path = '/feedback/stats' + const params: string[] = [] + if (startDate) params.push(`startDate=${encodeURIComponent(startDate)}`) + if (endDate) params.push(`endDate=${encodeURIComponent(endDate)}`) + if (params.length) path += '?' + params.join('&') + return request.get(path).then(r => r.data) +} +export function getFeedbackByConversation(conversationId: string): Promise { return request.get(`/feedback/by-conversation/${encodeURIComponent(conversationId)}`).then(r => r.data) } +export function getFeedbackBatch(messageIds: string[]): Promise { return request.get(`/feedback/batch?messageIds=${encodeURIComponent(messageIds.join(','))}`).then(r => r.data) } diff --git a/frontend/src/api/mcp-server.ts b/frontend/src/api/mcp-server.ts new file mode 100644 index 0000000..14f9f14 --- /dev/null +++ b/frontend/src/api/mcp-server.ts @@ -0,0 +1,11 @@ +import request from './request' +import type { ApiResponse } from '@/types/api' + +export function listMcpServers(page = 1, size = 10): Promise { return request.get(`/mcp-server/list?page=${page}&size=${size}`).then(r => r.data) } +export function getMcpServerDetail(id: string): Promise { return request.get(`/mcp-server/${id}`).then(r => r.data) } +export function createMcpServer(data: any): Promise { return request.post('/mcp-server', data).then(r => r.data) } +export function updateMcpServer(id: string, data: any): Promise { return request.put(`/mcp-server/${id}`, data).then(r => r.data) } +export function deleteMcpServer(id: string): Promise { return request.delete(`/mcp-server/${id}`).then(r => r.data) } +export function toggleMcpServer(id: string, active: boolean): Promise { return request.put(`/mcp-server/${id}/toggle?active=${active}`).then(r => r.data) } +export function testMcpServer(id: string): Promise { return request.post(`/mcp-server/${id}/test`, {}).then(r => r.data) } +export function refreshMcpServers(): Promise { return request.post('/mcp-server/refresh', {}).then(r => r.data) } diff --git a/frontend/src/api/model-config.ts b/frontend/src/api/model-config.ts new file mode 100644 index 0000000..17c78d9 --- /dev/null +++ b/frontend/src/api/model-config.ts @@ -0,0 +1,23 @@ +import request from './request' +import type { ApiResponse } from '@/types/api' + +export function listModelConfigs(page = 1, size = 10, appType?: string): Promise { + let path = `/model-config/list?page=${page}&size=${size}` + if (appType) path += `&appType=${encodeURIComponent(appType)}` + return request.get(path).then(r => r.data) +} +export function getModelConfigDetail(id: string): Promise { return request.get(`/model-config/${id}`).then(r => r.data) } +export function getActiveModelConfig(appType: string): Promise { return request.get(`/model-config/active/${encodeURIComponent(appType)}`).then(r => r.data) } +export function createModelConfig(data: any): Promise { return request.post('/model-config', data).then(r => r.data) } +export function updateModelConfig(id: string, data: any): Promise { return request.put(`/model-config/${id}`, data).then(r => r.data) } +export function activateModelConfig(id: string): Promise { return request.put(`/model-config/${id}/activate`).then(r => r.data) } +export function deleteModelConfig(id: string): Promise { return request.delete(`/model-config/${id}`).then(r => r.data) } +export function testModelConfig(id: string): Promise { return request.post(`/model-config/${id}/test`, {}).then(r => r.data) } +export function duplicateModelConfig(id: string): Promise { return request.post(`/model-config/${id}/duplicate`, {}).then(r => r.data) } +export function getHealthStatus(): Promise { return request.get('/model-config/health').then(r => r.data) } +export function manualHealthCheck(id: string): Promise { return request.post(`/model-config/${id}/health-check`, {}).then(r => r.data) } +export function updateModelConfigPriority(id: string, priority: number): Promise { return request.put(`/model-config/${id}/priority`, { priority }).then(r => r.data) } +export function deactivateModelConfig(id: string): Promise { return request.put(`/model-config/${id}/deactivate`).then(r => r.data) } +export function exportModelConfigs(): Promise { return request.get('/model-config/export').then(r => r.data) } +export function importModelConfigs(configs: any[], onConflict = 'skip'): Promise { return request.post('/model-config/import', { configs, onConflict }).then(r => r.data) } +export function fetchModels(baseUrl: string, apiKey: string, provider?: string): Promise { return request.post('/model-config/fetch-models', { baseUrl, apiKey, provider }).then(r => r.data) } diff --git a/frontend/src/api/request.ts b/frontend/src/api/request.ts new file mode 100644 index 0000000..d38101d --- /dev/null +++ b/frontend/src/api/request.ts @@ -0,0 +1,41 @@ +/** + * Axios 实例 + 请求/响应拦截器 + * 替代旧 api.js 中的 fetch 封装 + */ +import axios from 'axios' +import { getToken } from '@/utils/token' + +/** 同源部署,API 基址为空 */ +const request = axios.create({ + baseURL: '', + timeout: 30000, + headers: { 'Content-Type': 'application/json' }, +}) + +/** 请求拦截器:自动附加 Bearer Token */ +request.interceptors.request.use((config) => { + const token = getToken() + if (token) { + config.headers.Authorization = `Bearer ${token}` + } + return config +}) + +/** + * 响应拦截器:401 触发 auth:unauthorized 事件 + * 与旧版 app.js 中的 Token 刷新逻辑一致 + */ +request.interceptors.response.use( + (response) => response, + (error) => { + if (error.response?.status === 401) { + localStorage.removeItem('sb_token') + localStorage.removeItem('sb_user') + window.dispatchEvent(new Event('auth:unauthorized')) + } + const message = error.response?.data?.message || error.message || '网络错误' + return Promise.reject(new Error(message)) + } +) + +export default request diff --git a/frontend/src/api/role.ts b/frontend/src/api/role.ts new file mode 100644 index 0000000..5351cdb --- /dev/null +++ b/frontend/src/api/role.ts @@ -0,0 +1,11 @@ +import request from './request' +import type { ApiResponse } from '@/types/api' + +export function getRoleList(): Promise { return request.get('/role/list').then(r => r.data) } +export function getAllRoles(): Promise { return request.get('/role/all').then(r => r.data) } +export function createRole(role: any): Promise { return request.post('/role', role).then(r => r.data) } +export function updateRole(roleId: string, role: any): Promise { return request.put(`/role/${roleId}`, role).then(r => r.data) } +export function deleteRole(roleId: string): Promise { return request.delete(`/role/${roleId}`).then(r => r.data) } +export function updateRoleCategories(roleId: string, categoryIds: string[]): Promise { + return request.put(`/role/${roleId}/categories`, { categoryIds }).then(r => r.data) +} diff --git a/frontend/src/api/sensitive-word.ts b/frontend/src/api/sensitive-word.ts new file mode 100644 index 0000000..1fbf2cc --- /dev/null +++ b/frontend/src/api/sensitive-word.ts @@ -0,0 +1,18 @@ +import request from './request' +import type { ApiResponse } from '@/types/api' + +export function listSensitiveWords(page = 1, size = 20, keyword?: string, category?: string): Promise { + let path = `/sensitive-word/list?page=${page}&size=${size}` + if (keyword) path += `&keyword=${encodeURIComponent(keyword)}` + if (category) path += `&category=${encodeURIComponent(category)}` + return request.get(path).then(r => r.data) +} +export function createSensitiveWord(data: any): Promise { return request.post('/sensitive-word', data).then(r => r.data) } +export function updateSensitiveWord(id: string, data: any): Promise { return request.put(`/sensitive-word/${id}`, data).then(r => r.data) } +export function deleteSensitiveWord(id: string): Promise { return request.delete(`/sensitive-word/${id}`).then(r => r.data) } +export function batchImportSensitiveWords(data: { words: string[]; category: string; level: number }): Promise { return request.post('/sensitive-word/batch-import', data).then(r => r.data) } +export function listAuditLogs(page = 1, size = 20, sessionId?: string): Promise { + let path = `/sensitive-word/audit-log?page=${page}&size=${size}` + if (sessionId) path += `&sessionId=${encodeURIComponent(sessionId)}` + return request.get(path).then(r => r.data) +} diff --git a/frontend/src/api/system-config.ts b/frontend/src/api/system-config.ts new file mode 100644 index 0000000..df969c0 --- /dev/null +++ b/frontend/src/api/system-config.ts @@ -0,0 +1,9 @@ +import request from './request' +import type { ApiResponse } from '@/types/api' + +export function getSystemConfig(key: string): Promise { return request.get(`/system-config/${key}`).then(r => r.data) } +export function listSystemConfigs(): Promise { return request.get('/system-config/list').then(r => r.data) } +export function updateSystemConfig(key: string, configValue: string, description?: string): Promise { + return request.put(`/system-config/${key}`, { configValue, description }).then(r => r.data) +} +export function deleteSystemConfig(key: string): Promise { return request.delete(`/system-config/${key}`).then(r => r.data) } diff --git a/frontend/src/api/upload.ts b/frontend/src/api/upload.ts new file mode 100644 index 0000000..483c033 --- /dev/null +++ b/frontend/src/api/upload.ts @@ -0,0 +1,49 @@ +import { getToken } from '@/utils/token' +import type { ApiResponse } from '@/types/api' + +/** XMLHttpRequest 上传(带进度回调)—— 保留旧版 api.js 的核心实现 */ +function postFormWithProgress(path: string, formData: FormData, onProgress?: (pct: number) => void): Promise { + return new Promise((resolve, reject) => { + const xhr = new XMLHttpRequest() + xhr.open('POST', path) + const token = getToken() + if (token) xhr.setRequestHeader('Authorization', 'Bearer ' + token) + xhr.upload.addEventListener('progress', (e) => { + if (e.lengthComputable && onProgress) { + onProgress(Math.round((e.loaded / e.total) * 100)) + } + }) + xhr.addEventListener('load', () => { + if (xhr.status === 401) { localStorage.removeItem('sb_token'); localStorage.removeItem('sb_user'); window.dispatchEvent(new Event('auth:unauthorized')); reject(new Error('401')); return } + try { resolve(JSON.parse(xhr.responseText)) } catch { reject(new Error('响应解析失败')) } + }) + xhr.addEventListener('error', () => reject(new Error('网络错误'))) + xhr.addEventListener('abort', () => reject(new Error('上传已取消'))) + xhr.send(formData) + }) +} + +/** 上传文件(带进度) */ +export function uploadFile(formData: FormData, onProgress?: (pct: number) => void): Promise { + return postFormWithProgress('/upload/file', formData, onProgress) +} + +/** 上传 Markdown(带进度) */ +export function uploadMarkdown(formData: FormData, onProgress?: (pct: number) => void): Promise { + return postFormWithProgress('/upload/markdown', formData, onProgress) +} + +/** 上传 JSON 基本方式 */ +export function uploadJsonBasic(formData: FormData, onProgress?: (pct: number) => void): Promise { + return postFormWithProgress('/upload/json/basic', formData, onProgress) +} + +/** 上传 JSON 按字段提取 */ +export function uploadJsonFields(formData: FormData, fields: string, onProgress?: (pct: number) => void): Promise { + return postFormWithProgress(`/upload/json/fields?fields=${encodeURIComponent(fields)}`, formData, onProgress) +} + +/** 上传 JSON 按指针拆分 */ +export function uploadJsonPointer(formData: FormData, pointer: string, onProgress?: (pct: number) => void): Promise { + return postFormWithProgress(`/upload/json/pointer?pointer=${encodeURIComponent(pointer)}`, formData, onProgress) +} diff --git a/frontend/src/api/user.ts b/frontend/src/api/user.ts new file mode 100644 index 0000000..d2b7ede --- /dev/null +++ b/frontend/src/api/user.ts @@ -0,0 +1,19 @@ +import request from './request' +import type { ApiResponse } from '@/types/api' + +export function listSysUsers(page = 1, size = 20, keyword?: string, enabled?: boolean): Promise { + let path = `/sys-user/list?page=${page}&size=${size}` + if (keyword) path += `&keyword=${encodeURIComponent(keyword)}` + if (enabled !== undefined && enabled !== null) path += `&enabled=${enabled}` + return request.get(path).then(r => r.data) +} +export function getSysUserDetail(id: string): Promise { return request.get(`/sys-user/${id}`).then(r => r.data) } +export function createSysUser(data: any): Promise { return request.post('/sys-user', data).then(r => r.data) } +export function updateSysUser(id: string, data: any): Promise { return request.put(`/sys-user/${id}`, data).then(r => r.data) } +export function toggleSysUser(id: string, enabled: boolean): Promise { return request.put(`/sys-user/${id}/toggle?enabled=${enabled}`).then(r => r.data) } +export function assignSysUserRoles(id: string, roleIds: string[]): Promise { return request.put(`/sys-user/${id}/roles`, { roleIds }).then(r => r.data) } +export function changeSysUserPassword(id: string, newPassword: string): Promise { return request.put(`/sys-user/${id}/password`, { newPassword }).then(r => r.data) } +export function getAllSysRoles(): Promise { return request.get('/sys-user/roles').then(r => r.data) } +export function listSysRoles(): Promise { return request.get('/sys-role/list').then(r => r.data) } +export function createSysRole(data: any): Promise { return request.post('/sys-role', data).then(r => r.data) } +export function updateSysRole(id: string, data: any): Promise { return request.put(`/sys-role/${id}`, data).then(r => r.data) } diff --git a/frontend/src/api/webhook.ts b/frontend/src/api/webhook.ts new file mode 100644 index 0000000..c06a670 --- /dev/null +++ b/frontend/src/api/webhook.ts @@ -0,0 +1,8 @@ +import request from './request' +import type { ApiResponse } from '@/types/api' + +export function listWebhooks(page = 1, size = 20): Promise { return request.get(`/webhook/list?page=${page}&size=${size}`).then(r => r.data) } +export function createWebhook(data: any): Promise { return request.post('/webhook', data).then(r => r.data) } +export function updateWebhook(id: string, data: any): Promise { return request.put(`/webhook/${id}`, data).then(r => r.data) } +export function deleteWebhook(id: string): Promise { return request.delete(`/webhook/${id}`).then(r => r.data) } +export function testWebhook(id: string): Promise { return request.post(`/webhook/${id}/test`, {}).then(r => r.data) } diff --git a/frontend/src/components/FormDialog.vue b/frontend/src/components/FormDialog.vue new file mode 100644 index 0000000..8261651 --- /dev/null +++ b/frontend/src/components/FormDialog.vue @@ -0,0 +1,37 @@ + + + diff --git a/frontend/src/components/MessageSources.vue b/frontend/src/components/MessageSources.vue new file mode 100644 index 0000000..d76c7aa --- /dev/null +++ b/frontend/src/components/MessageSources.vue @@ -0,0 +1,47 @@ + + + + + diff --git a/frontend/src/composables/useConfirm.ts b/frontend/src/composables/useConfirm.ts new file mode 100644 index 0000000..d95967f --- /dev/null +++ b/frontend/src/composables/useConfirm.ts @@ -0,0 +1,38 @@ +/** + * 统一确认对话框 — 替代原生 window.confirm() + * 基于 TDesign DialogPlugin.confirm() + */ +import { DialogPlugin } from 'tdesign-vue-next' + +export function useConfirm() { + /** + * 弹出确认对话框 + * @param title 确认标题 + * @param body 确认内容(可选) + * @returns Promise — 用户点击确定返回 true,取消/关闭返回 false + */ + function confirm(title: string, body?: string): Promise { + return new Promise((resolve) => { + const dlg = DialogPlugin.confirm({ + header: title, + body: body || '', + confirmBtn: '确定', + cancelBtn: '取消', + onConfirm: () => { + dlg.hide() + resolve(true) + }, + onCancel: () => { + dlg.hide() + resolve(false) + }, + onClose: () => { + dlg.hide() + resolve(false) + }, + }) + }) + } + + return { confirm } +} diff --git a/frontend/src/composables/useCrudForm.ts b/frontend/src/composables/useCrudForm.ts new file mode 100644 index 0000000..6efb8c9 --- /dev/null +++ b/frontend/src/composables/useCrudForm.ts @@ -0,0 +1,70 @@ +/** + * 通用 CRUD 表单 composable + * 封装创建/编辑两种模式、表单重置、保存逻辑 + */ +import { ref, computed } from 'vue' +import type { ApiResponse } from '@/types/api' + +export interface CrudFormOptions { + /** 默认表单数据(创建时使用) */ + defaultForm: T + /** 创建 API 函数 */ + createFn: (data: T) => Promise + /** 更新 API 函数 */ + updateFn: (id: string | number, data: T) => Promise + /** 保存成功后的回调(如刷新列表) */ + onSuccess?: () => void +} + +export function useCrudForm>(options: CrudFormOptions) { + const visible = ref(false) + const editingId = ref(null) + const saving = ref(false) + const form = ref({ ...options.defaultForm }) as ReturnType> + + const isEdit = computed(() => editingId.value !== null) + + /** 打开创建弹窗 */ + function openCreate() { + editingId.value = null + form.value = { ...options.defaultForm } as T + visible.value = true + } + + /** 打开编辑弹窗 */ + function openEdit(item: T & { id?: string | number }) { + editingId.value = (item as any).id ?? null + form.value = { ...item } + visible.value = true + } + + /** 关闭弹窗 */ + function close() { + visible.value = false + editingId.value = null + form.value = { ...options.defaultForm } as T + } + + /** 保存(自动判断创建/更新) */ + async function save(): Promise { + saving.value = true + try { + let r: ApiResponse + if (editingId.value !== null) { + r = await options.updateFn(editingId.value, form.value as T) + } else { + r = await options.createFn(form.value as T) + } + if (r.success) { + close() + options.onSuccess?.() + return true + } + return false // 调用方自行 toast + } finally { + saving.value = false + } + } + + return { visible, editingId, saving, form, isEdit, openCreate, openEdit, close, save } +} diff --git a/frontend/src/composables/useDebounce.ts b/frontend/src/composables/useDebounce.ts new file mode 100644 index 0000000..a12095e --- /dev/null +++ b/frontend/src/composables/useDebounce.ts @@ -0,0 +1,33 @@ +/** + * 通用防抖 composable + * 替代各页面中手动 setTimeout/clearTimeout 的防抖模式 + */ +export function useDebounce() { + let timer: ReturnType | null = null + + /** + * 返回一个防抖化的函数 + * @param fn 要防抖的函数 + * @param delay 延迟毫秒数(默认 300ms) + * @returns 防抖后的函数 + */ + function debounce void>(fn: T, delay = 300): (...args: Parameters) => void { + return (...args: Parameters) => { + if (timer !== null) clearTimeout(timer) + timer = setTimeout(() => { + timer = null + fn(...args) + }, delay) + } + } + + /** 清除等待中的定时器 */ + function cancel() { + if (timer !== null) { + clearTimeout(timer) + timer = null + } + } + + return { debounce, cancel } +} diff --git a/frontend/src/composables/useLoading.ts b/frontend/src/composables/useLoading.ts new file mode 100644 index 0000000..e27a7b4 --- /dev/null +++ b/frontend/src/composables/useLoading.ts @@ -0,0 +1,36 @@ +/** + * 通用 loading 状态管理 composable + * 统一 try/finally 中 loading 状态的设置/清除 + */ +import { ref } from 'vue' + +export function useLoading() { + const loading = ref(false) + const saving = ref(false) + + /** + * 包裹一个异步函数,自动管理 loading 状态 + */ + async function withLoading(fn: () => Promise): Promise { + loading.value = true + try { + return await fn() + } finally { + loading.value = false + } + } + + /** + * 包裹一个异步函数,自动管理 saving 状态 + */ + async function withSaving(fn: () => Promise): Promise { + saving.value = true + try { + return await fn() + } finally { + saving.value = false + } + } + + return { loading, saving, withLoading, withSaving } +} diff --git a/frontend/src/composables/usePagination.ts b/frontend/src/composables/usePagination.ts new file mode 100644 index 0000000..6dee695 --- /dev/null +++ b/frontend/src/composables/usePagination.ts @@ -0,0 +1,51 @@ +/** + * 通用分页 composable + * 替代各页面中重复的 page/total/pageSize/loading/onPageChange 模式 + */ +import { ref } from 'vue' +import type { ApiResponse, PageData } from '@/types/api' + +export interface PaginationOptions { + /** 每页条数 */ + pageSize?: number + /** 数据加载函数,接收 page 和 pageSize,返回 API 响应 */ + fetchFn: (page: number, pageSize: number) => Promise>> +} + +export function usePagination(options: PaginationOptions) { + const page = ref(1) + const total = ref(0) + const pageSize = options.pageSize ?? 20 + const loading = ref(false) + const data = ref([]) as ReturnType> + + /** 加载数据 */ + async function load(p?: number) { + if (p !== undefined) page.value = p + loading.value = true + try { + const r = await options.fetchFn(page.value, pageSize) + if (r.success) { + data.value = (r.data?.records ?? r.data ?? []) as T[] + total.value = r.data?.total ?? (r as any).total ?? 0 + } + } finally { + loading.value = false + } + } + + /** 分页变化回调 */ + function onPageChange(info: { current: number }) { + page.value = info.current + load() + } + + /** 重置到第一页并重新加载 */ + function reset() { + page.value = 1 + total.value = 0 + data.value = [] + } + + return { page, total, pageSize, loading, data, load, onPageChange, reset } +} diff --git a/frontend/src/layouts/MainLayout.vue b/frontend/src/layouts/MainLayout.vue new file mode 100644 index 0000000..73e60f2 --- /dev/null +++ b/frontend/src/layouts/MainLayout.vue @@ -0,0 +1,68 @@ + + + + + diff --git a/frontend/src/layouts/Sidebar.vue b/frontend/src/layouts/Sidebar.vue new file mode 100644 index 0000000..1b2139c --- /dev/null +++ b/frontend/src/layouts/Sidebar.vue @@ -0,0 +1,104 @@ + + + + + diff --git a/frontend/src/layouts/Topbar.vue b/frontend/src/layouts/Topbar.vue new file mode 100644 index 0000000..bfea6e0 --- /dev/null +++ b/frontend/src/layouts/Topbar.vue @@ -0,0 +1,52 @@ + + + + + diff --git a/frontend/src/main.ts b/frontend/src/main.ts new file mode 100644 index 0000000..9b418c5 --- /dev/null +++ b/frontend/src/main.ts @@ -0,0 +1,17 @@ +/** + * Vue 应用入口 + * 替代旧 static/js/app.js + */ +import { createApp } from 'vue' +import { createPinia } from 'pinia' +import TDesign from 'tdesign-vue-next' +import 'tdesign-vue-next/es/style/index.css' +import './styles/shared.css' +import App from './App.vue' +import router from './router' + +const app = createApp(App) +app.use(createPinia()) +app.use(router) +app.use(TDesign) +app.mount('#app') diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts new file mode 100644 index 0000000..785613a --- /dev/null +++ b/frontend/src/router/index.ts @@ -0,0 +1,76 @@ +/** + * Vue Router 配置 —— 替代旧 app.js 中 activePage + v-if 模式 + * + * 使用 Hash 模式(#/path),无需后端 SPA fallback 配置。 + */ +import { createRouter, createWebHashHistory, type RouteRecordRaw } from 'vue-router' +import { useAuthStore } from '@/stores/auth' +import { useCategoryStore } from '@/stores/category' +import { useDocumentStore } from '@/stores/document' + +const routes: RouteRecordRaw[] = [ + { path: '/', redirect: '/chat' }, + // ==================== 智能客服 ==================== + { path: '/chat', name: 'Chat', component: () => import('@/views/ChatPanel.vue') }, + // ==================== 知识库 ==================== + { path: '/knowledge/stats', name: 'DocStats', component: () => import('@/views/DocStats.vue') }, + { path: '/knowledge/document', name: 'DocList', component: () => import('@/views/DocList.vue') }, + { path: '/knowledge/category', name: 'Category', component: () => import('@/views/CategoryManager.vue') }, + { path: '/knowledge/search', name: 'DocSearch', component: () => import('@/views/DocSearch.vue') }, + { path: '/knowledge/faq', name: 'FaqManager', component: () => import('@/views/FaqManager.vue') }, + // ==================== 会话管理 ==================== + { path: '/conversation', name: 'Conversation', component: () => import('@/views/ConversationManager.vue') }, + // ==================== 运营看板 ==================== + { path: '/dashboard', name: 'Dashboard', component: () => import('@/views/DashboardPanel.vue') }, + // ==================== 系统设置 ==================== + { path: '/settings/role', name: 'RoleManager', component: () => import('@/views/RoleManager.vue') }, + { path: '/settings/model-config', name: 'ModelConfig', component: () => import('@/views/ModelConfigManager.vue') }, + { path: '/settings/sensitive', name: 'SensitiveWord', component: () => import('@/views/SensitiveWordManager.vue') }, + { path: '/settings/audit-log', name: 'AuditLog', component: () => import('@/views/AuditLogManager.vue') }, + { path: '/settings/user', name: 'UserManager', component: () => import('@/views/UserManager.vue') }, + { path: '/settings/api-key', name: 'ApiKey', component: () => import('@/views/ApiKeyManager.vue') }, + { path: '/settings/webhook', name: 'Webhook', component: () => import('@/views/WebhookManager.vue') }, + { path: '/settings/mcp-server', name: 'McpServer', component: () => import('@/views/McpServerManager.vue') }, + { path: '/settings/pipeline-flow', name: 'PipelineFlow', component: () => import('@/views/PipelineFlow.vue') }, + { path: '/settings/system-config', name: 'SystemConfig', component: () => import('@/views/SystemConfigManager.vue') }, + // ==================== 兜底 ==================== + { path: '/:pathMatch(.*)*', redirect: '/chat' }, +] + +const router = createRouter({ + history: createWebHashHistory(), + routes, +}) + +/** 路由守卫:登录校验 + 页面数据预加载(替代旧 _onPageEnter) */ +router.beforeEach(async (to, _from, next) => { + // 页面数据预加载钩子 + const pageId = to.name as string + const categoryStore = useCategoryStore() + const documentStore = useDocumentStore() + + switch (pageId) { + case 'DocStats': + case 'DocList': + case 'DocSearch': + try { + await Promise.all([ + categoryStore.loadCategories(), + documentStore.loadStats(), + documentStore.loadTags(), + ]) + } catch { /* 预加载失败不阻塞导航 */ } + break + case 'Category': + case 'FaqManager': + try { await categoryStore.loadCategories() } catch { /* */ } + break + case 'RoleManager': + try { await categoryStore.loadCategories() } catch { /* */ } + break + } + + next() +}) + +export default router diff --git a/frontend/src/shims-vue.d.ts b/frontend/src/shims-vue.d.ts new file mode 100644 index 0000000..323c78a --- /dev/null +++ b/frontend/src/shims-vue.d.ts @@ -0,0 +1,7 @@ +/// + +declare module '*.vue' { + import type { DefineComponent } from 'vue' + const component: DefineComponent<{}, {}, any> + export default component +} diff --git a/frontend/src/stores/auth.ts b/frontend/src/stores/auth.ts new file mode 100644 index 0000000..cc1cdee --- /dev/null +++ b/frontend/src/stores/auth.ts @@ -0,0 +1,59 @@ +/** + * 认证状态管理 —— 替代旧 store.js 中 currentUser / isLoggedIn / hasRole 部分 + */ +import { defineStore } from 'pinia' +import { ref, computed } from 'vue' +import { getToken, isLoggedIn, clearTokens, getUserInfo, setUserInfo, setToken, setRefreshToken } from '@/utils/token' +import { getCurrentUser, refreshToken } from '@/api/auth' + +export const useAuthStore = defineStore('auth', () => { + const currentUser = ref>(getUserInfo()) + const loggedIn = ref(isLoggedIn()) + + const isAdmin = computed(() => hasRole('admin')) + + function hasRole(role: string): boolean { + return (currentUser.value.roles || []).includes(role) + } + + async function fetchUser(): Promise { + try { + const res = await getCurrentUser() + if (res.success && res.data) { + currentUser.value = res.data + setUserInfo(res.data) + loggedIn.value = true + return true + } + } catch { /* ignore */ } + return false + } + + async function tryRefreshToken(): Promise { + const rt = localStorage.getItem('sb_refresh_token') + if (!rt) return false + try { + const res = await refreshToken(rt) + if (res.success && res.data) { + setToken(res.data.accessToken) + setRefreshToken(res.data.refreshToken) + return true + } + } catch { /* ignore */ } + return false + } + + function logout(): void { + clearTokens() + loggedIn.value = false + currentUser.value = {} + } + + function onLoginSuccess(user: Record): void { + currentUser.value = user || getUserInfo() + setUserInfo(user || {}) + loggedIn.value = true + } + + return { currentUser, loggedIn, isAdmin, hasRole, fetchUser, tryRefreshToken, logout, onLoginSuccess } +}) diff --git a/frontend/src/stores/category.ts b/frontend/src/stores/category.ts new file mode 100644 index 0000000..50ba5b2 --- /dev/null +++ b/frontend/src/stores/category.ts @@ -0,0 +1,32 @@ +/** + * 分类状态管理 —— 替代旧 store.js 中 categories 部分 + */ +import { defineStore } from 'pinia' +import { ref, computed } from 'vue' +import { getCategoryList } from '@/api/category' + +export const useCategoryStore = defineStore('category', () => { + const categories = ref([]) + const categoryMap = computed>(() => { + const map: Record = {} + categories.value.forEach((c: any) => { map[c.id] = c.name }) + return map + }) + + function getCategoryName(categoryId: string): string { + return categoryMap.value[categoryId] || (categoryId && categoryId !== '0' ? '未知' : '未分类') + } + + async function loadCategories(): Promise { + try { + const res = await getCategoryList() + if (res.success) { + categories.value = res.data || [] + } + } catch (e) { + console.error('加载分类失败', e) + } + } + + return { categories, categoryMap, getCategoryName, loadCategories } +}) diff --git a/frontend/src/stores/dialog.ts b/frontend/src/stores/dialog.ts new file mode 100644 index 0000000..1c53962 --- /dev/null +++ b/frontend/src/stores/dialog.ts @@ -0,0 +1,40 @@ +/** + * 弹窗/详情状态管理 —— 替代旧 store.js 中 detailModal 部分 + */ +import { defineStore } from 'pinia' +import { ref, reactive } from 'vue' +import { getDocumentDetail, getDocumentChunks } from '@/api/document' + +export const useDialogStore = defineStore('dialog', () => { + const detailModal = reactive({ + visible: false, + doc: null as any, + chunks: [] as any[], + chunksError: '', + }) + + async function openDetail(docId: string): Promise { + try { + const [detailRes, chunksRes] = await Promise.all([ + getDocumentDetail(docId), + getDocumentChunks(docId), + ]) + if (!detailRes.success) return + detailModal.doc = detailRes.data + detailModal.chunks = chunksRes.success ? (chunksRes.data || []) : [] + detailModal.chunksError = chunksRes.success ? '' : (chunksRes.message || '分块详情加载失败') + detailModal.visible = true + } catch (e) { + console.error('加载文档详情失败', e) + } + } + + function closeDetail(): void { + detailModal.visible = false + detailModal.doc = null + detailModal.chunks = [] + detailModal.chunksError = '' + } + + return { detailModal, openDetail, closeDetail } +}) diff --git a/frontend/src/stores/document.ts b/frontend/src/stores/document.ts new file mode 100644 index 0000000..b1f4fb5 --- /dev/null +++ b/frontend/src/stores/document.ts @@ -0,0 +1,27 @@ +/** + * 文档状态管理 —— 替代旧 store.js 中 stats / tags 部分 + */ +import { defineStore } from 'pinia' +import { ref } from 'vue' +import { getStats, getTagList } from '@/api/document' + +export const useDocumentStore = defineStore('document', () => { + const stats = ref(null) + const tags = ref([]) + + async function loadStats(): Promise { + try { + const res = await getStats() + if (res.success) stats.value = res.data + } catch (e) { console.error('加载统计失败', e) } + } + + async function loadTags(): Promise { + try { + const res = await getTagList() + if (res.success) tags.value = res.data || [] + } catch (e) { console.error('加载标签列表失败', e) } + } + + return { stats, tags, loadStats, loadTags } +}) diff --git a/frontend/src/stores/navigation.ts b/frontend/src/stores/navigation.ts new file mode 100644 index 0000000..ddfdd78 --- /dev/null +++ b/frontend/src/stores/navigation.ts @@ -0,0 +1,44 @@ +/** + * 导航状态管理 —— 替代旧 store.js 中 activePage / MENU_ITEMS / sidebar 部分 + */ + +// ==================== 菜单配置(数据驱动) ==================== +export const MENU_ITEMS = [ + { id: 'chat', label: '智能客服对话', icon: '💬', path: '/chat' }, + { + id: 'knowledge', label: '知识库', icon: '📚', + children: [ + { id: 'stats', label: '统计概览', icon: '📊', path: '/knowledge/stats' }, + { id: 'doc-manage', label: '文档管理', icon: '📄', path: '/knowledge/document' }, + { id: 'category', label: '分类管理', icon: '🏷️', path: '/knowledge/category' }, + { id: 'search-test', label: '搜索测试', icon: '🔍', path: '/knowledge/search' }, + { id: 'faq', label: 'FAQ 管理', icon: '❓', path: '/knowledge/faq' }, + ], + }, + { id: 'conversation', label: '会话管理', icon: '💬', path: '/conversation' }, + { id: 'dashboard', label: '运营看板', icon: '📈', path: '/dashboard' }, + { + id: 'settings', label: '系统设置', icon: '⚙️', + children: [ + { id: 'role', label: '客服角色管理', icon: '🎭', path: '/settings/role' }, + { id: 'model-config', label: '模型配置', icon: '🤖', path: '/settings/model-config' }, + { id: 'sensitive', label: '敏感词管理', icon: '🛡️', path: '/settings/sensitive' }, + { id: 'audit-log', label: '审计日志', icon: '📋', path: '/settings/audit-log' }, + { id: 'user-manage', label: '用户管理', icon: '👥', path: '/settings/user', roles: ['admin'] }, + { id: 'api-key', label: 'API Key 管理', icon: '🔑', path: '/settings/api-key', roles: ['admin'] }, + { id: 'webhook', label: 'Webhook 管理', icon: '🔔', path: '/settings/webhook', roles: ['admin'] }, + { id: 'mcp-server', label: 'MCP 服务管理', icon: '🔌', path: '/settings/mcp-server', roles: ['admin'] }, + { id: 'pipeline-flow', label: 'AI 执行链', icon: '🔀', path: '/settings/pipeline-flow' }, + { id: 'system-config', label: '系统配置', icon: '🔧', path: '/settings/system-config', roles: ['admin'] }, + ], + }, +] + +export interface MenuItem { + id: string + label: string + icon: string + path?: string + roles?: string[] + children?: MenuItem[] +} diff --git a/frontend/src/styles/shared.css b/frontend/src/styles/shared.css new file mode 100644 index 0000000..ca6c073 --- /dev/null +++ b/frontend/src/styles/shared.css @@ -0,0 +1,49 @@ +/** + * 全局共享 CSS 工具类 + * 替代各页面中重复的行内样式和 scoped 样式 + */ + +/* ====== 工具栏 ====== */ +.toolbar { + display: flex; + gap: 8px; + align-items: center; + margin-bottom: 12px; + flex-wrap: wrap; +} + +/* ====== 弹窗页脚按钮行 ====== */ +.dialog-footer { + display: flex; + justify-content: flex-end; + gap: 8px; + margin-top: 16px; +} + +/* ====== 统计提示文字 ====== */ +.total-hint { + font-size: 13px; + color: #666; +} + +/* ====== 卡片容器(圆角边框) ====== */ +.card-box { + padding: 12px; + background: #fafafa; + border: 1px solid #e7e7e7; + border-radius: 8px; +} + +/* ====== 描述性文字 ====== */ +.desc-text { + font-size: 13px; + color: #999; +} + +/* ====== 代码标签/键名展示 ====== */ +.code-tag { + font-size: 12px; + background: #f3f4f6; + padding: 2px 6px; + border-radius: 4px; +} diff --git a/frontend/src/types/api.ts b/frontend/src/types/api.ts new file mode 100644 index 0000000..2f850d3 --- /dev/null +++ b/frontend/src/types/api.ts @@ -0,0 +1,23 @@ +/** 通用 API 响应结构 */ +export interface ApiResponse { + success: boolean + code?: number + message?: string + data: T +} + +/** 分页请求参数 */ +export interface PageParams { + page?: number + pageSize?: number + keyword?: string + [key: string]: any +} + +/** 分页响应 */ +export interface PageData { + records: T[] + total: number + page: number + pageSize: number +} diff --git a/frontend/src/types/models.ts b/frontend/src/types/models.ts new file mode 100644 index 0000000..96a03da --- /dev/null +++ b/frontend/src/types/models.ts @@ -0,0 +1,234 @@ +// ==================== 认证相关 ==================== + +/** 登录用户信息 */ +export interface LoginUser { + id: string + username: string + nickname?: string + roles: string[] + enabled?: boolean +} + +/** 登录请求 */ +export interface LoginRequest { + username: string + password: string +} + +/** Token 响应 */ +export interface TokenResponse { + accessToken: string + refreshToken: string +} + +// ==================== 知识库文档 ==================== + +/** 知识库文档 */ +export interface KnowledgeDocument { + id: string + title: string + categoryId: string + categoryName?: string + tags?: string[] + status: 'processing' | 'ready' | 'error' + sourceName?: string + chunkCount?: number + fileSize?: number + contentHash?: string + createTime?: string + updateTime?: string +} + +/** 文档分块 */ +export interface DocumentChunk { + id: string + documentId: string + chunkIndex: number + content: string + metadata?: Record +} + +// ==================== 分类管理 ==================== + +/** 知识库分类 */ +export interface KnowledgeCategory { + id: string + name: string + parentId: string + sortOrder?: number + description?: string + children?: KnowledgeCategory[] +} + +// ==================== FAQ 管理 ==================== + +/** FAQ 条目 */ +export interface FaqEntry { + id: string + question: string + answer: string + categoryId?: string + similarQuestions?: string[] + enabled: boolean + createTime?: string + updateTime?: string +} + +// ==================== AI 模型配置 ==================== + +/** 模型提供商 */ +export type ModelProvider = 'dashscope' | 'deepseek' | 'volcengine' | 'moonshot' | 'zhipu' | 'openai' + +/** 应用类型 */ +export type AppType = 'CHAT' | 'EMBEDDING' | 'RAG_REWRITE' | 'RERANK' + +/** 模型配置 */ +export interface AiModelConfig { + id: string + name: string + appType: AppType + provider: ModelProvider + modelName: string + apiKey?: string + baseUrl?: string + completionsPath?: string + temperature?: number + maxTokens?: number + topP?: number + extraConfig?: Record + isActive: boolean + priority?: number + healthStatus?: 'healthy' | 'unhealthy' | 'unknown' + createTime?: string + updateTime?: string +} + +// ==================== MCP Server ==================== + +/** MCP 服务配置 */ +export interface McpServerConfig { + id: string + name: string + command?: string + args?: string[] + env?: Record + url?: string + enabled: boolean + createTime?: string +} + +// ==================== 用户管理 ==================== + +/** 系统用户 */ +export interface SysUser { + id: string + username: string + nickname?: string + email?: string + roles?: string[] + enabled: boolean + createTime?: string + updateTime?: string +} + +// ==================== API Key ==================== + +/** API Key */ +export interface ApiKeyInfo { + id: string + name: string + apiKey: string + roleIds?: string[] + enabled: boolean + createTime?: string +} + +// ==================== 客服角色 ==================== + +/** 客服角色 */ +export interface SupportRole { + id: string + name: string + description?: string + systemPrompt?: string + categoryIds?: string[] + enabled: boolean + createTime?: string + updateTime?: string +} + +// ==================== 敏感词 ==================== + +/** 敏感词 */ +export interface SensitiveWord { + id: string + word: string + level: 1 | 2 // 1=脱敏(MASK), 2=拦截(BLOCK) + category?: string + enabled: boolean + createTime?: string +} + +// ==================== Webhook ==================== + +/** Webhook 配置 */ +export interface WebhookConfig { + id: string + name: string + url: string + events?: string[] + enabled: boolean + createTime?: string +} + +// ==================== 系统配置 ==================== + +/** 系统配置项 */ +export interface SystemConfigEntry { + id: string + configKey: string + configValue: string + description?: string + configType?: string + createTime?: string + updateTime?: string +} + +// ==================== 会话管理 ==================== + +/** 会话信息 */ +export interface ConversationInfo { + chatId: string + title?: string + roleId?: string + roleName?: string + messageCount?: number + createTime?: string + lastMessageTime?: string +} + +// ==================== 消息反馈 ==================== + +/** 消息反馈 */ +export interface MessageFeedback { + id?: string + messageId: string + type: 'THUMBS_UP' | 'THUMBS_DOWN' + reason?: string + detail?: string + createTime?: string +} + +// ==================== Dashboard ==================== + +/** 仪表盘统计数据 */ +export interface DashboardStats { + totalDocuments?: number + totalCategories?: number + totalConversations?: number + totalMessages?: number + todayMessages?: number + activeUsers?: number + faqCount?: number + [key: string]: any +} diff --git a/frontend/src/types/page.ts b/frontend/src/types/page.ts new file mode 100644 index 0000000..ad37eeb --- /dev/null +++ b/frontend/src/types/page.ts @@ -0,0 +1,3 @@ +/** 分页参数/响应类型 */ +import type { PageParams, PageData } from './api' +export type { PageParams, PageData } diff --git a/frontend/src/types/sse.ts b/frontend/src/types/sse.ts new file mode 100644 index 0000000..a7f3f7d --- /dev/null +++ b/frontend/src/types/sse.ts @@ -0,0 +1,8 @@ +/** SSE 事件类型定义 */ +export interface SSECallbacks { + onMessage?: (chunk: string) => void + onToolCallStart?: (data: any) => void + onToolCallResult?: (data: any) => void + onError?: (data: any) => void + onDone?: () => void +} diff --git a/frontend/src/utils/format.ts b/frontend/src/utils/format.ts new file mode 100644 index 0000000..107fde6 --- /dev/null +++ b/frontend/src/utils/format.ts @@ -0,0 +1,24 @@ +/** + * 格式化工具函数 + */ + +/** 格式化文件大小 */ +export function formatBytes(b: number): string { + if (b < 1024) return b + ' B' + if (b < 1048576) return (b / 1024).toFixed(1) + ' KB' + return (b / 1048576).toFixed(1) + ' MB' +} + +/** 格式化日期,输出 "YYYY-MM-DD HH:mm:ss" 或 "-" */ +export function formatDate(d: string | Date | number | null | undefined): string { + if (!d && d !== 0) return '-' + const date = new Date(d) + if (isNaN(date.getTime())) return '-' + const y = date.getFullYear() + const m = String(date.getMonth() + 1).padStart(2, '0') + const dd = String(date.getDate()).padStart(2, '0') + const h = String(date.getHours()).padStart(2, '0') + const mi = String(date.getMinutes()).padStart(2, '0') + const s = String(date.getSeconds()).padStart(2, '0') + return `${y}-${m}-${dd} ${h}:${mi}:${s}` +} diff --git a/frontend/src/utils/markdown.ts b/frontend/src/utils/markdown.ts new file mode 100644 index 0000000..88b351a --- /dev/null +++ b/frontend/src/utils/markdown.ts @@ -0,0 +1,22 @@ +/** + * Markdown 渲染工具 —— 从 utils.js 原封不动搬移 + * + * 把 Markdown 文本渲染为安全的 HTML(marked 解析 + DOMPurify 消毒)。 + * 仅用于 AI 回复展示;用户输入不要走这里,避免 XSS。 + */ +import { marked } from 'marked' +import DOMPurify from 'dompurify' + +// 单行换行也生效、启用 GitHub 风格 Markdown(表格/任务列表等) +marked.setOptions({ breaks: true, gfm: true }) + +/** + * Markdown → 安全的 HTML + * @param text Markdown 原文 + * @returns 消毒后的 HTML 字符串 + */ +export function renderMarkdown(text: string): string { + if (!text) return '' + const html = marked.parse(String(text)) + return DOMPurify.sanitize(html) +} diff --git a/src/main/resources/static/js/utils.js b/frontend/src/utils/sse.ts similarity index 50% rename from src/main/resources/static/js/utils.js rename to frontend/src/utils/sse.ts index 74a31b6..6c6d490 100644 --- a/src/main/resources/static/js/utils.js +++ b/frontend/src/utils/sse.ts @@ -1,146 +1,54 @@ /** - * 工具函数模块 - * 提供 Toast 提示、格式化、SSE 流式读取、Markdown 渲染、Token 管理等通用能力 + * SSE 流式读取工具 —— 从 utils.js 原封不动搬移 + * + * 统一处理 Flux / ServerSentEvent / SseEmitter 三种 SSE 接口。 + * 此文件使用原生 fetch + ReadableStream API,零框架依赖。 */ -import { marked } from 'marked' -import DOMPurify from 'dompurify' - -// 单行换行也生效、启用 GitHub 风格 Markdown(表格/任务列表等) -marked.setOptions({ breaks: true, gfm: true }) - -// API 基址:空字符串 = 同源部署,由 Spring Boot 直接服务前端 -export const API_BASE = '' - -// ==================== Token 管理 ==================== - -export function getToken() { - return localStorage.getItem('sb_token') || '' -} - -export function setToken(token) { - localStorage.setItem('sb_token', token) -} - -export function getRefreshToken() { - return localStorage.getItem('sb_refresh_token') || '' -} - -export function setRefreshToken(token) { - localStorage.setItem('sb_refresh_token', token) -} - -export function clearTokens() { - localStorage.removeItem('sb_token') - localStorage.removeItem('sb_refresh_token') - localStorage.removeItem('sb_user') -} +import type { SSECallbacks } from '@/types/sse' +import { getToken } from '@/utils/token' -export function getUserInfo() { - try { - return JSON.parse(localStorage.getItem('sb_user') || '{}') - } catch { return {} } -} - -export function setUserInfo(info) { - localStorage.setItem('sb_user', JSON.stringify(info)) -} - -export function isLoggedIn() { - return !!getToken() -} - -/** - * 获取含认证信息的请求头 - */ -export function authHeaders() { +/** 构建带认证的请求头 */ +function authHeaders(extra?: Record): Record { const token = getToken() - const headers = {} - if (token) headers['Authorization'] = 'Bearer ' + token - return headers -} - -// ==================== Toast 提示 ==================== - -let _toastContainer = null - -/** - * 获取或创建 Toast 容器 - */ -function getToastContainer() { - if (!_toastContainer) { - _toastContainer = document.getElementById('toasts') - } - return _toastContainer -} - -/** - * 显示 Toast 提示 - * @param {string} msg 消息内容 - * @param {'info'|'success'|'error'} type 类型 - */ -export function toast(msg, type = 'info') { - const container = getToastContainer() - if (!container) return - const el = document.createElement('div') - el.className = 'toast ' + type - el.textContent = msg - container.appendChild(el) - setTimeout(() => { - el.style.opacity = '0' - el.style.transition = 'opacity .3s' - setTimeout(() => el.remove(), 300) - }, 2500) -} - -// ==================== 格式化工具 ==================== - -/** - * 格式化文件大小 - * @param {number} b 字节数 - * @returns {string} - */ -export function formatBytes(b) { - return b < 1024 ? b + ' B' - : b < 1048576 ? (b / 1024).toFixed(1) + ' KB' - : (b / 1048576).toFixed(1) + ' MB' + const base: Record = extra ? { ...extra } : {} + if (token) base['Authorization'] = `Bearer ${token}` + return base } /** - * 格式化日期 - * @param {string|Date} d 日期 - * @returns {string} - */ -export function formatDate(d) { - if (!d) return '-' - const date = new Date(d) - return date.toLocaleString('zh-CN') -} - -// ==================== SSE 流式读取 ==================== - -/** - * 通用 SSE 流式读取 - * 统一处理 Flux / ServerSentEvent / SseEmitter 三种 SSE 接口 + * 通用 SSE 流式读取 —— 统一处理 Flux / ServerSentEvent / SseEmitter 三种 SSE 接口 * - * @param {string} url 请求地址 - * @param {function(string): void} onChunk 每收到一段文本的回调 - * @param {function(): void} onDone 流结束的回调 - * @param {object} [headers] 额外请求头 + * @param url 请求地址 + * @param onChunk 每收到一段文本的回调 + * @param onDone 流结束的回调 + * @param headers 额外请求头 + * @param signal AbortSignal 用于取消请求(组件卸载时必须传入以释放网络资源) */ -export async function readSSEStream(url, onChunk, onDone, headers) { - const res = await fetch(url, { headers: headers || {} }) +export async function readSSEStream( + url: string, + onChunk: (text: string) => void, + onDone?: () => void, + headers?: Record, + signal?: AbortSignal +): Promise { + const res = await fetch(url, { headers: authHeaders(headers), signal }) if (!res.ok) throw new Error('HTTP ' + res.status) - const reader = res.body.getReader() + const reader = res.body!.getReader() const decoder = new TextDecoder() let buffer = '' // SSE 规范:同一事件内的多行 data: 字段用 \n 拼接,事件间用空行分隔 - let eventDataLines = [] + let eventDataLines: string[] = [] + let currentEvent = 'message' const flushEvent = () => { if (eventDataLines.length === 0) return const text = eventDataLines.join('\n') eventDataLines = [] + const ev = currentEvent + currentEvent = 'message' if (text === '[DONE]') return + // 跳过 status / faq 等系统事件,不显示在对话框中 + if (ev === 'status') return // 空事件视为 LLM 流式输出的换行符(Spring 将 "\n" 编码为单条空 data: 事件) onChunk(text || '\n') } @@ -157,6 +65,9 @@ export async function readSSEStream(url, onChunk, onDone, headers) { if (line === '') { // 空行 = SSE 事件边界 flushEvent() + } else if (line.startsWith('event:')) { + // 记录事件类型,用于 flushEvent 时过滤系统事件 + currentEvent = line.slice(6).trim() } else if (line.startsWith('data:')) { // 累积 data 字段;仅剥离 data: 后的一个可选空格,保留 markdown 列表缩进 let data = line.slice(5) @@ -178,25 +89,31 @@ export async function readSSEStream(url, onChunk, onDone, headers) { * 增强版 SSE 流式读取(支持事件类型分发) * 解析 SSE 标准的 event: 字段,将不同类型事件分发到对应回调。 * - * @param {string} url 请求地址 - * @param {object} handlers 回调对象: + * @param url 请求地址 + * @param handlers 回调对象: * - onMessage(chunk): 普通文本内容(event: message 或无 event 的 data) * - onToolCallStart(data): 工具调用开始(event: tool_call_start) * - onToolCallResult(data): 工具调用结果(event: tool_call_result) * - onError(data): 错误事件(event: error) * - onDone(): 流结束 - * @param {object} [headers] 额外请求头 + * @param headers 额外请求头 + * @param signal AbortSignal 用于取消请求(组件卸载时必须传入以释放网络资源) */ -export async function readSSEStreamWithEvents(url, handlers, headers) { +export async function readSSEStreamWithEvents( + url: string, + handlers: SSECallbacks, + headers?: Record, + signal?: AbortSignal +): Promise { const { onMessage, onToolCallStart, onToolCallResult, onError, onDone } = handlers - const res = await fetch(url, { headers: headers || {} }) + const res = await fetch(url, { headers: authHeaders(headers), signal }) if (!res.ok) throw new Error('HTTP ' + res.status) - const reader = res.body.getReader() + const reader = res.body!.getReader() const decoder = new TextDecoder() let buffer = '' let currentEvent = 'message' // SSE 规范:同一事件内的多行 data: 字段用 \n 拼接 - let eventDataLines = [] + let eventDataLines: string[] = [] const flushEvent = () => { if (eventDataLines.length === 0) return @@ -218,6 +135,9 @@ export async function readSSEStreamWithEvents(url, handlers, headers) { case 'error': if (onError) onError(JSON.parse(text)) break + case 'status': + // 系统状态事件(generating / faq_hit 等),不显示在对话框中 + break case 'message': default: if (onMessage) onMessage(text) @@ -253,17 +173,3 @@ export async function readSSEStreamWithEvents(url, handlers, headers) { flushEvent() if (onDone) onDone() } - -// ==================== Markdown 渲染 ==================== - -/** - * 把 Markdown 文本渲染为安全的 HTML(marked 解析 + DOMPurify 消毒)。 - * 仅用于 AI 回复展示;用户输入不要走这里,避免 XSS。 - * @param {string} text Markdown 原文 - * @returns {string} 消毒后的 HTML 字符串 - */ -export function renderMarkdown(text) { - if (!text) return '' - const html = marked.parse(String(text)) - return DOMPurify.sanitize(html) -} diff --git a/frontend/src/utils/toast.ts b/frontend/src/utils/toast.ts new file mode 100644 index 0000000..9c3bab3 --- /dev/null +++ b/frontend/src/utils/toast.ts @@ -0,0 +1,8 @@ +/** + * Toast 提示 —— 使用 TDesign MessagePlugin + */ +import { MessagePlugin } from 'tdesign-vue-next' + +export function toast(msg: string, type: 'info' | 'success' | 'error' | 'warning' = 'info'): void { + MessagePlugin[type](msg) +} diff --git a/frontend/src/utils/token.ts b/frontend/src/utils/token.ts new file mode 100644 index 0000000..5d52c4e --- /dev/null +++ b/frontend/src/utils/token.ts @@ -0,0 +1,46 @@ +/** + * Token 管理工具 + * 基于 localStorage 存取 JWT Token 和用户信息 + */ + +const TOKEN_KEY = 'sb_token' +const REFRESH_TOKEN_KEY = 'sb_refresh_token' +const USER_KEY = 'sb_user' + +export function getToken(): string { + return localStorage.getItem(TOKEN_KEY) || '' +} + +export function setToken(token: string): void { + localStorage.setItem(TOKEN_KEY, token) +} + +export function getRefreshToken(): string { + return localStorage.getItem(REFRESH_TOKEN_KEY) || '' +} + +export function setRefreshToken(token: string): void { + localStorage.setItem(REFRESH_TOKEN_KEY, token) +} + +export function clearTokens(): void { + localStorage.removeItem(TOKEN_KEY) + localStorage.removeItem(REFRESH_TOKEN_KEY) + localStorage.removeItem(USER_KEY) +} + +export function getUserInfo(): Record { + try { + return JSON.parse(localStorage.getItem(USER_KEY) || '{}') + } catch { + return {} + } +} + +export function setUserInfo(info: Record): void { + localStorage.setItem(USER_KEY, JSON.stringify(info)) +} + +export function isLoggedIn(): boolean { + return !!getToken() +} diff --git a/frontend/src/views/AccountManager.vue b/frontend/src/views/AccountManager.vue new file mode 100644 index 0000000..776482f --- /dev/null +++ b/frontend/src/views/AccountManager.vue @@ -0,0 +1,58 @@ + + + + diff --git a/frontend/src/views/ApiKeyManager.vue b/frontend/src/views/ApiKeyManager.vue new file mode 100644 index 0000000..ed63739 --- /dev/null +++ b/frontend/src/views/ApiKeyManager.vue @@ -0,0 +1,108 @@ + + + + diff --git a/frontend/src/views/AuditLogManager.vue b/frontend/src/views/AuditLogManager.vue new file mode 100644 index 0000000..10df461 --- /dev/null +++ b/frontend/src/views/AuditLogManager.vue @@ -0,0 +1,39 @@ + + + + diff --git a/frontend/src/views/CategoryManager.vue b/frontend/src/views/CategoryManager.vue new file mode 100644 index 0000000..41a2cbf --- /dev/null +++ b/frontend/src/views/CategoryManager.vue @@ -0,0 +1,124 @@ + + + + diff --git a/frontend/src/views/ChatPanel.vue b/frontend/src/views/ChatPanel.vue new file mode 100644 index 0000000..4b28980 --- /dev/null +++ b/frontend/src/views/ChatPanel.vue @@ -0,0 +1,684 @@ + + + + + diff --git a/frontend/src/views/ConversationManager.vue b/frontend/src/views/ConversationManager.vue new file mode 100644 index 0000000..81f9e75 --- /dev/null +++ b/frontend/src/views/ConversationManager.vue @@ -0,0 +1,83 @@ + + + + diff --git a/frontend/src/views/DashboardPanel.vue b/frontend/src/views/DashboardPanel.vue new file mode 100644 index 0000000..b5dab81 --- /dev/null +++ b/frontend/src/views/DashboardPanel.vue @@ -0,0 +1,144 @@ + + + diff --git a/frontend/src/views/DocDetail.vue b/frontend/src/views/DocDetail.vue new file mode 100644 index 0000000..31d063b --- /dev/null +++ b/frontend/src/views/DocDetail.vue @@ -0,0 +1,130 @@ + + + + + diff --git a/frontend/src/views/DocList.vue b/frontend/src/views/DocList.vue new file mode 100644 index 0000000..b9dbd2c --- /dev/null +++ b/frontend/src/views/DocList.vue @@ -0,0 +1,264 @@ + + + + + diff --git a/frontend/src/views/DocSearch.vue b/frontend/src/views/DocSearch.vue new file mode 100644 index 0000000..2850e1f --- /dev/null +++ b/frontend/src/views/DocSearch.vue @@ -0,0 +1,33 @@ + + + + diff --git a/frontend/src/views/DocStats.vue b/frontend/src/views/DocStats.vue new file mode 100644 index 0000000..cac2eef --- /dev/null +++ b/frontend/src/views/DocStats.vue @@ -0,0 +1,19 @@ + + + diff --git a/frontend/src/views/DocUpload.vue b/frontend/src/views/DocUpload.vue new file mode 100644 index 0000000..a423c11 --- /dev/null +++ b/frontend/src/views/DocUpload.vue @@ -0,0 +1,147 @@ + + + + + diff --git a/frontend/src/views/FaqManager.vue b/frontend/src/views/FaqManager.vue new file mode 100644 index 0000000..ba10d12 --- /dev/null +++ b/frontend/src/views/FaqManager.vue @@ -0,0 +1,157 @@ + + + + diff --git a/frontend/src/views/LoginPage.vue b/frontend/src/views/LoginPage.vue new file mode 100644 index 0000000..2bb3ae7 --- /dev/null +++ b/frontend/src/views/LoginPage.vue @@ -0,0 +1,78 @@ + + + + + diff --git a/frontend/src/views/McpServerManager.vue b/frontend/src/views/McpServerManager.vue new file mode 100644 index 0000000..e4d7ba6 --- /dev/null +++ b/frontend/src/views/McpServerManager.vue @@ -0,0 +1,117 @@ + + + + diff --git a/frontend/src/views/ModelConfigManager.vue b/frontend/src/views/ModelConfigManager.vue new file mode 100644 index 0000000..124017f --- /dev/null +++ b/frontend/src/views/ModelConfigManager.vue @@ -0,0 +1,719 @@ + + + + + diff --git a/frontend/src/views/PipelineFlow.vue b/frontend/src/views/PipelineFlow.vue new file mode 100644 index 0000000..8760c43 --- /dev/null +++ b/frontend/src/views/PipelineFlow.vue @@ -0,0 +1,197 @@ + + + + + diff --git a/frontend/src/views/RoleManager.vue b/frontend/src/views/RoleManager.vue new file mode 100644 index 0000000..8127834 --- /dev/null +++ b/frontend/src/views/RoleManager.vue @@ -0,0 +1,89 @@ + + + + diff --git a/frontend/src/views/SensitiveWordManager.vue b/frontend/src/views/SensitiveWordManager.vue new file mode 100644 index 0000000..e19279d --- /dev/null +++ b/frontend/src/views/SensitiveWordManager.vue @@ -0,0 +1,104 @@ + + + + diff --git a/frontend/src/views/SystemConfigManager.vue b/frontend/src/views/SystemConfigManager.vue new file mode 100644 index 0000000..37a6202 --- /dev/null +++ b/frontend/src/views/SystemConfigManager.vue @@ -0,0 +1,94 @@ + + + + diff --git a/frontend/src/views/UserManager.vue b/frontend/src/views/UserManager.vue new file mode 100644 index 0000000..c4aa35f --- /dev/null +++ b/frontend/src/views/UserManager.vue @@ -0,0 +1,117 @@ + + + + diff --git a/frontend/src/views/WebhookManager.vue b/frontend/src/views/WebhookManager.vue new file mode 100644 index 0000000..106bead --- /dev/null +++ b/frontend/src/views/WebhookManager.vue @@ -0,0 +1,80 @@ + + + + diff --git a/frontend/test_automation.py b/frontend/test_automation.py new file mode 100644 index 0000000..5322519 --- /dev/null +++ b/frontend/test_automation.py @@ -0,0 +1,259 @@ +# -*- coding: utf-8 -*- +""" +Automated test script for Vue 3 frontend app +""" +from playwright.sync_api import sync_playwright +import json +import sys +import io + +# Force UTF-8 output +sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8') +sys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding='utf-8') + +CONSOLE_ERRORS = [] +RESOURCE_ERRORS = [] + +def run(): + with sync_playwright() as p: + browser = p.chromium.launch(headless=True) + context = browser.new_context( + viewport={'width': 1440, 'height': 900}, + ignore_https_errors=True, + ) + page = context.new_page() + + # Collect errors + page.on('console', lambda msg: CONSOLE_ERRORS.append({ + 'type': msg.type, + 'text': msg.text, + }) if msg.type in ('error', 'warning') else None) + + page.on('pageerror', lambda err: CONSOLE_ERRORS.append({ + 'type': 'pageerror', + 'text': str(err), + })) + + page.on('requestfailed', lambda req: RESOURCE_ERRORS.append({ + 'url': req.url, + 'failure': req.failure, + }) if req.failure else None) + + # ========== 1. Home page (login) ========== + print('\n' + '='*60) + print('TEST 1: Home page (login)') + print('='*60) + page.goto('http://localhost:5173', wait_until='networkidle', timeout=30000) + page.wait_for_timeout(2000) + + page.screenshot(path='/tmp/test_01_login.png', full_page=True) + + title = page.title() + print(f'Page title: {title}') + + body_text = page.inner_text('body') + print(f'Body preview: {body_text[:500]}') + + # Check login form elements + inputs = page.locator('input').all() + print(f'Input count: {len(inputs)}') + for i, inp in enumerate(inputs): + placeholder = inp.get_attribute('placeholder') or '' + input_type = inp.get_attribute('type') or 'text' + print(f' Input {i}: type={input_type}, placeholder="{placeholder}"') + + buttons = page.locator('button').all() + print(f'Button count: {len(buttons)}') + for i, btn in enumerate(buttons): + text = btn.inner_text() + print(f' Button {i}: text="{text}"') + + # Try login + try: + username_input = page.locator('input[type="text"]').first + password_input = page.locator('input[type="password"]').first + username_input.fill('admin') + password_input.fill('admin123') + except Exception as e: + print(f'Fill form error: {e}') + + # Click any button containing login text + login_clicked = False + for btn in page.locator('button').all(): + text = btn.inner_text().strip() + if '登' in text or 'login' in text.lower(): + btn.click() + login_clicked = True + print(f'Clicked login button: "{text}"') + break + + if not login_clicked: + # Try pressing Enter instead + page.keyboard.press('Enter') + print('Pressed Enter to submit form') + + page.wait_for_timeout(3000) + page.screenshot(path='/tmp/test_02_after_login.png', full_page=True) + + current_url = page.evaluate('window.location.href') + print(f'Current URL: {current_url}') + current_hash = page.evaluate('window.location.hash') + print(f'Current hash: {current_hash}') + + # Check if we're still on login page or navigated away + body_text = page.inner_text('body') + print(f'After login body preview: {body_text[:500]}') + + # ========== 2. Navigate all pages ========== + routes = [ + '/chat', + '/knowledge/stats', + '/knowledge/document', + '/knowledge/category', + '/knowledge/search', + '/knowledge/faq', + '/conversation', + '/dashboard', + '/settings/role', + '/settings/model-config', + '/settings/sensitive', + '/settings/audit-log', + '/settings/user', + '/settings/api-key', + '/settings/webhook', + '/settings/mcp-server', + '/settings/pipeline-flow', + '/settings/system-config', + ] + + for route in routes: + print(f'\n--- Navigate: {route} ---') + page.evaluate(f'window.location.hash = "{route}"') + page.wait_for_timeout(2000) + + safe_name = route.replace('/', '_') + page.screenshot(path=f'/tmp/route_{safe_name}.png', full_page=True) + + # Count interactive elements on page + btns = page.locator('button').count() + inputs = page.locator('input:not([type="hidden"])').count() + selects = page.locator('.t-select, .t-select__wrap').count() + tables = page.locator('.t-table, table').count() + print(f' Buttons:{btns} Inputs:{inputs} Selects:{selects} Tables:{tables}') + + # ========== 3. Deep check: Model Config page ========== + print('\n' + '='*60) + print('TEST 3: Model Config deep check') + print('='*60) + page.evaluate('window.location.hash = "/settings/model-config"') + page.wait_for_timeout(3000) + page.screenshot(path='/tmp/deep_modelconfig.png', full_page=True) + + # Find and click any "add" button + for btn in page.locator('button').all(): + try: + text = btn.inner_text().strip() + except: + continue + if '添' in text or '新' in text or 'add' in text.lower(): + try: + btn.click() + page.wait_for_timeout(1000) + page.screenshot(path='/tmp/modelconfig_dialog.png', full_page=True) + print(f'Opened dialog via button: "{text}"') + + # Check selects in dialog + dialog_selects = page.locator('.t-dialog .t-select, .t-dialog .t-select__wrap').all() + print(f' Dialog select count: {len(dialog_selects)}') + for ds in dialog_selects[:5]: + try: + ds.click() + page.wait_for_timeout(500) + opts = page.locator('.t-select-option, .t-popup__content .t-select-option').all() + print(f' Options visible: {len(opts)}') + page.keyboard.press('Escape') + page.wait_for_timeout(300) + except Exception as e: + print(f' Select click error: {e}') + + page.keyboard.press('Escape') + page.wait_for_timeout(500) + except Exception as e: + print(f' Button click error: {e}') + break + + # ========== 4. Deep check: Doc List page ========== + print('\n' + '='*60) + print('TEST 4: Doc List page') + print('='*60) + page.evaluate('window.location.hash = "/knowledge/document"') + page.wait_for_timeout(3000) + page.screenshot(path='/tmp/deep_doclist.png', full_page=True) + + # ========== 5. Deep check: FAQ page ========== + print('\n' + '='*60) + print('TEST 5: FAQ Management page') + print('='*60) + page.evaluate('window.location.hash = "/knowledge/faq"') + page.wait_for_timeout(3000) + page.screenshot(path='/tmp/deep_faq.png', full_page=True) + + # ========== 6. Deep check: User Manager page ========== + print('\n' + '='*60) + print('TEST 6: User Manager page') + print('='*60) + page.evaluate('window.location.hash = "/settings/user"') + page.wait_for_timeout(3000) + page.screenshot(path='/tmp/deep_user.png', full_page=True) + + # ========== 7. Final Report ========== + print('\n' + '='*60) + print('FINAL REPORT') + print('='*60) + + # Categorize errors + real_errors = [e for e in CONSOLE_ERRORS if e['type'] in ('error', 'pageerror')] + warnings = [e for e in CONSOLE_ERRORS if e['type'] == 'warning'] + + # Deduplicate + seen = set() + unique_errors = [] + for e in real_errors: + key = e['text'][:100] + if key not in seen: + seen.add(key) + unique_errors.append(e) + + print(f'\nConsole Errors (unique): {len(unique_errors)}') + for e in unique_errors: + print(f' [{e["type"]}] {e["text"][:300]}') + + print(f'\nConsole Warnings (total): {len(warnings)}') + seen_w = set() + for w in warnings: + key = w['text'][:100] + if key not in seen_w: + seen_w.add(key) + print(f' {w["text"][:200]}') + + failed = [r for r in RESOURCE_ERRORS if r.get('failure')] + print(f'\nFailed Resources: {len(failed)}') + for r in failed[:20]: + print(f' {r["url"]}') + + browser.close() + + # Save report + report = { + 'errors': unique_errors, + 'warnings': [w['text'] for w in warnings], + 'failed_resources': [r['url'] for r in failed], + } + with open('/tmp/test_report.json', 'w', encoding='utf-8') as f: + json.dump(report, f, ensure_ascii=False, indent=2) + + print('\nReport saved to /tmp/test_report.json') + + +if __name__ == '__main__': + run() diff --git a/frontend/test_interact.py b/frontend/test_interact.py new file mode 100644 index 0000000..2bea590 --- /dev/null +++ b/frontend/test_interact.py @@ -0,0 +1,192 @@ +# -*- coding: utf-8 -*- +from playwright.sync_api import sync_playwright +import sys, io +import re + +sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8') + +def run(): + with sync_playwright() as p: + browser = p.chromium.launch(headless=True) + page = browser.new_page(viewport={'width': 1440, 'height': 900}) + + errors = [] + page.on('pageerror', lambda err: errors.append(f'PAGE_ERR: {str(err)[:200]}')) + + page.goto('http://localhost:5173', wait_until='networkidle', timeout=30000) + page.wait_for_timeout(1000) + + # Login + page.locator('input[type="text"]').first.fill('admin') + page.locator('input[type="password"]').first.fill('admin123') + page.locator('button').first.click() + page.wait_for_timeout(3000) + + # ===================================================== + # TEST: Model Config modal - check select visibility + # ===================================================== + print('=' * 60) + print('TEST: Model Config 弹窗控件可见性') + print('=' * 60) + + page.evaluate('window.location.hash = "/settings/model-config"') + page.wait_for_timeout(2000) + page.screenshot(path='/tmp/report_model_before.png', full_page=True) + + # Click new config button + add_btn = page.locator('button:has-text("新建配置")') + if add_btn.count() > 0: + add_btn.first.click() + page.wait_for_timeout(1500) + page.screenshot(path='/tmp/report_model_modal.png', full_page=True) + + # Get all selects in dialog + all_selects = page.locator('.t-dialog .t-select__wrap').all() + print(f'弹窗中所有 t-select__wrap: {len(all_selects)}') + for i, s in enumerate(all_selects): + visible = s.is_visible() + bb = s.bounding_box() + print(f' 下拉{i}: visible={visible}, box={bb}') + + # Check provider select specially + provider_select = page.locator('.t-dialog .t-select__wrap').nth(1) # second select + print(f'\nProvider select visible: {provider_select.is_visible()}') + print(f'Provider select box: {provider_select.bounding_box()}') + + # Check model input + model_input = page.locator('.t-dialog input[placeholder*="模型名称"]') + print(f'Model input count: {model_input.count()}') + + # Count ALL inputs in dialog + all_inputs = page.locator('.t-dialog input:not([type="hidden"])') + print(f'\nAll dialog inputs: {all_inputs.count()}') + for i, inp in enumerate(all_inputs.all()[:15]): + tp = inp.get_attribute('type') or 'text' + ph = inp.get_attribute('placeholder') or '' + vis = inp.is_visible() + print(f' input{i}: type={tp}, placeholder="{ph[:40]}", visible={vis}') + + page.keyboard.press('Escape') + page.wait_for_timeout(500) + + # ===================================================== + # TEST: Check the appType filter select in toolbar + # ===================================================== + print('\n' + '=' * 60) + print('TEST: 页面工具栏下拉控件') + print('=' * 60) + + # Back to model config page + toolbar_selects = page.locator('.t-select__wrap, .t-select').all() + visible_selects = [s for s in toolbar_selects if s.is_visible()] + print(f'可见下拉总数: {len(visible_selects)}') + + # ===================================================== + # TEST: Doc upload modal + # ===================================================== + print('\n' + '=' * 60) + print('TEST: 文档管理 - 上传弹窗') + print('=' * 60) + + page.evaluate('window.location.hash = "/knowledge/document"') + page.wait_for_timeout(2000) + + # 检查页面上的上传按钮 - 在旧代码中是 .upload-area 或按钮 + upload_triggers = page.locator('button').all() + upload_found = False + for btn in upload_triggers: + text = btn.inner_text() if btn.is_visible() else '' + if '上传' in text: + print(f'找到上传相关按钮: "{text}"') + btn.click() + page.wait_for_timeout(1000) + upload_found = True + page.screenshot(path='/tmp/report_upload_modal.png', full_page=True) + page.keyboard.press('Escape') + page.wait_for_timeout(500) + break + + if not upload_found: + print('WARNING: 未找到上传按钮') + + # ===================================================== + # TEST: FAQ modal - check selects + # ===================================================== + print('\n' + '=' * 60) + print('TEST: FAQ 弹窗 - 下拉控件详情') + print('=' * 60) + + page.evaluate('window.location.hash = "/knowledge/faq"') + page.wait_for_timeout(2000) + + add_faq = page.locator('button:has-text("添加 FAQ")') + if add_faq.count() > 0: + add_faq.first.click() + page.wait_for_timeout(1000) + page.screenshot(path='/tmp/report_faq_modal.png', full_page=True) + + faq_selects = page.locator('.t-dialog .t-select__wrap').all() + for i, ds in enumerate(faq_selects): + visible = ds.is_visible() + bb = ds.bounding_box() + print(f' FAQ弹窗下拉{i}: visible={visible}, box={bb}') + + page.keyboard.press('Escape') + page.wait_for_timeout(500) + + # ===================================================== + # TEST: Sensitive word modal - check selects + # ===================================================== + print('\n' + '=' * 60) + print('TEST: 敏感词弹窗 - 下拉控件详情') + print('=' * 60) + + page.evaluate('window.location.hash = "/settings/sensitive"') + page.wait_for_timeout(2000) + + add_sw = page.locator('button:has-text("添加敏感词")') + if add_sw.count() > 0: + add_sw.first.click() + page.wait_for_timeout(1000) + page.screenshot(path='/tmp/report_sensitive_modal.png', full_page=True) + + sw_selects = page.locator('.t-dialog .t-select__wrap').all() + for i, ds in enumerate(sw_selects): + visible = ds.is_visible() + bb = ds.bounding_box() + print(f' 敏感词弹窗下拉{i}: visible={visible}, box={bb}') + if visible: + try: + ds.click() + page.wait_for_timeout(400) + opts = page.locator('.t-select-option').all() + print(f' 展开选项数: {len(opts)}') + for o in opts: + print(f' - {o.inner_text()[:50]}') + page.keyboard.press('Escape') + page.wait_for_timeout(200) + except Exception as e: + print(f' 展开异常: {e}') + + page.keyboard.press('Escape') + page.wait_for_timeout(500) + + # ===================================================== + # FINAL SUMMARY + # ===================================================== + print('\n' + '=' * 60) + print('FINAL SUMMARY') + print('=' * 60) + + if errors: + unique_errs = list(set(errors)) + print(f'PAGE ERRORS ({len(unique_errs)}):') + for e in unique_errs: + print(f' {e}') + else: + print('NO PAGE ERRORS') + + browser.close() + +if __name__ == '__main__': + run() diff --git a/frontend/test_targeted.py b/frontend/test_targeted.py new file mode 100644 index 0000000..7f8a261 --- /dev/null +++ b/frontend/test_targeted.py @@ -0,0 +1,61 @@ +# -*- coding: utf-8 -*- +from playwright.sync_api import sync_playwright +import sys, io, re + +sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8') + +def run(): + with sync_playwright() as p: + browser = p.chromium.launch(headless=True) + page = browser.new_page(viewport={'width': 1440, 'height': 900}) + + errors = [] + page.on('pageerror', lambda err: errors.append(str(err))) + + page.goto('http://localhost:5173', wait_until='networkidle', timeout=30000) + page.wait_for_timeout(1000) + + # Login + page.locator('input[type="text"]').first.fill('admin') + page.locator('input[type="password"]').first.fill('admin123') + page.locator('button').first.click() + page.wait_for_timeout(3000) + + # Test all routes after fix + routes = [ + '/chat', '/knowledge/stats', '/knowledge/document', '/knowledge/category', + '/knowledge/search', '/knowledge/faq', '/conversation', '/dashboard', + '/settings/role', '/settings/model-config', '/settings/sensitive', + '/settings/audit-log', '/settings/user', '/settings/api-key', + '/settings/webhook', '/settings/mcp-server', '/settings/pipeline-flow', + '/settings/system-config', + ] + + all_ok = True + for route in routes: + errors.clear() + page.evaluate(f'window.location.hash = "{route}"') + page.wait_for_timeout(2500) + + main = page.locator('.main-content') + if main.count() > 0: + html = main.inner_html() + clean = re.sub(r'', '', html, flags=re.DOTALL) + clean = re.sub(r'\s+', ' ', clean).strip() + has_content = len(clean) > 20 + else: + has_content = False + clean = '' + + errs = list(set(e[:150] for e in errors)) + status = 'OK' if has_content and not errs else ('ERR' if errs else 'EMPTY') + print(f'[{status}] {route} | content_len={len(clean)} | errors={errs}') + + if not has_content or errs: + all_ok = False + + print(f'\n=== ALL OK: {all_ok} ===') + browser.close() + +if __name__ == '__main__': + run() diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json new file mode 100644 index 0000000..25c5aa2 --- /dev/null +++ b/frontend/tsconfig.json @@ -0,0 +1,26 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", + "target": "ES2020", + "useDefineForClassFields": true, + "module": "ESNext", + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "skipLibCheck": true, + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "isolatedModules": true, + "moduleDetection": "force", + "noEmit": true, + "jsx": "preserve", + "strict": true, + "noUnusedLocals": false, + "noUnusedParameters": false, + "noFallthroughCasesInSwitch": true, + "noUncheckedSideEffectImports": true, + "baseUrl": ".", + "paths": { + "@/*": ["./src/*"] + } + }, + "include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue", "src/shims-vue.d.ts"] +} diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts new file mode 100644 index 0000000..d3c85bf --- /dev/null +++ b/frontend/vite.config.ts @@ -0,0 +1,78 @@ +import { defineConfig } from 'vite' +import vue from '@vitejs/plugin-vue' +import { resolve } from 'path' +import AutoImport from 'unplugin-auto-import/vite' +import Components from 'unplugin-vue-components/vite' +import { TDesignResolver } from 'unplugin-vue-components/resolvers' + +// Node 20.18 for Windows 缺少 getRandomValues,手动 polyfill +import crypto from 'node:crypto' +if (!globalThis.crypto) { + (globalThis as any).crypto = crypto +} +if (!(globalThis.crypto as any).getRandomValues) { + (globalThis.crypto as any).getRandomValues = (arr: Uint8Array) => crypto.randomFillSync(arr) +} + +export default defineConfig({ + plugins: [ + vue(), + // TDesign 按需自动引入 + AutoImport({ + resolvers: [TDesignResolver({ library: 'vue-next' })], + }), + Components({ + resolvers: [TDesignResolver({ library: 'vue-next' })], + }), + ], + // 同源部署:资源使用绝对路径 / + base: '/', + resolve: { + alias: { + '@': resolve(__dirname, 'src'), + }, + }, + build: { + outDir: 'dist', + assetsDir: 'assets', + rollupOptions: { + output: { + // 拆出独立 chunk,浏览器可长期缓存 + manualChunks: { + 'tdesign': ['tdesign-vue-next'], + 'echarts': ['mermaid'], // mermaid 体积较大,独立打包 + }, + }, + }, + }, + server: { + port: 5173, + proxy: { + // 开发阶段:API 请求代理到 Spring Boot 后端 9090 + '/ai': 'http://localhost:9090', + '/auth': 'http://localhost:9090', + '/category': 'http://localhost:9090', + '/conversation': 'http://localhost:9090', + '/dashboard': 'http://localhost:9090', + '/document': 'http://localhost:9090', + '/faq': 'http://localhost:9090', + '/feedback': 'http://localhost:9090', + '/model-config': 'http://localhost:9090', + '/sensitive-word': 'http://localhost:9090', + '/upload': 'http://localhost:9090', + '/api-key': 'http://localhost:9090', + '/webhook': 'http://localhost:9090', + '/mcp-server': 'http://localhost:9090', + '/system-config': 'http://localhost:9090', + '/sys-user': 'http://localhost:9090', + '/sys-role': 'http://localhost:9090', + '/tag': 'http://localhost:9090', + '/open-api': 'http://localhost:9090', + '/sdk': 'http://localhost:9090', + '/doc.html': 'http://localhost:9090', + '/swagger-ui': 'http://localhost:9090', + '/v3': 'http://localhost:9090', + '/webjars': 'http://localhost:9090', + }, + }, +}) diff --git a/pom.xml b/pom.xml index bf117f8..4eb7983 100644 --- a/pom.xml +++ b/pom.xml @@ -326,6 +326,58 @@ + + + copy-vite-to-static + prepare-package + + copy-resources + + + ${project.basedir}/src/main/resources/static + true + + + ${project.basedir}/frontend/dist + + + + + + + + + + com.github.eirslett + frontend-maven-plugin + 1.15.1 + + frontend + ${skip.frontend} + + + + + install-frontend-npm + prepare-package + + npm + + + install + + + + + build-frontend-vite + prepare-package + + npm + + + run build + + diff --git a/src/main/java/com/wok/supportbot/app/AssistantApp.java b/src/main/java/com/wok/supportbot/app/AssistantApp.java index 4ee1610..f2e0c74 100644 --- a/src/main/java/com/wok/supportbot/app/AssistantApp.java +++ b/src/main/java/com/wok/supportbot/app/AssistantApp.java @@ -78,16 +78,12 @@ public class AssistantApp { /** AI 熔断时的降级提示语 */ private static final String CIRCUIT_OPEN_MESSAGE = "AI 服务暂时不可用,请稍后重试。"; - /** SSE 状态事件模板 */ - private static final String STATUS_EVENT_FAQD = "event:status\ndata:{\"status\":\"faq_hit\"}\n\n"; - private static final String STATUS_EVENT_GENERATING = "event:status\ndata:{\"status\":\"generating\"}\n\n"; - - private static final String SYSTEM_PROMPT = "";//""\u4f60\u662f\u83dc\u5927\u738b\u5185\u90e8\u77e5\u8bc6\u52a9\u624b\uff0c\u4e3b\u8981\u670d\u52a1\u5bf9\u8c61\u662f\u5ba2\u670d\u3001\u8d22\u52a1\u3001\u884c\u653f\u7b49\u5185\u90e8\u4eba\u5458\uff0c\u4e0d\u662f\u76f4\u63a5\u9762\u5411\u7ec8\u7aef\u5ba2\u6237\u7684\u673a\u5668\u4eba\u3002\n\n\u9ed8\u8ba4\u4e1a\u52a1\u4e0a\u4e0b\u6587\uff1a\n- \u7528\u6237\u672a\u7279\u522b\u8bf4\u660e\u65f6\uff0c\u5e73\u53f0\u3001APP\u3001\u540e\u53f0\u3001\u8ba2\u5355\u3001\u5ba2\u6237\u3001\u4f9b\u5e94\u5546\u5747\u9ed8\u8ba4\u6307\u83dc\u5927\u738b\u76f8\u5173\u7cfb\u7edf\u548c\u4e1a\u52a1\u3002\n- \u4e0d\u8981\u53cd\u95ee\u201c\u54ea\u4e2a\u5e73\u53f0/\u7cfb\u7edf\u201d\uff0c\u9664\u975e\u7528\u6237\u660e\u786e\u63d0\u5230\u5916\u90e8\u5e73\u53f0\u3001\u591a\u4e1a\u52a1\u7ebf\u51b2\u7a81\uff0c\u6216\u95ee\u9898\u672c\u8eab\u5fc5\u987b\u533a\u5206\u7cfb\u7edf\u3002\n\n\u56de\u7b54\u65b9\u5f0f\uff1a\n- \u5185\u90e8\u4eba\u5458\u8be2\u95ee\u5ba2\u6237\u54a8\u8be2\u7c7b\u95ee\u9898\u65f6\uff0c\u5148\u7ed9\u201c\u53ef\u8f6c\u8ff0\u7ed9\u5ba2\u6237\u7684\u7b54\u590d\u53e3\u5f84\u201d\uff0c\u518d\u7ed9\u5185\u90e8\u5904\u7406\u8981\u70b9\u3002\n- \u5bf9\u6d41\u7a0b\u7c7b\u95ee\u9898\uff0c\u4f18\u5148\u76f4\u63a5\u7ed9\u5165\u53e3\u3001\u6b65\u9aa4\u3001\u6761\u4ef6\u548c\u5f02\u5e38\u5904\u7406\uff0c\u4e0d\u8981\u4e00\u5f00\u59cb\u53ea\u8ffd\u95ee\u4fe1\u606f\u3002\n- \u53ea\u6709\u5728\u7f3a\u5c11\u8ba2\u5355\u53f7\u3001\u8ba2\u5355\u72b6\u6001\u3001\u53d1\u7968\u7c7b\u578b\u3001\u8d26\u53f7\u6743\u9650\u7b49\u4f1a\u5f71\u54cd\u529e\u7406\u7ed3\u679c\u7684\u5173\u952e\u4fe1\u606f\u65f6\uff0c\u624d\u8865\u5145\u9700\u8981\u786e\u8ba4\u7684\u4e8b\u9879\u3002\n- \u6d89\u53ca\u8d22\u52a1\u3001\u884c\u653f\u5236\u5ea6\u6216\u654f\u611f\u5185\u90e8\u4fe1\u606f\u65f6\uff0c\u53ea\u8f93\u51fa\u5f53\u524d\u89d2\u8272\u548c\u77e5\u8bc6\u5e93\u5141\u8bb8\u4f7f\u7528\u7684\u5185\u5bb9\uff1b\u8d44\u6599\u4e0d\u8db3\u65f6\u5982\u5b9e\u8bf4\u660e\uff0c\u4e0d\u8981\u7f16\u9020\u3002\n\n\u4fdd\u6301\u4e13\u4e1a\u3001\u7b80\u6d01\u3001\u53ef\u6267\u884c\u3002"; + private static final String SYSTEM_PROMPT = ""; /** * 初始化 ChatClient * - * @param dashscopeChatModel + * @param chatModelFactory */ public AssistantApp(ChatModelFactory chatModelFactory, DatabaseChatMemory chatMemory) { this.chatModelFactory = chatModelFactory; @@ -95,7 +91,7 @@ public class AssistantApp { } private ChatClient getChatClient(String appType) { - return getChatClient(appType, enableMcpTools ? java.util.List.of("*") : null); + return getChatClient(appType, enableMcpTools ? List.of("*") : null); } /** @@ -105,7 +101,7 @@ public class AssistantApp { // 空列表降级为 ["*"](允许所有工具),与无参数版本行为一致 final List effectiveTools; if (enableMcpTools && (allowedMcpTools == null || allowedMcpTools.isEmpty())) { - effectiveTools = java.util.List.of("*"); + effectiveTools = List.of("*"); } else { effectiveTools = allowedMcpTools; } @@ -205,10 +201,12 @@ public class AssistantApp { /** * 流式对话(新入口,委托 {@link ChatPipeline} 编排)。 - * 自动重置 MCP 事件并在流末尾追加工具调用事件行。 + *

+ * 注意:返回类型为 Flux<String>,Spring WebFlux 会对每个元素做 data: 帧包装。 + * 因此 Flux 元素必须只是纯文本内容,不应包含 event: / data: 等 SSE 协议行。 * * @param ctx 对话上下文 - * @return 流式回答 + * @return 纯文本流式回答(每个元素为一段自然语言文本) */ public Flux chatStream(ChatContext ctx) { // 熔断:全局 AI 调用处于熔断状态 @@ -218,8 +216,8 @@ public class AssistantApp { } ChatRequest req = chatPipeline.buildRequest(ctx); if (req.faqHit()) { - // FAQ 命中:先发状态事件告知前端,再返回答案 - return Flux.just(STATUS_EVENT_FAQD, req.faqAnswer().get()); + // FAQ 命中:直接返回答案文本(不附加 SSE 协议行,Spring 会自动做 data: 帧包装) + return Flux.just(req.faqAnswer().get()); } McpToolCallback.resetEvents(); McpToolCallback.resetCallRounds(); @@ -230,9 +228,8 @@ public class AssistantApp { if (StringUtils.hasText(req.finalSystemPrompt())) { spec = spec.system(req.finalSystemPrompt()); } - // 首字节前先发状态事件,让前端感知系统正在生成回复 - Flux statusEvent = Flux.just(STATUS_EVENT_GENERATING); - return statusEvent.concatWith(appendMcpToolEvents(spec.stream().content())) + // 直接返回纯文本流,不附加任何 SSE 协议行 + return spec.stream().content() .doOnComplete(() -> aiCircuitBreaker.recordSuccess(AI_CIRCUIT_KEY)) .doOnError(e -> { aiCircuitBreaker.recordFailure(AI_CIRCUIT_KEY); @@ -256,38 +253,5 @@ public class AssistantApp { return chatPipeline.retrieveSources(ctx); } - // ==================== 内部工具方法 ==================== - - /** - * 在 SSE 文本流末尾追加 MCP 工具调用事件行。 - * 事件格式遵循 SSE 标准,前端由 readSSEStreamWithEvents 解析。 - */ - private Flux appendMcpToolEvents(Flux contentFlux) { - return contentFlux.concatMap(chunk -> Flux.just(chunk)) - .concatWith(Flux.defer(() -> { - java.util.List events = McpToolCallback.drainEvents(); - if (events.isEmpty()) { - return Flux.empty(); - } - log.info("本次对话触发了 {} 个 MCP 工具调用", events.size()); - List sseLines = new ArrayList<>(); - for (McpToolCallback.ToolCallEvent event : events) { - sseLines.add("event:tool_call_result"); - String json = String.format("{\"tool\":\"%s\",\"result\":\"%s\",\"latencyMs\":%d}", - escapeSse(event.tool()), - escapeSse(event.result()), - event.latencyMs()); - sseLines.add("data:" + json); - sseLines.add(""); // SSE 事件分隔空行 - } - return Flux.fromIterable(sseLines); - })); - } - - /** 转义 SSE 数据中的特殊字符 */ - private static String escapeSse(String s) { - if (s == null) return ""; - return s.replace("\\", "\\\\").replace("\"", "\\\"") - .replace("\n", "\\n").replace("\r", "\\r"); - } + // ==================== 内部工具方法已移除,不再需要 ==================== } diff --git a/src/main/java/com/wok/supportbot/security/SdkAuthFilter.java b/src/main/java/com/wok/supportbot/security/SdkAuthFilter.java index d6afb05..9b40324 100644 --- a/src/main/java/com/wok/supportbot/security/SdkAuthFilter.java +++ b/src/main/java/com/wok/supportbot/security/SdkAuthFilter.java @@ -13,10 +13,15 @@ import org.slf4j.MDC; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.core.Ordered; import org.springframework.core.annotation.Order; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; import org.springframework.web.filter.OncePerRequestFilter; import java.io.IOException; +import java.util.List; import java.util.Map; import java.util.Set; @@ -24,11 +29,17 @@ import java.util.UUID; /** * SDK 鉴权过滤器 - * 拦截 /ai/** 请求,校验 SDK JWT Token。 + * 拦截 /ai/** 请求,校验 SDK JWT Token(也兼容管理后台 JWT Token)。 *

* 优先级高于 ApiKeyAuthFilter,在 SecurityFilterChain 之前执行。 * 校验通过后提取 allowedRoleIds 用于角色鉴权。 * 校验失败直接返回 401/403,请求不会到达 AiController。 + *

+ * 兼容两种 Token: + *

    + *
  1. SDK JWT Token — 由 /open-api/auth/token 签发,使用 jwt.sdk-secret 签名
  2. + *
  3. 管理后台 JWT Token — 由 /auth/login 签发,使用 jwt.secret 签名(管理后台 ChatPanel 使用)
  4. + *
*/ @Slf4j @Component @@ -38,6 +49,9 @@ public class SdkAuthFilter extends OncePerRequestFilter { @Autowired private SdkJwtTokenProvider sdkJwtTokenProvider; + @Autowired + private JwtTokenProvider jwtTokenProvider; + private static final ObjectMapper objectMapper = new ObjectMapper(); @Override @@ -73,23 +87,57 @@ public class SdkAuthFilter extends OncePerRequestFilter { return; } - // ② JWT 签名验证 + ③ 过期检查 - Claims claims; + // ② 双通道 Token 验证:先 SDK JWT,再管理后台 JWT + Claims claims = null; + boolean isSdkToken = false; + + // 通道 A:SDK JWT 验证 try { claims = sdkJwtTokenProvider.parseToken(token); + isSdkToken = true; } catch (ExpiredJwtException e) { writeError(response, 401, "令牌已过期"); return; } catch (JwtException e) { - log.warn("SDK Token 验证失败: {}", e.getMessage()); - writeError(response, 401, "无效的认证令牌"); - return; + // SDK 验证失败,尝试管理后台 JWT(通道 B) + log.debug("SDK Token 验证未通过(可能为管理后台 Token),尝试管理后台 JWT: {}", e.getMessage()); + } + + // 通道 B:管理后台 JWT 验证(仅当 SDK 验证失败时尝试) + if (claims == null) { + try { + if (jwtTokenProvider.validateToken(token)) { + // 管理后台 JWT 验证成功,设置 SecurityContext + String username = jwtTokenProvider.getUsernameFromToken(token); + List roles = jwtTokenProvider.getRolesFromToken(token); + List authorities = roles.stream() + .map(role -> new SimpleGrantedAuthority("ROLE_" + role)) + .toList(); + UsernamePasswordAuthenticationToken authentication = + new UsernamePasswordAuthenticationToken(username, null, authorities); + SecurityContextHolder.getContext().setAuthentication(authentication); + log.debug("管理后台 JWT 认证成功: path={}, user={}, roles={}", + request.getServletPath(), username, roles); + chain.doFilter(request, response); + return; + } else { + log.warn("管理后台 JWT 验证失败: path={}", request.getServletPath()); + writeError(response, 401, "无效的认证令牌"); + return; + } + } catch (Exception e) { + log.warn("管理后台 JWT 验证异常: path={}, error={}", + request.getServletPath(), e.getMessage()); + writeError(response, 401, "无效的认证令牌"); + return; + } } - // ④ 提取 allowedRoleIds + // SDK Token 验证成功后的逻辑 + // ③ 提取 allowedRoleIds Set allowedRoleIds = sdkJwtTokenProvider.getAllowedRoleIds(claims); - // ⑤ roleId ∈ allowedRoleIds 校验(仅当请求携带 roleId 参数时) + // ④ roleId ∈ allowedRoleIds 校验(仅当请求携带 roleId 参数时) String roleIdParam = request.getParameter("roleId"); if (roleIdParam != null && !roleIdParam.isEmpty()) { try { diff --git a/src/main/java/com/wok/supportbot/security/SdkJwtTokenProvider.java b/src/main/java/com/wok/supportbot/security/SdkJwtTokenProvider.java index 88829cb..a124b86 100644 --- a/src/main/java/com/wok/supportbot/security/SdkJwtTokenProvider.java +++ b/src/main/java/com/wok/supportbot/security/SdkJwtTokenProvider.java @@ -37,8 +37,8 @@ public class SdkJwtTokenProvider { /** 5 分钟下限 */ private static final long MIN_EXPIRATION = 300000L; - /** 默认密钥标识(禁止使用) */ - private static final String DEFAULT_SECRET = "support-bot-sdk-jwt-secret-2026-please-change"; + /** 默认密钥标识(禁止使用),长度 ≥64 字符以兼容 HS256/HS384/HS512 */ + private static final String DEFAULT_SECRET = "support-bot-sdk-jwt-secret-2026-please-change-to-a-strong-random-value"; public SdkJwtTokenProvider( @Value("${jwt.sdk-secret:support-bot-sdk-jwt-secret-2026-please-change}") String sdkSecret, diff --git a/src/main/java/com/wok/supportbot/security/SecurityConfig.java b/src/main/java/com/wok/supportbot/security/SecurityConfig.java index 281f942..926d3bc 100644 --- a/src/main/java/com/wok/supportbot/security/SecurityConfig.java +++ b/src/main/java/com/wok/supportbot/security/SecurityConfig.java @@ -8,6 +8,7 @@ import org.springframework.security.config.annotation.authentication.configurati import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.config.annotation.web.configuration.WebSecurityCustomizer; import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; import org.springframework.security.config.http.SessionCreationPolicy; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; @@ -60,9 +61,6 @@ public class SecurityConfig { // SDK 专属会话接口(在 /ai/** 路径下,含账户归属校验) // Knife4j / Swagger .requestMatchers("/doc.html", "/swagger-ui/**", "/v3/api-docs/**", "/webjars/**").permitAll() - // 静态资源 - .requestMatchers("/sdk/**", "/css/**", "/js/**", "/components/**").permitAll() - .requestMatchers("/index.html", "/", "/favicon.ico").permitAll() // ========== 管理接口(需要认证) ========== .anyRequest().authenticated() @@ -83,6 +81,20 @@ public class SecurityConfig { return http.build(); } + @Bean + public WebSecurityCustomizer webSecurityCustomizer() { + return web -> web.ignoring().requestMatchers( + // HTML 首页 + "/", "/index.html", + // favicon + "/favicon.ico", "/favicon.svg", + // Vite 构建产物 + "/assets/**", + // SDK 静态资源 + "/sdk/**" + ); + } + @Bean public PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); diff --git a/src/main/java/com/wok/supportbot/service/DocumentService.java b/src/main/java/com/wok/supportbot/service/DocumentService.java index 2001b2a..40f4a25 100644 --- a/src/main/java/com/wok/supportbot/service/DocumentService.java +++ b/src/main/java/com/wok/supportbot/service/DocumentService.java @@ -414,7 +414,9 @@ public class DocumentService { */ public List> getDocumentChunks(Long id) { String documentId = String.valueOf(id); - String sql = "SELECT id::text as id, content, metadata, create_time FROM vector_store " + + String sql = "SELECT id::text as id, content, metadata, " + + "COALESCE((metadata->>'chunkIndex')::int, 0) as chunk_index, " + + "create_time FROM vector_store " + "WHERE metadata->>'documentId' = ? " + " OR metadata->>'document_id' = ? " + " OR metadata->>'docId' = ? " + @@ -446,7 +448,9 @@ public class DocumentService { return List.of(); } - String sql = "SELECT id::text as id, content, metadata, create_time FROM vector_store " + + String sql = "SELECT id::text as id, content, metadata, " + + "COALESCE((metadata->>'chunkIndex')::int, 0) as chunk_index, " + + "create_time FROM vector_store " + "WHERE " + String.join(" OR ", filters) + " " + "ORDER BY " + chunkOrderSql(); if (doc.getChunkCount() != null && doc.getChunkCount() > 0) { diff --git a/src/main/resources/static/components/AccountManager.js b/src/main/resources/static/components/AccountManager.js deleted file mode 100644 index 215b842..0000000 --- a/src/main/resources/static/components/AccountManager.js +++ /dev/null @@ -1,155 +0,0 @@ -/** - * 账号管理 - * 管理对话账号,并为账号绑定默认客服角色。 - */ -import { ref, reactive, onMounted } from 'vue' -import { getAllAccounts, createAccount, updateAccount, deleteAccount, getRoleList } from '../js/api.js' -import { toast } from '../js/utils.js' - -export default { - template: ` -
-

账号管理

-

- 为每个账号绑定一个默认角色。对话页选择账号后,系统会按账号绑定的角色回答,并把会话历史归属到该账号。 -

- -
-
- - - - -
-
- - - -
-
- -
暂无账号
-
- - - - - - - - - - - - - - - - - - - -
账号说明绑定角色状态操作
- {{ account.name }} -
{{ account.account_key }}
-
{{ account.description || '-' }}{{ account.role_name || '未绑定' }}{{ account.enabled === false ? '停用' : '启用' }} - - -
-
-
- `, - setup() { - const accounts = ref([]) - const roles = ref([]) - const editingId = ref('') - const form = reactive({ name: '', description: '', roleId: '', enabled: true }) - - async function loadRoles() { - const json = await getRoleList() - if (json.success) { - roles.value = json.data || [] - } - } - - async function reload() { - try { - const json = await getAllAccounts() - if (json.success) { - accounts.value = json.data || [] - } - } catch (e) { - toast('加载账号失败:' + e.message, 'error') - } - } - - function resetForm() { - editingId.value = '' - form.name = '' - form.description = '' - form.roleId = '' - form.enabled = true - } - - function startEdit(account) { - editingId.value = account.id - form.name = account.name || '' - form.description = account.description || '' - form.roleId = account.role_id || '' - form.enabled = account.enabled !== false - } - - async function submit() { - if (!form.name.trim()) { - toast('请输入账号名称', 'error') - return - } - const payload = { - name: form.name, - description: form.description, - roleId: form.roleId || null, - enabled: form.enabled - } - try { - const json = editingId.value - ? await updateAccount(editingId.value, payload) - : await createAccount(payload) - if (json.success) { - toast(editingId.value ? '账号已更新' : '账号已创建', 'success') - resetForm() - reload() - } else { - toast(json.message || '操作失败', 'error') - } - } catch (e) { - toast('操作失败:' + e.message, 'error') - } - } - - async function remove(account) { - if (!confirm(`确定删除账号「${account.name}」?历史会话记录会保留归属信息。`)) return - try { - const json = await deleteAccount(account.id) - if (json.success) { - toast('账号已删除', 'success') - if (editingId.value === account.id) resetForm() - reload() - } else { - toast(json.message || '删除失败', 'error') - } - } catch (e) { - toast('删除失败:' + e.message, 'error') - } - } - - onMounted(() => { - loadRoles() - reload() - }) - - return { accounts, roles, editingId, form, reload, resetForm, startEdit, submit, remove } - } -} diff --git a/src/main/resources/static/components/ApiKeyManager.js b/src/main/resources/static/components/ApiKeyManager.js deleted file mode 100644 index 274d9ab..0000000 --- a/src/main/resources/static/components/ApiKeyManager.js +++ /dev/null @@ -1,365 +0,0 @@ -/** - * API Key 管理组件 - * 支持 API Key 的生成、吊销、启用、删除、复制、分页查询、角色绑定 - */ -import { ref, onMounted, computed } from 'vue' -import { listApiKeys, createApiKey, revokeApiKey, enableApiKey, deleteApiKey, getAllRoles, updateApiKeyRoles } from '../js/api.js' -import { toast } from '../js/utils.js' - -export default { - template: ` -
-

🔑 API Key 管理

- - -
- - 共 {{ total }} 个 Key -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
名称Key频率限制调用次数绑定角色过期时间状态操作
加载中...
暂无 API Key
-
{{ k.name || '未命名' }}
-
{{ k.description }}
-
-
- {{ maskKey(k.keyValue) }} - -
-
{{ k.rateLimit || 60 }} 次/分钟 - {{ k.currentCalls || 0 }} - / {{ k.maxCalls }} - (不限) - - {{ getBoundRoleCount(k.roleIds) }} 个角色 - 不限(全部) - - {{ k.expireTime ? formatDate(k.expireTime) : '永不过期' }} - - - {{ k.enabled ? '✅ 有效' : '❌ 已吊销' }} - - - - - - -
- - -
- - 第 {{ page }} / {{ Math.ceil(total / pageSize) }} 页(共 {{ total }} 条) - -
- - -
-
-

新建 API Key

-
- - -
-
- - -
-
-
- - -
-
- - -
-
-
- - -
-
- -
暂无可用角色
-
- -
-
-
- - -
-
-
- - -
-
-

✅ API Key 已创建

-

⚠ 请立即复制保存,关闭后将无法再次查看完整 Key。

-
- {{ revealedKey }} -
-
- - -
-
-
- - -
-
-

绑定客服角色 — {{ editingKeyName }}

-

勾选允许此 API Key 使用的客服角色,不勾选任何角色则不限制(可使用所有角色)

- -
加载角色列表...
-
暂无可用角色,请先创建客服角色
-
-
- -
-
- -
- 已选 {{ selectedRoleIds.length }} 个角色{{ selectedRoleIds.length === 0 ? '(不限制)' : '' }} -
- - -
-
-
-
-
- `, - - setup() { - const keys = ref([]) - const loading = ref(false) - const page = ref(1) - const pageSize = ref(20) - const total = ref(0) - const showCreateDialog = ref(false) - const showKeyReveal = ref(false) - const revealedKey = ref('') - const form = ref({ name: '', description: '', rateLimit: null, maxCalls: null, expireTime: '', roleIds: [] }) - - // 角色绑定相关状态 - const allRoles = ref([]) - const roleLoading = ref(false) - const showRoleDialog = ref(false) - const editingKeyId = ref(null) - const editingKeyName = ref('') - const selectedRoleIds = ref([]) - - async function loadList() { - loading.value = true - try { - const res = await listApiKeys(page.value, pageSize.value) - if (res.success) { - keys.value = res.data || [] - total.value = res.total || 0 - } - } catch (e) { - toast('加载失败: ' + e.message, 'error') - } - loading.value = false - } - - async function loadRoles() { - roleLoading.value = true - try { - const res = await getAllRoles() - allRoles.value = res.data || [] - } catch (e) { - console.warn('加载角色列表失败', e) - } - roleLoading.value = false - } - - function openCreateDialog() { - form.value = { name: '', description: '', rateLimit: null, maxCalls: null, expireTime: '', roleIds: [] } - loadRoles() - showCreateDialog.value = true - } - - async function doCreate() { - const data = { name: form.value.name, description: form.value.description || '' } - if (form.value.rateLimit) data.rateLimit = form.value.rateLimit - if (form.value.maxCalls) data.maxCalls = form.value.maxCalls - if (form.value.expireTime) data.expireTime = form.value.expireTime - if (form.value.roleIds && form.value.roleIds.length > 0) data.roleIds = form.value.roleIds - - try { - const res = await createApiKey(data) - if (res.success) { - toast('创建成功', 'success') - showCreateDialog.value = false - // 显示完整 Key 供用户复制 - if (res.data && res.data.keyValue) { - revealedKey.value = res.data.keyValue - showKeyReveal.value = true - } - loadList() - } else { - toast(res.message || '创建失败', 'error') - } - } catch (e) { - toast('创建失败: ' + e.message, 'error') - } - } - - async function doRevoke(id) { - if (!confirm('确认吊销该 API Key?吊销后使用该 Key 的请求将被拒绝。')) return - try { - const res = await revokeApiKey(id) - if (res.success) { toast('已吊销', 'success'); loadList() } - else toast(res.message || '吊销失败', 'error') - } catch (e) { toast('吊销失败: ' + e.message, 'error') } - } - - async function doEnable(id) { - try { - const res = await enableApiKey(id) - if (res.success) { toast('已启用', 'success'); loadList() } - else toast(res.message || '启用失败', 'error') - } catch (e) { toast('启用失败: ' + e.message, 'error') } - } - - async function doDelete(id) { - if (!confirm('确认删除该 API Key?此操作不可恢复。')) return - try { - const res = await deleteApiKey(id) - if (res.success) { toast('已删除', 'success'); loadList() } - else toast(res.message || '删除失败', 'error') - } catch (e) { toast('删除失败: ' + e.message, 'error') } - } - - function openRoleDialog(key) { - editingKeyId.value = key.id - editingKeyName.value = key.name || '未命名' - try { - selectedRoleIds.value = key.roleIds ? JSON.parse(key.roleIds).map(Number) : [] - } catch { selectedRoleIds.value = [] } - loadRoles() - showRoleDialog.value = true - } - - function toggleRole(roleId) { - const idx = selectedRoleIds.value.indexOf(roleId) - if (idx >= 0) selectedRoleIds.value.splice(idx, 1) - else selectedRoleIds.value.push(roleId) - } - - function toggleRole(roleId) { - const idx = selectedRoleIds.value.indexOf(roleId) - if (idx >= 0) selectedRoleIds.value.splice(idx, 1) - else selectedRoleIds.value.push(roleId) - } - - async function doSaveRoles() { - try { - const res = await updateApiKeyRoles(editingKeyId.value, selectedRoleIds.value) - if (res.success) { - toast('角色绑定更新成功', 'success') - showRoleDialog.value = false - loadList() - } else { - toast(res.message || '更新失败', 'error') - } - } catch (e) { - toast('更新失败: ' + e.message, 'error') - } - } - - function getBoundRoleCount(roleIdsStr) { - if (!roleIdsStr) return 0 - try { - const arr = JSON.parse(roleIdsStr) - return Array.isArray(arr) ? arr.length : 0 - } catch { return 0 } - } - - function maskKey(key) { - if (!key || key.length < 12) return key || '***' - return key.substring(0, 6) + '****' + key.substring(key.length - 4) - } - - function copyKey(key) { - if (!key) return - navigator.clipboard.writeText(key).then(() => { - toast('已复制到剪贴板', 'success') - }).catch(() => { - // 降级方案 - const ta = document.createElement('textarea') - ta.value = key - document.body.appendChild(ta) - ta.select() - document.execCommand('copy') - document.body.removeChild(ta) - toast('已复制到剪贴板', 'success') - }) - } - - function formatDate(dateVal) { - if (!dateVal) return '-' - const d = new Date(dateVal) - const y = d.getFullYear() - const m = String(d.getMonth() + 1).padStart(2, '0') - const day = String(d.getDate()).padStart(2, '0') - const h = String(d.getHours()).padStart(2, '0') - const min = String(d.getMinutes()).padStart(2, '0') - return y + '-' + m + '-' + day + ' ' + h + ':' + min - } - - onMounted(() => loadList()) - - return { - keys, loading, page, pageSize, total, - showCreateDialog, showKeyReveal, revealedKey, form, - allRoles, roleLoading, showRoleDialog, editingKeyId, editingKeyName, selectedRoleIds, - loadList, openCreateDialog, doCreate, - doRevoke, doEnable, doDelete, - openRoleDialog, doSaveRoles, - getBoundRoleCount, maskKey, copyKey, formatDate - } - } -} diff --git a/src/main/resources/static/components/AuditLogManager.js b/src/main/resources/static/components/AuditLogManager.js deleted file mode 100644 index 84769a0..0000000 --- a/src/main/resources/static/components/AuditLogManager.js +++ /dev/null @@ -1,103 +0,0 @@ -/** - * 内容审计日志组件 - * 展示敏感词命中记录,支持分页浏览 - */ -import { listAuditLogs } from '../js/api.js' -import { toast } from '../js/utils.js' - -export default { - template: ` -
-

📋 内容审计日志

-

记录所有敏感词命中事件,包含输入/输出方向、违规内容、命中词及处理方式。

- - -
- - - - - - - - - - - - - - - - - - - - - -
时间方向违规内容命中词处理方式
加载中...
暂无审计日志
{{ formatTime(log.createTime) }} - - {{ log.direction === 'INPUT' ? '输入' : '输出' }} - - {{ log.originalText }}{{ formatHitWords(log.hitWords) }} - - {{ log.actionTaken === 'BLOCK' ? '拦截' : log.actionTaken === 'MASK' ? '脱敏' : '通过' }} - -
-
- - -
- - 第 {{ page }} / {{ Math.ceil(total / pageSize) }} 页(共 {{ total }} 条) - -
-
- `, - - data() { - return { - logs: [], - loading: false, - page: 1, - pageSize: 30, - total: 0 - } - }, - - mounted() { - this.loadLogs() - }, - - methods: { - async loadLogs() { - this.loading = true - try { - const res = await listAuditLogs(this.page, this.pageSize) - if (res.success) { - this.logs = res.data?.records || res.data || [] - this.total = res.data?.total || 0 - } else { - toast('加载审计日志失败: ' + (res.message || '未知错误'), 'error') - } - } catch (e) { - toast('加载审计日志失败: ' + e.message, 'error') - } - this.loading = false - }, - - formatTime(t) { return t ? new Date(t).toLocaleString('zh-CN') : '' }, - - formatHitWords(hw) { - if (!hw) return '' - if (hw.type === 'jsonb' && hw.value) { - try { hw = JSON.parse(hw.value) } catch { return hw.value } - } - if (typeof hw === 'string') { try { hw = JSON.parse(hw) } catch { return hw } } - if (hw && hw.hits && Array.isArray(hw.hits)) { - return hw.hits.map(h => `${h.word || ''}(${h.category || ''})`).join('、') - } - if (Array.isArray(hw)) return hw.map(h => typeof h === 'string' ? h : h.word || '').join('、') - return String(hw) - } - } -} diff --git a/src/main/resources/static/components/CategoryManager.js b/src/main/resources/static/components/CategoryManager.js deleted file mode 100644 index e8b3a7d..0000000 --- a/src/main/resources/static/components/CategoryManager.js +++ /dev/null @@ -1,322 +0,0 @@ -/** - * 🏷️ 分类管理 - * P0-3: 支持编辑 + 树形层级展示 + 创建时选择父分类 - */ -import { ref, computed } from 'vue' -import { store } from '../js/store.js' -import { createCategory, updateCategory, deleteCategory } from '../js/api.js' -import { toast } from '../js/utils.js' - -export default { - template: ` -
-

分类管理

- - -
-
创建新分类
-
- - - - - -
-
- - -
- 共 {{ store.categories.length }} 个分类 - -
- -
暂无分类,请在上方创建
-
-
- - -
- - - {{ expandedIds.has(node.id) ? '▼' : '▶' }} - - - - -
- {{ node.name }} - {{ node.description }} - - ({{ node.documentCount || 0 }} 篇文档) - -
-
- - -
- - -
-
-
- - -
-
-

编辑分类

-
-
- - -
-
- - -
-
- - -
-
-
- - -
-
-
-
- `, - setup() { - // ==================== 创建分类 ==================== - const newName = ref('') - const newDescription = ref('') - const newParentId = ref('') - const newSortOrder = ref(0) - - // 父分类选项(排除顶级,只显示可以成为父分类的节点,限制最多2级) - const parentOptions = computed(() => { - const opts = [] - function addOptions(categories, level) { - for (const cat of categories) { - opts.push({ id: cat.id, name: cat.name, level }) - if (cat.children && cat.children.length > 0 && level < 2) { - addOptions(cat.children, level + 1) - } - } - } - addOptions(categoryTree.value, 0) - return opts - }) - - // 父分类下拉显示文本(用 └ 前缀表示层级) - function parentLabel(c) { - return c.level > 0 ? '─'.repeat(c.level) + ' ' + c.name : c.name - } - - async function create() { - if (!newName.value.trim()) { - toast('请输入分类名称', 'error') - return - } - try { - const json = await createCategory({ - name: newName.value.trim(), - description: newDescription.value.trim(), - parentId: newParentId.value || null, - sortOrder: newSortOrder.value - }) - if (json.success) { - toast('分类创建成功', 'success') - newName.value = '' - newDescription.value = '' - newParentId.value = '' - newSortOrder.value = 0 - await store.loadCategories() - } else { - toast(json.message || '创建失败', 'error') - } - } catch (e) { - toast('创建分类失败:' + e.message, 'error') - } - } - - // ==================== 树形展示 ==================== - const expandedIds = ref(new Set()) - - // 构建分类树(从扁平列表构建层级结构) - const categoryTree = computed(() => { - const categories = store.categories || [] - const nodeMap = new Map() - const roots = [] - - // 先创建所有节点 - for (const cat of categories) { - nodeMap.set(cat.id, { ...cat, children: [], level: 0 }) - } - - // 构建父子关系 - for (const node of nodeMap.values()) { - if (node.parentId && node.parentId !== '0' && node.parentId !== 0) { - const parent = nodeMap.get(node.parentId) - if (parent) { - parent.children.push(node) - } else { - roots.push(node) - } - } else { - roots.push(node) - } - } - - // 按 sortOrder 升序排列(与后端一致) - function sortChildren(nodes) { - nodes.sort((a, b) => (a.sortOrder || 0) - (b.sortOrder || 0)) - for (const node of nodes) { - sortChildren(node.children) - } - } - sortChildren(roots) - - return roots - }) - - // 展平树为列表(带层级信息) - const flatTree = computed(() => { - const result = [] - function traverse(nodes, level) { - for (const node of nodes) { - result.push({ - ...node, - level, - hasChildren: node.children && node.children.length > 0 - }) - // 只展示已展开节点的子节点 - if (node.children && node.children.length > 0 && expandedIds.value.has(node.id)) { - traverse(node.children, level + 1) - } - } - } - traverse(categoryTree.value, 0) - return result - }) - - function toggleExpand(id) { - const newSet = new Set(expandedIds.value) - if (newSet.has(id)) { - newSet.delete(id) - } else { - newSet.add(id) - } - expandedIds.value = newSet - } - - // 初始时展开所有有子节点的分类 - function expandAll() { - const ids = new Set() - function collectIds(nodes) { - for (const node of nodes) { - if (node.children && node.children.length > 0) { - ids.add(node.id) - collectIds(node.children) - } - } - } - collectIds(categoryTree.value) - expandedIds.value = ids - } - - // 加载分类后自动展开 - store.loadCategories().then(() => { - setTimeout(expandAll, 100) - }) - - // ==================== 编辑分类 ==================== - const editModal = ref({ - visible: false, - id: null, - name: '', - description: '', - sortOrder: 0 - }) - - function openEdit(node) { - editModal.value = { - visible: true, - id: node.id, - name: node.name, - description: node.description || '', - sortOrder: node.sortOrder || 0 - } - } - - function closeEdit() { - editModal.value = { visible: false, id: null, name: '', description: '', sortOrder: 0 } - } - - async function saveEdit() { - if (!editModal.value.name.trim()) { - toast('请输入分类名称', 'error') - return - } - try { - const json = await updateCategory(editModal.value.id, { - name: editModal.value.name.trim(), - description: editModal.value.description.trim(), - sortOrder: editModal.value.sortOrder - }) - if (json.success) { - toast('分类更新成功', 'success') - closeEdit() - await store.loadCategories() - setTimeout(expandAll, 100) - } else { - toast(json.message || '更新失败', 'error') - } - } catch (e) { - toast('更新分类失败:' + e.message, 'error') - } - } - - // ==================== 删除分类 ==================== - async function remove(node) { - const childCount = countDescendants(node) - const childMsg = childCount > 0 ? `\n注意:此分类下有 ${childCount} 个子分类,将一并清空关联` : '' - if (!confirm(`确定删除分类"${node.name}"?关联文档将变为未分类${childMsg}`)) return - try { - const json = await deleteCategory(node.id) - if (json.success) { - toast('分类已删除', 'success') - await store.loadCategories() - setTimeout(expandAll, 100) - } else { - toast(json.message || '删除失败', 'error') - } - } catch (e) { - toast('删除分类失败:' + e.message, 'error') - } - } - - function countDescendants(node) { - if (!node.children || node.children.length === 0) return 0 - let count = node.children.length - for (const child of node.children) { - count += countDescendants(child) - } - return count - } - - return { - newName, newDescription, newParentId, newSortOrder, parentOptions, parentLabel, - create, store, flatTree, expandedIds, toggleExpand, - editModal, openEdit, closeEdit, saveEdit, remove - } - } -} diff --git a/src/main/resources/static/components/ChatPanel.js b/src/main/resources/static/components/ChatPanel.js deleted file mode 100644 index b348fa8..0000000 --- a/src/main/resources/static/components/ChatPanel.js +++ /dev/null @@ -1,529 +0,0 @@ -/** - * 智能客服对话面板 - */ -import { ref, computed, nextTick, onMounted } from 'vue' -import { chatSync, chatRagSync, chatSSEUrl, chatRagSSEUrl, getRoleList, getActiveModelConfig, truncateConversation, ragSources, submitFeedback as submitFeedbackApi } from '../js/api.js' -import { toast, readSSEStream, readSSEStreamWithEvents, renderMarkdown } from '../js/utils.js' -import { store } from '../js/store.js' -import MessageSources from './MessageSources.js' - -const FALLBACK_ROLE = { - id: '', - key: 'general', - name: '客服', - desc: '可检索全部知识库', - tone: '用户咨询、业务办理、常见问题、问题受理与进度说明', - badge: '默认', - categoryIds: [] -} - -const QUICK_QUESTIONS = [ - '我的问题应该找哪个部门处理?', - '如何办理业务申请?', - '流程进度一直没更新怎么办?', - '费用报销需要准备哪些材料?', - '办公用品或资产怎么申请?' -] - -export default { - components: { 'message-sources': MessageSources }, - template: ` -
- - -
-
-
-

{{ currentRole.name }}

-
- {{ ragStatusText }} - ·{{ selectedCategoryNames.length ? selectedCategoryNames.join(String.fromCharCode(12289)) : '\u5168\u90e8\u77e5\u8bc6\u5e93' }} -
-
-
-
- 调用模型 - {{ activeModelText }} -
- - - -
-
- -
- -
- -
-
-
- - -
-
-
- -
-
- - -
-
-
-
- `, - setup() { - const chatId = ref('') - const mode = ref('sync') - const selectedRole = ref('general') - const roles = ref([FALLBACK_ROLE]) - const isRagMode = ref(false) - const ragStrategy = ref('MULTI_QUERY') - const activeModel = ref(null) - const modelLoadError = ref('') - const userInput = ref('') - const lastUserInput = ref('') - const isSending = ref(false) - const messages = ref([ - { - role: 'assistant', - content: '\u60a8\u597d\uff0c\u6211\u662f\u667a\u80fd\u5ba2\u670d\u52a9\u624b\u3002\u53ef\u4ee5\u54a8\u8be2\u5ba2\u670d\u3001\u8d22\u52a1\u3001\u884c\u653f\u76f8\u5173\u95ee\u9898\uff1b\u9700\u8981\u57fa\u4e8e\u77e5\u8bc6\u5e93\u56de\u7b54\u65f6\uff0c\u53ef\u4ee5\u5728\u4e0a\u65b9\u5f00\u542f RAG \u68c0\u7d22\u3002', - streaming: false, - time: formatTime() - } - ]) - const msgArea = ref(null) - const editingIndex = ref(-1) - const editingText = ref('') - - const currentRole = computed(() => roles.value.find(role => role.key === selectedRole.value) || roles.value[0] || FALLBACK_ROLE) - const selectedCategoryIds = computed(() => currentRole.value.categoryIds || []) - const selectedCategoryNames = computed(() => { - const selected = new Set(selectedCategoryIds.value) - return store.categories - .filter(category => selected.has(String(category.id))) - .map(category => category.name) - }) - const activeModelText = computed(() => { - if (activeModel.value) { - const provider = providerLabel(activeModel.value.provider) - const modelName = activeModel.value.modelName || activeModel.value.model_name || '-' - return provider ? `${provider} / ${modelName}` : modelName - } - return modelLoadError.value ? '未配置' : '加载中' - }) - const modelTitle = computed(() => { - if (activeModel.value) { - const model = activeModel.value - const name = model.name ? `${model.name}:` : '' - const provider = providerLabel(model.provider) - const modelName = model.modelName || model.model_name || '-' - return `对话模型:${name}${provider ? provider + ' / ' : ''}${modelName}` - } - return modelLoadError.value || '正在加载对话模型' - }) - const ragStatusText = computed(() => { - if (!isRagMode.value) return '普通对话' - const names = { - NONE: 'RAG:不重写', - REWRITE: 'RAG:查询重写', - TRANSLATION: 'RAG:翻译扩展', - COMPRESSION: 'RAG:查询压缩', - MULTI_QUERY: 'RAG:多路扩展' - } - return names[ragStrategy.value] || 'RAG 已启用' - }) - - function formatTime() { - return new Date().toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' }) - } - - function generateMsgId() { - return 'msg_' + Date.now() + '_' + Math.random().toString(36).substring(2, 8) - } - - function newChatId() { - chatId.value = 'web_' + Date.now() + '_' + Math.random().toString(36).slice(2, 8) - } - function selectRole(roleKey) { - selectedRole.value = roleKey - newChatId() - clearMessages(roleKey) - } - function clearMessages(roleKey = selectedRole.value) { - const role = roles.value.find(item => item.key === roleKey) || FALLBACK_ROLE - messages.value = [{ - role: 'assistant', - content: `已切换到${role.name}。请直接输入你的问题。`, - streaming: false, - time: formatTime() - }] - } - - function useQuickQuestion(question) { - userInput.value = question - send() - } - - function providerLabel(provider) { - const map = { - dashscope: '通义千问', - openai: 'OpenAI', - volcengine: '豆包', - zhipu: '智谱', - baidu: '百度千帆', - other: '自定义' - } - return map[String(provider || '').toLowerCase()] || provider || '' - } - - async function loadActiveModel() { - try { - const json = await getActiveModelConfig('CHAT') - if (json.success) { - activeModel.value = json.data - modelLoadError.value = '' - } else { - activeModel.value = null - modelLoadError.value = json.message || '对话模型未配置' - } - } catch (e) { - activeModel.value = null - modelLoadError.value = e.message || '模型加载失败' - } - } - - async function loadRoles() { - try { - const json = await getRoleList() - if (json.success) { - roles.value = (json.data || []).map(role => ({ - id: role.id, - key: role.role_key || role.roleKey || String(role.id), - name: role.name, - desc: role.description || '', - tone: role.description || '专业、耐心、简洁', - badge: role.name ? role.name.slice(0, 2) : '角色', - categoryIds: (role.categoryIds || []).map(String) - })) - if (!roles.value.some(role => role.key === selectedRole.value)) { - selectedRole.value = roles.value[0]?.key || 'general' - } - } - } catch (e) { - toast('加载客服角色失败:' + e.message, 'error') - } - } - - - function currentRoleId() { - // 角色人设与知识库范围统一由服务端依据 roleId 套用,这里仅取出当前角色ID。 - // 空ID(如未加载完成的兜底角色)时返回空串,服务端将退回基础提示词与全量知识库。 - return currentRole.value && currentRole.value.id ? String(currentRole.value.id) : '' - } - - - async function scrollToBottom() { - await nextTick() - if (msgArea.value) msgArea.value.scrollTop = msgArea.value.scrollHeight - } - - async function send() { - const text = userInput.value.trim() - if (!text || isSending.value) return - - userInput.value = '' - lastUserInput.value = text - isSending.value = true - messages.value.push({ id: generateMsgId(), role: 'user', content: text, streaming: false, time: formatTime() }) - - const assistantMsg = { id: generateMsgId(), role: 'assistant', content: '', streaming: true, time: formatTime(), sources: [], toolCalls: [] } - messages.value.push(assistantMsg) - await scrollToBottom() - - const cid = chatId.value || ('web_' + Date.now()) - chatId.value = cid - // 发送用户原始问题(不再包装角色信息,避免污染会话记忆); - // 角色人设与知识库范围由服务端依据 roleId 强制套用。 - const roleId = currentRoleId() - - try { - if (isRagMode.value && mode.value === 'sync') { - assistantMsg.content = await chatRagSync(text, cid, ragStrategy.value, roleId) - } else if (isRagMode.value) { - const url = chatRagSSEUrl(text, cid, ragStrategy.value, roleId) - await readSSEStreamWithEvents(url, { - onMessage: async (chunk) => { - assistantMsg.content += chunk - await scrollToBottom() - }, - onToolCallStart: (data) => { - assistantMsg.toolCalls.push({ tool: data.tool, input: data.input, status: 'running', result: null }) - scrollToBottom() - }, - onToolCallResult: (data) => { - const tc = assistantMsg.toolCalls.find(t => t.tool === data.tool && t.status === 'running') - if (tc) { - tc.status = 'done' - tc.result = data.result - tc.latencyMs = data.latencyMs - } - scrollToBottom() - }, - onError: (data) => { - assistantMsg.content += '\n\n⚠️ ' + (data.message || '工具调用出错') - }, - onDone: () => {} - }) - } else if (mode.value === 'sync') { - assistantMsg.content = await chatSync(text, cid, roleId) - } else { - const url = chatSSEUrl(text, cid, roleId) - await readSSEStream(url, async (chunk) => { - assistantMsg.content += chunk - await scrollToBottom() - }, () => {}) - } - // RAG 模式:回答完成后拉取本次命中的知识库片段,挂到该条回答下方 - if (isRagMode.value) { - try { - const sj = await ragSources(text, cid, ragStrategy.value, roleId) - if (sj && sj.success) assistantMsg.sources = sj.data || [] - } catch (_) { /* 来源获取失败不影响主回答 */ } - } - } catch (e) { - assistantMsg.content = '请求失败:' + e.message - assistantMsg.error = true - toast('对话失败:' + e.message, 'error') - } finally { - assistantMsg.streaming = false - isSending.value = false - await scrollToBottom() - } - } - - function retryLast() { - if (!lastUserInput.value || isSending.value) return - userInput.value = lastUserInput.value - send() - } - - function startEditMessage(i) { - if (isSending.value) return - editingIndex.value = i - editingText.value = messages.value[i].content || '' - } - - function cancelEdit() { - editingIndex.value = -1 - editingText.value = '' - } - - async function submitEdit(i) { - const text = editingText.value.trim() - if (!text || isSending.value) return - // 这是第几条用户消息(1-based,忽略问候语 / AI 消息) - let userTurn = 0 - for (let j = 0; j <= i; j++) { - if (messages.value[j].role === 'user') userTurn++ - } - // 先让后端清掉这条用户消息及其之后的全部消息(修复对话记忆,避免污染上下文) - try { - const json = await truncateConversation(chatId.value, userTurn) - if (json && json.success === false) { - toast(json.message || '截断失败', 'error') - return - } - } catch (e) { - toast('截断失败:' + e.message, 'error') - return - } - // 切掉本地的本条及其之后消息,再用新内容重发 - messages.value = messages.value.slice(0, i) - editingIndex.value = -1 - editingText.value = '' - userInput.value = text - await send() - } - - async function regenerate(i) { - if (isSending.value) return - // 找到这条 AI 回答对应的上一条用户消息 - let userIndex = -1 - for (let j = i - 1; j >= 0; j--) { - if (messages.value[j].role === 'user') { userIndex = j; break } - } - if (userIndex < 0) return - const text = messages.value[userIndex].content - let userTurn = 0 - for (let j = 0; j <= userIndex; j++) { - if (messages.value[j].role === 'user') userTurn++ - } - // 截断该用户消息及其之后(含本条回答),用相同问题重新生成 - try { - const json = await truncateConversation(chatId.value, userTurn) - if (json && json.success === false) { toast(json.message || '重新生成失败', 'error'); return } - } catch (e) { toast('重新生成失败:' + e.message, 'error'); return } - messages.value = messages.value.slice(0, userIndex) - userInput.value = text - await send() - } - - async function copyMessage(content) { - try { - await navigator.clipboard.writeText(content) - toast('已复制回复', 'success') - } catch (e) { - toast('复制失败', 'error') - } - } - - // ===== P0-002: 消息反馈 ===== - async function submitFeedback(msgId, type) { - const msgIndex = messages.value.findIndex(m => m.id === msgId && m.role === 'assistant') - if (msgIndex === -1) return - - const msg = messages.value[msgIndex] - const wasActive = msg.feedback === type - const newFeedback = wasActive ? null : type - msg.feedback = newFeedback - - try { - await submitFeedbackApi({ - messageId: String(msgId), - conversationId: chatId.value, - feedbackType: newFeedback ? (newFeedback === 'up' ? 'THUMBS_UP' : 'THUMBS_DOWN') : null - }) - if (newFeedback) { - toast(newFeedback === 'up' ? '感谢反馈 👍' : '感谢反馈,我们会持续改进', 'success') - } - } catch (e) { - console.error('反馈提交失败:', e) - } - } - - onMounted(() => { - newChatId() - store.loadCategories() - loadRoles() - loadActiveModel() - }) - - return { - store, - roles, - quickQuestions: QUICK_QUESTIONS, - chatId, - mode, - selectedRole, - selectedCategoryNames, - isRagMode, - ragStrategy, - activeModelText, - modelTitle, - modelLoadError, - userInput, - isSending, - messages, - msgArea, - currentRole, - ragStatusText, - newChatId, - selectRole, - clearMessages, - useQuickQuestion, - send, - retryLast, - copyMessage, - renderMd: renderMarkdown, - editingIndex, - editingText, - startEditMessage, - cancelEdit, - submitEdit, - regenerate, - submitFeedback - } - } -} \ No newline at end of file diff --git a/src/main/resources/static/components/ConversationManager.js b/src/main/resources/static/components/ConversationManager.js deleted file mode 100644 index f3e8c48..0000000 --- a/src/main/resources/static/components/ConversationManager.js +++ /dev/null @@ -1,255 +0,0 @@ -/** - * 💬 会话管理组件 - * 展示会话列表、消息详情、删除、导出功能 - */ -import { ref } from 'vue' -import { listConversations, deleteConversation, getConversationMessages, getConversationStats, exportConversation, getRoleList } from '../js/api.js' -import { toast, formatDate } from '../js/utils.js' - -export default { - template: ` -
-

会话列表

- - -
-
-
{{ stats.totalConversations || 0 }}
-
总会话数
-
-
-
{{ stats.totalMessages || 0 }}
-
总消息数
-
-
-
{{ stats.todayConversations || 0 }}
-
今日会话
-
-
-
{{ stats.todayMessages || 0 }}
-
今日消息
-
-
- - -
- - - - - -
- - -
- - - - - - - - - - - - - - - - - - - - - - - - - - -
会话ID外部用户ID角色消息数最后消息时间最后消息预览操作
暂无会话记录
- {{ c.conversationId }} - {{ c.accountId || '-' }}{{ c.roleName || '-' }}{{ c.messageCount }}{{ formatDate(c.lastMessageTime) }} - {{ c.lastMessagePreview || '-' }} - - - - -
-
- - - -
- - - - `, - setup() { - const conversations = ref([]) - const page = ref(1) - const pages = ref(1) - const total = ref(0) - const keyword = ref('') - const roleFilter = ref('') - const accountFilter = ref('') - const roles = ref([]) - const stats = ref(null) - - // 消息详情弹窗 - const messageModal = ref({ - visible: false, - conversationId: '', - roleName: '', - messages: [] - }) - - async function load(p = 1) { - page.value = p - try { - const json = await listConversations( - p, 10, - keyword.value || undefined, - accountFilter.value || undefined, - roleFilter.value || undefined - ) - if (json.success) { - conversations.value = json.data || [] - total.value = json.total || 0 - pages.value = json.pages || 1 - } else { - toast(json.message || '查询失败', 'error') - } - } catch (e) { - toast('加载会话失败:' + e.message, 'error') - } - } - - async function loadFilters() { - try { - const roleJson = await getRoleList() - if (roleJson.success) roles.value = roleJson.data || [] - } catch (e) { - console.error('加载筛选项失败', e) - } - } - async function loadStats() { - try { - const json = await getConversationStats() - if (json.success) { - stats.value = json.data - } - } catch (e) { - console.error('加载会话统计失败', e) - } - } - - async function viewMessages(conversationId) { - try { - const json = await getConversationMessages(conversationId) - if (json.success) { - messageModal.value.conversationId = conversationId - messageModal.value.messages = json.data || [] - const current = conversations.value.find(item => item.conversationId === conversationId) - messageModal.value.roleName = current?.roleName || '' - messageModal.value.visible = true - } else { - toast(json.message || '加载消息失败', 'error') - } - } catch (e) { - toast('加载消息失败:' + e.message, 'error') - } - } - - function closeMessageModal() { - messageModal.value.visible = false - messageModal.value.conversationId = '' - messageModal.value.roleName = '' - messageModal.value.messages = [] - } - - async function remove(conversationId) { - if (!confirm('确定删除此会话?该会话下的所有消息将被逻辑删除')) return - try { - const json = await deleteConversation(conversationId) - if (json.success) { - toast(`会话删除成功,共删除 ${json.deletedMessages || 0} 条消息`, 'success') - load(page.value) - loadStats() - } else { - toast(json.message || '删除失败', 'error') - } - } catch (e) { - toast('删除失败:' + e.message, 'error') - } - } - - async function downloadExport(conversationId) { - try { - const content = await exportConversation(conversationId) - const blob = new Blob([content], { type: 'text/plain;charset=utf-8' }) - const url = URL.createObjectURL(blob) - const a = document.createElement('a') - a.href = url - a.download = `conversation_${conversationId}.txt` - document.body.appendChild(a) - a.click() - document.body.removeChild(a) - URL.revokeObjectURL(url) - toast('导出成功', 'success') - } catch (e) { - toast('导出失败:' + e.message, 'error') - } - } - - // 初始加载 - loadFilters() - load() - loadStats() - - return { - conversations, page, pages, total, keyword, accountFilter, roleFilter, roles, stats, messageModal, - load, loadStats, viewMessages, closeMessageModal, remove, downloadExport, formatDate - } - } -} diff --git a/src/main/resources/static/components/DashboardPanel.js b/src/main/resources/static/components/DashboardPanel.js deleted file mode 100644 index 118ed64..0000000 --- a/src/main/resources/static/components/DashboardPanel.js +++ /dev/null @@ -1,340 +0,0 @@ -/** - * 运营看板组件 - * 指标卡片 + Chart.js 趋势图表 + 知识库文档命中排行 + 未命中问题收集 - */ -import { ref, computed, onMounted, onBeforeUnmount, nextTick } from 'vue' -import { getDashboardOverview, getDashboardTrend, getDashboardKnowledge, getDashboardCustom } from '../js/api.js' - -export default { - template: ` -
- -
-
-

📈 运营数据看板

-
- - - | - - - - -
-
-
- - -
-
-
{{ overview.conversationCount ?? 0 }}
-
今日对话数
-
-
-
{{ formatPercent(overview.satisfactionRate) }}%
-
满意率
-
-
-
{{ formatPercent(overview.ragHitRate) }}%
-
知识库命中率
-
-
-
{{ overview.avgResponseTime ?? 0 }}ms
-
平均响应时间
-
-
- - -
- -
-

📊 对话量趋势

-
- -
-
- -
-

📊 满意度趋势

-
- -
-
-
- - -
- -
-

📚 文档命中排行 TOP-10

-
暂无数据
- - - - - - - - - - - - - - - - - -
排名文档标题命中次数平均得分
{{ idx + 1 }}{{ doc.document_title || '-' }}{{ doc.hit_count ?? doc.cnt ?? 0 }}{{ doc.avg_score != null ? Number(doc.avg_score).toFixed(3) : '-' }}
-
- - -
-

❓ 未命中问题收集

-
暂无数据
-
-
- {{ idx + 1 }}. - {{ item.user_query }} - {{ formatDateShort(item.last_time) }} -
-
-
-
- - -
加载中...
-
- `, - setup() { - const loading = ref(false) - const rangeDays = ref(7) - const customStart = ref('') - const customEnd = ref('') - const overview = ref({}) - const trendData = ref([]) - const topHitDocuments = ref([]) - const missQuestions = ref([]) - - // Chart.js 实例引用(用于销毁重建) - let conversationChart = null - let satisfactionChart = null - const conversationChartRef = ref(null) - const satisfactionChartRef = ref(null) - - // 满意率颜色:>=80% 绿色,>=60% 橙色,<60% 红色 - const satisfactionColor = computed(() => { - const rate = overview.value.satisfactionRate || 0 - if (rate >= 80) return 'var(--success)' - if (rate >= 60) return 'var(--warn)' - return 'var(--danger)' - }) - - // 格式化百分比(保留一位小数) - function formatPercent(val) { - if (val == null) return '0.0' - return Number(val).toFixed(1) - } - - // 格式化日期简写(仅月-日) - function formatDateShort(dateVal) { - if (!dateVal) return '' - const d = new Date(dateVal) - return `${d.getMonth() + 1}/${d.getDate()}` - } - - // 设置时间范围并加载趋势 - async function setRange(days) { - rangeDays.value = days - await loadTrend(days) - } - - // 加载今日概览 - async function loadOverview() { - try { - const res = await getDashboardOverview() - if (res.success) overview.value = res.data - } catch (e) { - console.error('加载概览数据失败', e) - } - } - - // 加载趋势数据 - async function loadTrend(days) { - try { - const res = await getDashboardTrend(days) - if (res.success) { - trendData.value = res.data || [] - await nextTick() - renderCharts() - } - } catch (e) { - console.error('加载趋势数据失败', e) - } - } - - // 加载自定义范围数据 - async function loadCustomRange() { - if (!customStart.value || !customEnd.value) return - loading.value = true - try { - const res = await getDashboardCustom(customStart.value, customEnd.value) - if (res.success) { - trendData.value = res.data || [] - rangeDays.value = 0 - await nextTick() - renderCharts() - } - } catch (e) { - console.error('加载自定义范围数据失败', e) - } finally { - loading.value = false - } - } - - // 加载知识库分析 - async function loadKnowledgeAnalysis() { - try { - const res = await getDashboardKnowledge() - if (res.success) { - topHitDocuments.value = res.data?.topHitDocuments || [] - missQuestions.value = res.data?.missQuestions || [] - } - } catch (e) { - console.error('加载知识库分析数据失败', e) - } - } - - // 渲染 Chart.js 图表 - async function renderCharts() { - const { Chart, LineController, LineElement, PointElement, LinearScale, CategoryScale, Tooltip, Legend, Filler } = await import('chart.js') - - // 注册所需组件 - Chart.register(LineController, LineElement, PointElement, LinearScale, CategoryScale, Tooltip, Legend, Filler) - - const labels = trendData.value.map(s => { - const d = new Date(s.snapshotDate || s.snapshot_date) - return `${d.getMonth() + 1}/${d.getDate()}` - }) - - // === 对话量趋势图 === - if (conversationChart) conversationChart.destroy() - if (conversationChartRef.value) { - const convData = trendData.value.map(s => s.conversationCount ?? s.conversation_count ?? 0) - const msgData = trendData.value.map(s => s.messageCount ?? s.message_count ?? 0) - - conversationChart = new Chart(conversationChartRef.value, { - type: 'line', - data: { - labels, - datasets: [ - { - label: '对话数', - data: convData, - borderColor: '#3b82f6', - backgroundColor: 'rgba(59,130,246,0.1)', - fill: true, - tension: 0.3, - pointRadius: 4, - pointHoverRadius: 6 - }, - { - label: '消息数', - data: msgData, - borderColor: '#8b5cf6', - backgroundColor: 'rgba(139,92,246,0.1)', - fill: true, - tension: 0.3, - pointRadius: 4, - pointHoverRadius: 6 - } - ] - }, - options: { - responsive: true, - maintainAspectRatio: false, - plugins: { legend: { position: 'bottom', labels: { boxWidth: 12, padding: 16, font: { size: 12 } } } }, - scales: { - y: { beginAtZero: true, ticks: { font: { size: 11 } }, grid: { color: 'rgba(0,0,0,0.05)' } }, - x: { ticks: { font: { size: 11 } }, grid: { display: false } } - } - } - }) - } - - // === 满意度趋势图(👍/👎 双线) === - if (satisfactionChart) satisfactionChart.destroy() - if (satisfactionChartRef.value) { - const upData = trendData.value.map(s => s.thumbsUpCount ?? s.thumbs_up_count ?? 0) - const downData = trendData.value.map(s => s.thumbsDownCount ?? s.thumbs_down_count ?? 0) - - satisfactionChart = new Chart(satisfactionChartRef.value, { - type: 'line', - data: { - labels, - datasets: [ - { - label: '👍 有帮助', - data: upData, - borderColor: '#10b981', - backgroundColor: 'rgba(16,185,129,0.1)', - fill: true, - tension: 0.3, - pointRadius: 4, - pointHoverRadius: 6 - }, - { - label: '👎 没帮助', - data: downData, - borderColor: '#ef4444', - backgroundColor: 'rgba(239,68,68,0.1)', - fill: true, - tension: 0.3, - pointRadius: 4, - pointHoverRadius: 6 - } - ] - }, - options: { - responsive: true, - maintainAspectRatio: false, - plugins: { legend: { position: 'bottom', labels: { boxWidth: 12, padding: 16, font: { size: 12 } } } }, - scales: { - y: { beginAtZero: true, ticks: { font: { size: 11 } }, grid: { color: 'rgba(0,0,0,0.05)' } }, - x: { ticks: { font: { size: 11 } }, grid: { display: false } } - } - } - }) - } - } - - // 销毁图表 - function destroyCharts() { - if (conversationChart) { conversationChart.destroy(); conversationChart = null } - if (satisfactionChart) { satisfactionChart.destroy(); satisfactionChart = null } - } - - onMounted(async () => { - loading.value = true - await Promise.all([ - loadOverview(), - loadTrend(7), - loadKnowledgeAnalysis() - ]) - loading.value = false - }) - - onBeforeUnmount(() => { - destroyCharts() - }) - - return { - loading, rangeDays, customStart, customEnd, - overview, trendData, topHitDocuments, missQuestions, - conversationChartRef, satisfactionChartRef, - satisfactionColor, formatPercent, formatDateShort, - setRange, loadCustomRange - } - } -} diff --git a/src/main/resources/static/components/DocDetail.js b/src/main/resources/static/components/DocDetail.js deleted file mode 100644 index 03bc347..0000000 --- a/src/main/resources/static/components/DocDetail.js +++ /dev/null @@ -1,186 +0,0 @@ -/** - * 📄 文档详情弹窗 - * P1-2.5: 分块编辑/删除 - * P1-2.6: Markdown 渲染预览 - */ -import { computed, ref } from 'vue' -import { store } from '../js/store.js' -import { updateChunk, deleteChunk } from '../js/api.js' -import { formatBytes, formatDate, renderMarkdown } from '../js/utils.js' -import { toast } from '../js/utils.js' - -export default { - template: ` -
- -
- `, - setup() { - const statusClass = computed(() => { - const status = store.detailModal.doc?.status - return status === 'READY' ? 'status-ready' - : status === 'PROCESSING' ? 'status-processing' - : 'status-failed' - }) - - // P1-2.6: 判断是否为 Markdown 类型 - const isMarkdown = computed(() => { - const fileType = store.detailModal.doc?.fileType - return fileType === 'md' || fileType === 'markdown' - }) - - // P1-2.6: Markdown 渲染后的内容 - const renderedContent = computed(() => { - const content = store.detailModal.doc?.content - if (!content) return '

无内容

' - return renderMarkdown(content) - }) - - function getKeywords(chunk) { - let meta = chunk.metadata - if (typeof meta === 'object' && meta && meta.value) meta = meta.value - try { - const m = typeof meta === 'string' ? JSON.parse(meta) : meta - return m.excerpt_keywords || '' - } catch (e) { - return '' - } - } - - /** - * P0-3: 从 chunk metadata 中获取真实的 chunkIndex - * 避免使用 v-for 数组索引(删除/编辑后索引会偏移) - */ - function getRealChunkIndex(chunk, fallbackIdx) { - let meta = chunk.metadata - if (typeof meta === 'object' && meta && meta.value) meta = meta.value - try { - const m = typeof meta === 'string' ? JSON.parse(meta) : meta - if (m && m.chunkIndex !== undefined && m.chunkIndex !== null) { - const idx = parseInt(m.chunkIndex, 10) - if (!isNaN(idx)) return idx - } - } catch (e) { - // 解析失败,使用 fallback - } - return fallbackIdx - } - - // P1-2.5: 分块编辑状态 - const editIdx = ref(-1) - const editContent = ref('') - const editSaving = ref(false) - - function startEdit(idx, chunk) { - editIdx.value = idx - editContent.value = chunk.content || '' - } - - function cancelEdit() { - editIdx.value = -1 - editContent.value = '' - } - - async function saveEdit(idx) { - const docId = store.detailModal.doc?.id - if (!docId) return - // P0-3: 使用真实 chunkIndex 而非数组索引 - const chunk = store.detailModal.chunks[idx] - const realIdx = getRealChunkIndex(chunk, idx) - editSaving.value = true - try { - const json = await updateChunk(docId, realIdx, editContent.value) - if (json.success) { - toast(json.message || '分块已更新', 'success') - editIdx.value = -1 - // 重新加载分块 - await store.openDetail(docId) - } else { - toast(json.message || '更新失败', 'error') - } - } catch (e) { - toast('更新分块失败:' + e.message, 'error') - } finally { - editSaving.value = false - } - } - - // P1-2.5: 删除分块 - async function removeChunk(idx) { - const docId = store.detailModal.doc?.id - if (!docId) return - // P0-3: 使用真实 chunkIndex 而非数组索引 - const chunk = store.detailModal.chunks[idx] - const realIdx = getRealChunkIndex(chunk, idx) - if (!confirm(`确定删除第 ${realIdx + 1} 个分块?对应的向量也将被移除`)) return - try { - const json = await deleteChunk(docId, realIdx) - if (json.success) { - toast(json.message || '分块已删除', 'success') - // 重新加载分块和文档详情 - await store.openDetail(docId) - } else { - toast(json.message || '删除失败', 'error') - } - } catch (e) { - toast('删除分块失败:' + e.message, 'error') - } - } - - return { - store, statusClass, isMarkdown, renderedContent, getKeywords, getRealChunkIndex, - formatBytes, formatDate, renderMarkdown, - editIdx, editContent, editSaving, - startEdit, cancelEdit, saveEdit, removeChunk - } - } -} diff --git a/src/main/resources/static/components/DocList.js b/src/main/resources/static/components/DocList.js deleted file mode 100644 index 442a904..0000000 --- a/src/main/resources/static/components/DocList.js +++ /dev/null @@ -1,431 +0,0 @@ -/** - * 📋 文档列表 + 分页 + 批量操作 - * P0-1: 关键词搜索(标题/文件名模糊匹配) - * P0-2: 文件大小列 + 分类名称列 - * P0-4: 处理中状态自动轮询刷新 - * P1-2.1: 文档启用/禁用(开关按钮 + 批量操作) - * P1-2.2: 批量移动分类 - * P1-2.3: 标签筛选 + 标签 badge 展示 - */ -import { ref, computed, onUnmounted } from 'vue' -import { store } from '../js/store.js' -import { listDocuments, deleteDocument, reprocessDocument, batchDeleteDocuments, batchReprocessDocuments, toggleDocument, batchToggleDocuments, batchMoveDocuments, downloadDocument } from '../js/api.js' -import { toast, formatDate, formatBytes } from '../js/utils.js' - -export default { - template: ` -
-

文档列表

-
- - - - - - - - - ⏳ 有文档处理中... - - - -
- -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
标题类型大小分类状态启用分块数创建时间操作
暂无文档
- {{ d.title }}
- {{ d.sourceName || '' }} - -
- {{ tag }} -
-
{{ d.fileType }}{{ formatSize(d.fileSize) }}{{ store.getCategoryName(d.categoryId) }}{{ statusLabel(d.status) }} - - - {{ d.chunkCount || 0 }}{{ formatDate(d.createTime) }} - - - - -
-
- - -
- `, - setup() { - const documents = ref([]) - const page = ref(1) - const pages = ref(1) - const total = ref(0) - const filterCategory = ref('') - const filterStatus = ref('') - const keyword = ref('') - const filterTag = ref('') - const selectedIds = ref(new Set()) - const moveCategoryId = ref('') - - // 防抖搜索 - let debounceTimer = null - function debouncedLoad() { - clearTimeout(debounceTimer) - debounceTimer = setTimeout(() => load(1), 300) - } - - // 轮询控制 - let pollTimer = null - const hasProcessing = ref(false) - - function startPolling() { - stopPolling() - hasProcessing.value = true - pollTimer = setInterval(async () => { - await load(page.value) - if (!documents.value.some(d => d.status === 'PROCESSING')) { - stopPolling() - store.loadStats() - toast('所有文档处理完成', 'success') - } - }, 2000) - } - - function stopPolling() { - hasProcessing.value = false - if (pollTimer) { - clearInterval(pollTimer) - pollTimer = null - } - } - - // 组件卸载时清理定时器 - onUnmounted(() => { - stopPolling() - clearTimeout(debounceTimer) - }) - - // 计算是否全选(computed 确保响应式更新) - const isAllSelected = computed(() => { - return documents.value.length > 0 && documents.value.every(d => selectedIds.value.has(d.id)) - }) - - function statusClass(status) { - return status === 'READY' ? 'status-ready' - : status === 'PROCESSING' ? 'status-processing' - : 'status-failed' - } - - function statusLabel(status) { - return status === 'READY' ? '已完成' - : status === 'PROCESSING' ? '处理中' - : status === 'FAILED' ? '失败' - : status - } - - function formatSize(bytes) { - if (!bytes || bytes === 0) return '-' - return formatBytes(bytes) - } - - /** P1-2.3: 从文档的 tags JSONB 中提取标签数组 */ - function getDocTags(doc) { - if (!doc.tags) return [] - const t = doc.tags - if (t.tags && Array.isArray(t.tags)) return t.tags - if (Array.isArray(t)) return t - return [] - } - - /** P1-2.1: 禁用文档行灰色标记 */ - function getRowStyle(doc) { - const base = selectedIds.value.has(doc.id) ? 'background:#f3f4f6;' : '' - if (doc.enabled === false) return base + 'opacity:0.5;' - return base - } - - async function load(p = 1) { - page.value = p - // 切换页面时清空选择 - selectedIds.value = new Set() - try { - const json = await listDocuments(p, 10, - filterCategory.value || undefined, - filterStatus.value || undefined, - keyword.value.trim() || undefined, - filterTag.value || undefined) - if (json.success) { - documents.value = json.data || [] - total.value = json.total || 0 - pages.value = json.pages || 1 - // 检查是否有处理中的文档,启动轮询 - if (documents.value.some(d => d.status === 'PROCESSING')) { - if (!pollTimer) startPolling() - } - } else { - toast(json.message || '查询失败', 'error') - } - } catch (e) { - toast('加载文档失败:' + e.message, 'error') - } - } - - function toggleSelect(id) { - const s = new Set(selectedIds.value) - if (s.has(id)) s.delete(id) - else s.add(id) - selectedIds.value = s - } - - function toggleSelectAll() { - if (isAllSelected.value) { - selectedIds.value = new Set() - } else { - const s = new Set() - documents.value.forEach(d => s.add(d.id)) - selectedIds.value = s - } - } - - function clearSelection() { - selectedIds.value = new Set() - } - - function viewDetail(id) { - store.openDetail(id) - } - - function download(id) { - downloadDocument(id) - } - - // P1-2.1: 切换单个文档启用/禁用 - async function toggle(doc) { - try { - const json = await toggleDocument(doc.id) - if (json.success) { - doc.enabled = json.data.enabled - toast(json.message, 'success') - } else { - toast(json.message || '操作失败', 'error') - // 恢复开关状态 - load(page.value) - } - } catch (e) { - toast('操作失败:' + e.message, 'error') - load(page.value) - } - } - - async function remove(id) { - if (!confirm('确定删除此文档?关联的向量也将被删除')) return - try { - const json = await deleteDocument(id) - if (json.success) { - toast(`已删除,连带删除 ${json.deletedVectors || 0} 个向量`, 'success') - load(page.value) - store.loadStats() - } else { - toast(json.message || '删除失败', 'error') - } - } catch (e) { - toast('删除失败:' + e.message, 'error') - } - } - - async function reprocess(id) { - if (!confirm('确定重新处理此文档?将重新分块并向量化')) return - try { - const json = await reprocessDocument(id) - if (json.success) { - toast(json.message || '已提交重新处理', 'success') - load(page.value) - if (!pollTimer) startPolling() - } else { - toast(json.message || '重新处理失败', 'error') - } - } catch (e) { - toast('重新处理失败:' + e.message, 'error') - } - } - - async function batchRemove() { - const ids = Array.from(selectedIds.value) - if (!confirm(`确定删除选中的 ${ids.length} 个文档?关联的向量也将被删除`)) return - try { - const json = await batchDeleteDocuments(ids) - if (json.success) { - toast(json.message, 'success') - selectedIds.value = new Set() - load(page.value) - store.loadStats() - } else { - toast(json.message || '批量删除失败', 'error') - } - } catch (e) { - toast('批量删除失败:' + e.message, 'error') - } - } - - async function batchReprocess() { - const ids = Array.from(selectedIds.value) - if (!confirm(`确定重新处理选中的 ${ids.length} 个文档?`)) return - try { - const json = await batchReprocessDocuments(ids) - if (json.success) { - toast(json.message, 'success') - selectedIds.value = new Set() - load(page.value) - if (!pollTimer) startPolling() - } else { - toast(json.message || '批量重新处理失败', 'error') - } - } catch (e) { - toast('批量重新处理失败:' + e.message, 'error') - } - } - - // P1-2.1: 批量启用 - async function batchEnable() { - const ids = Array.from(selectedIds.value) - if (!confirm(`确定启用选中的 ${ids.length} 个文档?`)) return - try { - const json = await batchToggleDocuments(ids, true) - if (json.success) { - toast(json.message, 'success') - selectedIds.value = new Set() - load(page.value) - } else { - toast(json.message || '批量启用失败', 'error') - } - } catch (e) { - toast('批量启用失败:' + e.message, 'error') - } - } - - // P1-2.1: 批量禁用 - async function batchDisable() { - const ids = Array.from(selectedIds.value) - if (!confirm(`确定禁用选中的 ${ids.length} 个文档?禁用后将不参与 RAG 检索`)) return - try { - const json = await batchToggleDocuments(ids, false) - if (json.success) { - toast(json.message, 'success') - selectedIds.value = new Set() - load(page.value) - } else { - toast(json.message || '批量禁用失败', 'error') - } - } catch (e) { - toast('批量禁用失败:' + e.message, 'error') - } - } - - // P1-2.2: 批量移动分类 - async function batchMove() { - const ids = Array.from(selectedIds.value) - const catId = moveCategoryId.value - if (!catId && catId !== '0') { - toast('请选择目标分类', 'error') - return - } - const catName = catId === '0' ? '未分类' : store.getCategoryName(catId) - if (!confirm(`确定将选中的 ${ids.length} 个文档移动到「${catName}」?`)) return - try { - // 注意:不可用 parseInt,雪花 ID 超出 Number.MAX_SAFE_INTEGER 会丢失精度 - const json = await batchMoveDocuments(ids, catId === '0' ? 0 : catId) - if (json.success) { - toast(json.message, 'success') - selectedIds.value = new Set() - moveCategoryId.value = '' - load(page.value) - store.loadStats() - } else { - toast(json.message || '批量移动失败', 'error') - } - } catch (e) { - toast('批量移动失败:' + e.message, 'error') - } - } - - return { - documents, page, pages, total, filterCategory, filterStatus, keyword, filterTag, - selectedIds, moveCategoryId, store, statusClass, statusLabel, isAllSelected, hasProcessing, - formatSize, debouncedLoad, getDocTags, getRowStyle, - load, toggleSelect, toggleSelectAll, clearSelection, - viewDetail, toggle, remove, reprocess, batchRemove, batchReprocess, - batchEnable, batchDisable, batchMove, formatDate, download - } - } -} diff --git a/src/main/resources/static/components/DocSearch.js b/src/main/resources/static/components/DocSearch.js deleted file mode 100644 index b894475..0000000 --- a/src/main/resources/static/components/DocSearch.js +++ /dev/null @@ -1,105 +0,0 @@ -/** - * 🔍 知识库搜索测试(支持多种检索模式) - */ -import { ref } from 'vue' -import { searchDocuments } from '../js/api.js' -import { toast } from '../js/utils.js' - -export default { - template: ` -
-

知识库搜索测试

-

输入查询语句,测试知识库检索效果(支持向量、关键词、混合检索三种模式)

-
- - - - - -
- -
搜索中...
- -
未找到相关结果
- -
搜索失败:{{ errorMsg }}
- -
-
-
- {{ modeLabel(r.searchMode) }} - 得分: {{ formatScore(r) }} | {{ r.title || '无标题' }} | {{ r.sourceName || '无来源' }} -
-
{{ r.content || '' }}
-
-
-
- `, - setup() { - const query = ref('') - const topK = ref(5) - const threshold = ref(0.5) - const searchMode = ref('VECTOR') - const results = ref([]) - const isSearching = ref(false) - const searched = ref(false) - const errorMsg = ref('') - - async function search() { - if (!query.value.trim()) { - toast('请输入查询内容', 'error') - return - } - isSearching.value = true - searched.value = false - errorMsg.value = '' - try { - const json = await searchDocuments(query.value, topK.value, threshold.value, null, null, searchMode.value) - if (!json.success) { - errorMsg.value = json.message - results.value = [] - } else { - results.value = json.data || [] - } - searched.value = true - } catch (e) { - errorMsg.value = e.message - results.value = [] - searched.value = true - } finally { - isSearching.value = false - } - } - - function formatScore(r) { - if (r.searchMode === 'KEYWORD' || r.searchMode === 'HYBRID') { - return r.score != null ? r.score.toFixed(4) : '-' - } - return r.score != null ? (1 - r.score).toFixed(4) : '-' - } - - function modeLabel(m) { - return { VECTOR: '向量', KEYWORD: '关键词', HYBRID: '混合' }[m] || m || '向量' - } - - function modeColor(m) { - return { VECTOR: '#007bff', KEYWORD: '#28a745', HYBRID: '#6f42c1' }[m] || '#6c757d' - } - - return { query, topK, threshold, searchMode, results, isSearching, searched, errorMsg, search, formatScore, modeLabel, modeColor } - } -} diff --git a/src/main/resources/static/components/DocStats.js b/src/main/resources/static/components/DocStats.js deleted file mode 100644 index bfda2c5..0000000 --- a/src/main/resources/static/components/DocStats.js +++ /dev/null @@ -1,39 +0,0 @@ -/** - * 📊 知识库统计面板 - */ -import { computed } from 'vue' -import { store } from '../js/store.js' -import { formatDate } from '../js/utils.js' - -export default { - template: ` -
-

知识库概览

-
-
-
{{ store.stats?.totalDocuments || 0 }}
-
文档总数
-
-
-
{{ store.stats?.totalVectors || 0 }}
-
向量总数
-
-
-
{{ store.stats?.lastUploadTime ? formatDate(store.stats.lastUploadTime) : '-' }}
-
最近上传
-
-
-
{{ fileTypeDetail }}
-
- `, - setup() { - const fileTypeDetail = computed(() => { - if (!store.stats?.byFileType) return '' - const entries = Object.entries(store.stats.byFileType) - if (entries.length === 0) return '' - return '文件类型: ' + entries.map(([k, v]) => `${k}: ${v}`).join(', ') - }) - - return { store, fileTypeDetail, formatDate } - } -} diff --git a/src/main/resources/static/components/DocUpload.js b/src/main/resources/static/components/DocUpload.js deleted file mode 100644 index 19a28e5..0000000 --- a/src/main/resources/static/components/DocUpload.js +++ /dev/null @@ -1,469 +0,0 @@ -/** - * 📤 文档上传面板 - * 支持 6 种上传格式 + 前端校验 + 上传进度 + 分块配置 - */ -import { ref, reactive, computed } from 'vue' -import { store } from '../js/store.js' -import { uploadFile, uploadString, uploadMarkdown, uploadJsonBasic, uploadJsonFields, uploadJsonPointer } from '../js/api.js' -import { toast, formatBytes, authHeaders, API_BASE } from '../js/utils.js' - -// ==================== 上传校验常量 ==================== - -/** 允许上传的文件类型白名单 */ -const ALLOWED_EXTENSIONS = new Set([ - 'pdf', 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx', - 'txt', 'md', 'json', 'csv', 'html', 'xml', 'rtf' -]) - -/** 文件大小上限 50MB */ -const MAX_FILE_SIZE = 50 * 1024 * 1024 - -export default { - template: ` -
-

文档上传

-

上传文档到 RAG 知识库,自动分词 → 向量化 → 存入 PGVector

- - -
- - -
-
- - {{ tag }}× - - -
- -
-
- {{ s.tag }} ({{ s.count }}) -
-
-
-
- - -
- -
-

留空则使用全局配置(分块大小 200 Token,重叠 100 Token)

-
- - - Token -
-
-
- - -
- -
- - -
-
-
-
-
上传进度:{{ uploadProgress }}%
-
- - -
-
POST/upload/file(Tika 多格式解析)
-
-

点击或拖拽上传,支持多文件(PDF / Word / Excel / PPT / TXT 等)

- -
-
-
{{ validationErrors.file }}
- -
-
- - -
-
POST/upload/string(直接上传文本)
- - - -
-
- - -
-
POST/upload/markdown
-
-

点击或拖拽上传,支持多文件(Markdown .md)

- -
-
-
{{ validationErrors.markdown }}
- -
-
- - -
-
POST/upload/json/basic(整体解析)
-
-

点击或拖拽上传,支持多文件(JSON .json)

- -
-
-
{{ validationErrors.jsonBasic }}
- -
-
- - -
-
POST/upload/json/fields(按字段名提取)
-
-

点击或拖拽上传,支持多文件(JSON .json)

- -
-
-
{{ validationErrors.jsonFields }}
- - -
-
- - -
-
POST/upload/json/pointer(JSON Pointer 路径拆分)
-
-

点击或拖拽上传,支持多文件(JSON .json)

- -
-
-
{{ validationErrors.jsonPointer }}
- - -
-
-
- `, - setup() { - const activeSubTab = ref('file') - const uploadCategory = ref('') - const uploadTags = ref('') - const stringTitle = ref('') - const stringContent = ref('') - const jsonFieldsStr = ref('') - const jsonPointerStr = ref('') - const showAdvanced = ref(false) - const chunkSizeOverride = ref(null) - const overlapOverride = ref(null) - const uploadProgress = ref(-1) - - // 文件 input 的 ref 引用(Composition API 需要声明才能在模板中使用) - const fileInput = ref(null) - const mdInput = ref(null) - const jsonBInput = ref(null) - const jsonFInput = ref(null) - const jsonPInput = ref(null) - const tagInputRef = ref(null) - - // P1-2.3: 标签输入状态 - const tagList = ref([]) - const tagInputValue = ref('') - const tagInputFocused = ref(false) - - const filteredSuggestions = computed(() => { - const input = tagInputValue.value.trim().toLowerCase() - if (!input) return (store.tags || []).slice(0, 10) - return (store.tags || []) - .filter(t => t.tag.toLowerCase().includes(input) && !tagList.value.includes(t.tag)) - .slice(0, 8) - }) - - function addTag() { - const val = tagInputValue.value.trim().replace(/,/g, '') - if (val && !tagList.value.includes(val)) { - tagList.value.push(val) - } - tagInputValue.value = '' - } - - function removeTag(idx) { - tagList.value.splice(idx, 1) - } - - function selectSuggestion(tag) { - if (!tagList.value.includes(tag)) { - tagList.value.push(tag) - } - tagInputValue.value = '' - tagInputFocused.value = false - } - - function onTagInput() { - // 输入时自动显示建议列表 - } - - function onTagBlur() { - // 延迟关闭建议列表,让 mousedown 事件先触发 - setTimeout(() => { tagInputFocused.value = false }, 200) - } - - const fileData = reactive({ - file: null, markdown: null, jsonBasic: null, jsonFields: null, jsonPointer: null - }) - - const fileInfo = reactive({ - file: '', markdown: '', jsonBasic: '', jsonFields: '', jsonPointer: '' - }) - - const results = reactive({ - file: '', string: '', markdown: '', jsonBasic: '', jsonFields: '', jsonPointer: '' - }) - - const validationErrors = reactive({ - file: '', markdown: '', jsonBasic: '', jsonFields: '', jsonPointer: '' - }) - - // 检查按钮是否应禁用 - const getButtonState = (type) => { - return !fileData[type] || !!validationErrors[type] - } - - const subTabs = [ - { key: 'file', icon: '📎', label: '通用文件' }, - { key: 'string', icon: '📝', label: '文本内容' }, - { key: 'markdown', icon: '📑', label: 'Markdown' }, - { key: 'jsonBasic', icon: '📋', label: 'JSON 基本' }, - { key: 'jsonFields', icon: '🔑', label: 'JSON 按字段' }, - { key: 'jsonPointer', icon: '📍', label: 'JSON 按指针' } - ] - - /** - * 校验文件列表,返回错误信息或空字符串 - */ - function validateFiles(files) { - for (const f of files) { - // 文件大小校验 - if (f.size > MAX_FILE_SIZE) { - return `文件"${f.name}"超过 50MB 大小限制` - } - // 文件类型校验 - const ext = f.name.includes('.') ? f.name.substring(f.name.lastIndexOf('.') + 1).toLowerCase() : '' - if (ext && !ALLOWED_EXTENSIONS.has(ext)) { - return `文件类型".${ext}"不在允许列表中` - } - } - return '' - } - - function handleFileSelect(event, type) { - const input = event.target - if (!input.files || input.files.length === 0) return - const files = Array.from(input.files) - - // 前端校验 - const error = validateFiles(files) - - if (error) { - // 校验不通过:清空数据,保持按钮禁用 - validationErrors[type] = error - fileData[type] = null - fileInfo[type] = `${error}` - toast(error, 'error') - return - } - - // 校验通过 - validationErrors[type] = '' - fileData[type] = files - const totalSize = files.reduce((s, f) => s + f.size, 0) - const label = files.length > 1 - ? `已选择 ${files.length} 个文件(共 ${formatBytes(totalSize)})` - : `已选择:${files[0].name} (${formatBytes(files[0].size)})` - fileInfo[type] = label - } - - function handleDrop(event, type) { - event.currentTarget.classList.remove('drag-over') - const dt = new DataTransfer() - for (const f of event.dataTransfer.files) dt.items.add(f) - const fakeEvent = { target: { files: dt.files } } - handleFileSelect(fakeEvent, type) - } - - /** - * 构建分块参数的 query string - */ - function chunkParams() { - const params = [] - if (chunkSizeOverride.value) params.push(`chunkSize=${chunkSizeOverride.value}`) - if (overlapOverride.value) params.push(`overlap=${overlapOverride.value}`) - return params.length > 0 ? (params.join('&')) : '' - } - - /** - * 将分块参数附加到 URL - */ - function appendChunkParams(url) { - const cp = chunkParams() - if (!cp) return url - return url + (url.includes('?') ? '&' : '?') + cp - } - - async function doUpload(type) { - const catId = uploadCategory.value - // P1-2.3: 使用标签列表替代逗号分隔输入 - const tagsStr = tagList.value.length > 0 ? tagList.value.join(',') : uploadTags.value.trim() - uploadProgress.value = -1 - - // 文本内容上传 - if (type === 'string') { - if (!stringContent.value.trim()) { - toast('请输入文本内容', 'error') - return - } - try { - const title = stringTitle.value.trim() || stringContent.value.trim().substring(0, 30) - let url = `/upload/string?title=${encodeURIComponent(title)}` - if (catId) url += `&categoryId=${catId}` - if (tagsStr) url += `&tags=${encodeURIComponent(tagsStr)}` - url = appendChunkParams(url) - - const res = await fetch(API_BASE + url, { method: 'POST', headers: { 'Content-Type': 'text/plain', ...authHeaders() }, body: stringContent.value }) - const json = await res.json() - if (json.success) { - const isProcessing = json.data && json.data.status === 'PROCESSING' - const statusText = isProcessing ? '后台处理中...' : `分块数:${json.data.chunkCount} | 状态:已完成` - results.string = `
${json.message} | ${statusText}
` - toast(json.message, 'success') - store.loadCategories() - store.loadStats() - } else { - const isDuplicate = json.message && json.message.includes('重复') - results.string = `
${json.message}
` - if (!isDuplicate) toast(json.message, 'error') - } - } catch (e) { - results.string = `
上传失败:${e.message}
` - toast('上传失败:' + e.message, 'error') - } - return - } - - // 文件类上传 - const files = fileData[type] - if (!files || files.length === 0) { - toast('请先选择文件', 'error') - return - } - - let successCount = 0, failCount = 0 - const resultsHtml = [] - - for (let i = 0; i < files.length; i++) { - const file = files[i] - uploadProgress.value = files.length > 1 ? Math.round((i / files.length) * 100) : 0 - try { - const formData = new FormData() - formData.append('file', file) - if (catId) formData.append('categoryId', catId) - if (tagsStr) formData.append('tags', tagsStr) - // P1-2.4: 传递 per-doc 分块参数 - if (chunkSizeOverride.value) formData.append('chunkSize', chunkSizeOverride.value) - if (overlapOverride.value) formData.append('overlap', overlapOverride.value) - - let json - const onProgress = files.length === 1 ? (p) => { uploadProgress.value = p } : null - - switch (type) { - case 'file': - json = await uploadFile(formData, onProgress) - break - case 'markdown': - json = await uploadMarkdown(formData, onProgress) - break - case 'jsonBasic': - json = await uploadJsonBasic(formData, onProgress) - break - case 'jsonFields': { - if (!jsonFieldsStr.value.trim()) { - toast('请输入要提取的字段名', 'error') - uploadProgress.value = -1 - return - } - json = await uploadJsonFields(formData, jsonFieldsStr.value.trim(), onProgress) - break - } - case 'jsonPointer': { - if (!jsonPointerStr.value.trim()) { - toast('请输入 JSON Pointer 路径', 'error') - uploadProgress.value = -1 - return - } - json = await uploadJsonPointer(formData, jsonPointerStr.value.trim(), onProgress) - break - } - } - - if (json.success) { - successCount++ - const isProcessing = json.data && json.data.status === 'PROCESSING' - const chunkInfo = isProcessing ? '后台处理中...' : `${json.data.chunkCount || 0} 分块` - resultsHtml.push(`${file.name} — ${chunkInfo}`) - } else { - failCount++ - const isDuplicate = json.message && json.message.includes('重复') - resultsHtml.push(`${file.name} — ${json.message || '错误'}`) - } - } catch (e) { - failCount++ - resultsHtml.push(`${file.name} — ${e.message}`) - } - } - - uploadProgress.value = 100 - setTimeout(() => { uploadProgress.value = -1 }, 1500) - - const summary = successCount > 0 - ? `上传完成:成功 ${successCount} 个${failCount > 0 ? `,失败 ${failCount} 个` : ''}` - : `全部失败(${failCount} 个文件)` - results[type] = `
${summary}
${resultsHtml.join('
')}
` - toast(summary, successCount > 0 ? 'success' : 'error') - - if (successCount > 0) { - store.loadCategories() - store.loadStats() - } - } - - return { - activeSubTab, uploadCategory, uploadTags, subTabs, - stringTitle, stringContent, jsonFieldsStr, jsonPointerStr, - showAdvanced, chunkSizeOverride, overlapOverride, - uploadProgress, - fileInput, mdInput, jsonBInput, jsonFInput, jsonPInput, - tagInputRef, tagList, tagInputValue, tagInputFocused, - filteredSuggestions, addTag, removeTag, selectSuggestion, onTagInput, onTagBlur, - fileData, fileInfo, results, validationErrors, store, - getButtonState, - handleFileSelect, handleDrop, doUpload, formatBytes - } - } -} diff --git a/src/main/resources/static/components/FaqManager.js b/src/main/resources/static/components/FaqManager.js deleted file mode 100644 index 7a6c489..0000000 --- a/src/main/resources/static/components/FaqManager.js +++ /dev/null @@ -1,340 +0,0 @@ -/** - * FAQ 管理组件 - * 支持 CRUD + 批量导入 + 导出 + 启用/禁用 - * 分类引用文档管理的 knowledge_category 分类树 - */ -import { listFaqs, createFaq, updateFaq, deleteFaq, toggleFaqStatus, batchImportFaqs, exportFaqs, getFaqStats, getCategoryTree } from '../js/api.js' -import { toast } from '../js/utils.js' - -/** - * 将分类树扁平化为 [{id, name, path}] 列表,path 为完整路径如 "一级/二级" - */ -function flattenCategoryTree(tree, prefix = '') { - if (!tree || !Array.isArray(tree)) return [] - const result = [] - for (const node of tree) { - const path = prefix ? `${prefix} / ${node.name}` : node.name - result.push({ id: node.id, name: node.name, path }) - if (node.children && node.children.length) { - result.push(...flattenCategoryTree(node.children, path)) - } - } - return result -} - -export default { - template: ` -
-

❓ FAQ 精准匹配管理

- - -
- - - - - - -
- - -
- 总计: {{ stats.totalCount || 0 }} - 启用: {{ stats.enabledCount || 0 }} - 总命中: {{ stats.totalHitCount || stats.totalHits || 0 }} -
- - - - - - - - - - - - - - - - - - - - - - - - - - - -
问题答案(摘要)分类优先级命中状态操作
加载中...
暂无 FAQ
{{ f.question }}{{ f.answer }}{{ getCategoryName(f.categoryId) || f.category || '-' }}{{ f.priority }}{{ f.hitCount }} - - {{ f.status === 'ENABLED' ? '✅ 启用' : '❌ 禁用' }} - - - - -
- - -
- - 第 {{ page }} / {{ Math.ceil(total / pageSize) }} 页(共 {{ total }} 条) - -
- - -
-
-

{{ editingFaq ? '编辑 FAQ' : '添加 FAQ' }}

-
- - -
-
- - -
-
- - -
-
-
- - -
-
- - -
-
-
- - -
-
-
- - -
-
-

批量导入 FAQ

-

使用 JSON 格式批量导入,每条包含 question、answer、similarQuestions(可选数组)、categoryId(可选,分类ID)

- -
- - -
-
-
-
- `, - - data() { - return { - faqs: [], - loading: false, - page: 1, - pageSize: 20, - total: 0, - searchKeyword: '', - filterCategoryId: '', - filterStatus: '', - flatCategories: [], - categoryMap: {}, - stats: null, - showFormDialog: false, - showImportDialog: false, - editingFaq: null, - form: { question: '', answer: '', similarQuestionsText: '', categoryId: null, priority: 0 }, - importJson: '', - searchTimer: null - } - }, - - mounted() { - this.loadCategories() - this.loadList() - this.loadStats() - }, - - methods: { - async loadCategories() { - try { - const res = await getCategoryTree() - if (res.success) { - this.flatCategories = flattenCategoryTree(res.data) - // 构建 id → path 映射,用于表格中显示分类名 - const map = {} - for (const c of this.flatCategories) { - map[c.id] = c.path - } - this.categoryMap = map - } - } catch (e) { - console.warn('加载分类树失败', e) - } - }, - - getCategoryName(categoryId) { - if (!categoryId) return '' - return this.categoryMap[categoryId] || '' - }, - - async loadList() { - this.loading = true - try { - const res = await listFaqs(this.page, this.pageSize, this.searchKeyword || undefined, this.filterCategoryId || undefined, this.filterStatus || undefined) - if (res.success) { - this.faqs = res.data?.records || res.data || [] - this.total = res.data?.total || res.total || 0 - } - } catch (e) { - toast('加载失败: ' + e.message, 'error') - } - this.loading = false - }, - - async loadStats() { - try { - const res = await getFaqStats() - if (res.success) this.stats = res.data - } catch { /* silent */ } - }, - - debouncedSearch() { - clearTimeout(this.searchTimer) - this.searchTimer = setTimeout(() => { this.page = 1; this.loadList() }, 300) - }, - - openAddDialog() { - this.editingFaq = null - this.form = { question: '', answer: '', similarQuestionsText: '', categoryId: null, priority: 0 } - this.showFormDialog = true - }, - - openEditDialog(f) { - this.editingFaq = f - let similarText = '' - try { - const arr = typeof f.similarQuestions === 'string' ? JSON.parse(f.similarQuestions) : f.similarQuestions - similarText = Array.isArray(arr) ? arr.join('\n') : '' - } catch { similarText = f.similarQuestions || '' } - this.form = { question: f.question, answer: f.answer, similarQuestionsText: similarText, categoryId: f.categoryId || null, priority: f.priority || 0 } - this.showFormDialog = true - }, - - async saveFaq() { - const similarQuestions = this.form.similarQuestionsText.split('\n').map(s => s.trim()).filter(Boolean) - const data = { - question: this.form.question, - answer: this.form.answer, - similarQuestions: JSON.stringify(similarQuestions), - categoryId: this.form.categoryId || null, - priority: this.form.priority || 0 - } - try { - let res - if (this.editingFaq) { - res = await updateFaq(this.editingFaq.id, data) - } else { - res = await createFaq(data) - } - if (res.success) { - toast(this.editingFaq ? '修改成功' : '添加成功', 'success') - this.showFormDialog = false - this.loadList() - this.loadStats() - } else { - toast(res.message || '操作失败', 'error') - } - } catch (e) { - toast('操作失败: ' + e.message, 'error') - } - }, - - async toggleStatus(f) { - const newStatus = f.status === 'ENABLED' ? 'DISABLED' : 'ENABLED' - try { - const res = await toggleFaqStatus(f.id, newStatus) - if (res.success) { - f.status = newStatus - toast(`已${newStatus === 'ENABLED' ? '启用' : '禁用'}`, 'success') - } else { - toast(res.message || '操作失败', 'error') - } - } catch (e) { - toast('操作失败: ' + e.message, 'error') - } - }, - - async removeFaq(id) { - if (!confirm('确认删除该 FAQ?')) return - try { - const res = await deleteFaq(id) - if (res.success) { toast('删除成功', 'success'); this.loadList(); this.loadStats() } - else toast(res.message || '删除失败', 'error') - } catch (e) { - toast('删除失败: ' + e.message, 'error') - } - }, - - async doImport() { - try { - const faqs = JSON.parse(this.importJson) - if (!Array.isArray(faqs) || faqs.length === 0) return toast('请输入有效的 FAQ 数组', 'error') - // 处理 similarQuestions 字段 - for (const f of faqs) { - if (Array.isArray(f.similarQuestions)) f.similarQuestions = JSON.stringify(f.similarQuestions) - else if (!f.similarQuestions) f.similarQuestions = '[]' - } - const res = await batchImportFaqs({ faqs }) - if (res.success) { - toast(`成功导入 ${res.data || faqs.length} 条`, 'success') - this.showImportDialog = false - this.importJson = '' - this.loadList() - this.loadStats() - } else { - toast(res.message || '导入失败', 'error') - } - } catch (e) { - if (e instanceof SyntaxError) toast('JSON 格式错误', 'error') - else toast('导入失败: ' + e.message, 'error') - } - }, - - async doExport() { - try { - const res = await exportFaqs() - if (res.success) { - const data = JSON.stringify(res.data, null, 2) - const blob = new Blob([data], { type: 'application/json' }) - const url = URL.createObjectURL(blob) - const a = document.createElement('a') - a.href = url; a.download = 'faq_export.json'; a.click() - URL.revokeObjectURL(url) - toast('导出成功', 'success') - } else { - toast(res.message || '导出失败', 'error') - } - } catch (e) { - toast('导出失败: ' + e.message, 'error') - } - } - } -} diff --git a/src/main/resources/static/components/LoginPage.js b/src/main/resources/static/components/LoginPage.js deleted file mode 100644 index c3f9fbf..0000000 --- a/src/main/resources/static/components/LoginPage.js +++ /dev/null @@ -1,71 +0,0 @@ -/** - * 登录页组件 - * 全屏居中登录表单,JWT Token 认证 - */ -import { ref } from 'vue' -import { login } from '../js/api.js' -import { setToken, setRefreshToken, setUserInfo, toast } from '../js/utils.js' - -export default { - template: ` - - `, - setup(props, { emit }) { - const username = ref('') - const password = ref('') - const loading = ref(false) - const errorMsg = ref('') - - async function handleLogin() { - if (!username.value || !password.value) { - errorMsg.value = '请输入用户名和密码' - return - } - loading.value = true - errorMsg.value = '' - try { - const res = await login(username.value, password.value) - if (res.success && res.data) { - setToken(res.data.accessToken) - setRefreshToken(res.data.refreshToken) - setUserInfo(res.data.user) - toast('登录成功', 'success') - emit('login-success', res.data.user) - } else { - errorMsg.value = res.message || '登录失败' - } - } catch (e) { - errorMsg.value = '网络错误,请重试' - } finally { - loading.value = false - } - } - - return { username, password, loading, errorMsg, handleLogin } - } -} diff --git a/src/main/resources/static/components/McpServerManager.js b/src/main/resources/static/components/McpServerManager.js deleted file mode 100644 index ca97f91..0000000 --- a/src/main/resources/static/components/McpServerManager.js +++ /dev/null @@ -1,524 +0,0 @@ -/** - * MCP 服务管理组件 - * 支持 SSE / stdio 两种传输类型的 MCP Server 配置管理 - * 功能:列表展示、新建/编辑/删除、启用/禁用切换、测试连接、刷新连接、分页 - */ -import { ref, computed, onMounted, onUnmounted } from 'vue' -import * as api from '../js/api.js' -import { toast, formatDate } from '../js/utils.js' - -// 传输类型选项 -const TRANSPORT_OPTIONS = [ - { value: 'sse', label: 'SSE(HTTP 服务)' }, - { value: 'stdio', label: 'Stdio(本地进程)' } -] - -export default { - template: ` -
-

MCP 服务管理

-

- 管理 MCP (Model Context Protocol) 服务端配置,支持 SSE 和 Stdio 两种传输方式 -

- - -
- - - - - 共 {{ total }} 条配置 - -
- - -
- - - - - - - - - - - - - - - - - - - - - - - - - - -
名称传输类型URL / 命令启用状态描述创建时间操作
暂无 MCP 服务配置
{{ s.name || '-' }} - - {{ s.transport_type === 'sse' ? 'SSE' : 'Stdio' }} - - - {{ getDisplayUrl(s) || '-' }} - - - - {{ s.description || '-' }} - {{ formatDate(s.create_time) }} - - - -
-
- - - - - -
-
{{ testResult.success ? '连接成功' : '连接失败' }}
-
{{ testResult.message }}
-
-
- - - - `, - - setup() { - // ==================== 列表状态 ==================== - const servers = ref([]) - const currentPage = ref(1) - const totalPages = ref(1) - const total = ref(0) - const pageSize = 10 - - // ==================== 弹窗状态 ==================== - const showModal = ref(false) - const modalMode = ref('add') // 'add' | 'edit' - const editId = ref(null) - const saving = ref(false) - - // 表单数据 - const form = ref(createEmptyForm()) - - function createEmptyForm() { - return { - name: '', - transportType: 'sse', - url: '', - command: '', - argsText: '', - envText: '', - description: '' - } - } - - // ==================== 测试连接状态 ==================== - const testLoading = ref({}) - const testResult = ref({ visible: false, success: false, message: '' }) - let testResultTimer = null - - // ==================== 刷新连接状态 ==================== - const refreshLoading = ref(false) - - // ==================== 传输类型选项 ==================== - const transportOptions = TRANSPORT_OPTIONS - - // ==================== 数据加载 ==================== - - async function load(p = 1) { - currentPage.value = p - try { - const json = await api.listMcpServers(p, pageSize) - if (json.success) { - servers.value = json.data || [] - total.value = json.total || 0 - totalPages.value = json.pages || 1 - } else { - toast(json.message || '查询失败', 'error') - } - } catch (e) { - toast('加载列表失败:' + e.message, 'error') - } - } - - // ==================== 弹窗操作 ==================== - - function openAddModal() { - modalMode.value = 'add' - editId.value = null - form.value = createEmptyForm() - showModal.value = true - } - - function openEditModal(server) { - modalMode.value = 'edit' - editId.value = server.id - - // 解析 args 数组为换行文本 - let argsText = '' - if (server.args && Array.isArray(server.args)) { - argsText = server.args.join('\n') - } else if (typeof server.args === 'string' && server.args) { - try { - const parsed = JSON.parse(server.args) - if (Array.isArray(parsed)) argsText = parsed.join('\n') - else argsText = server.args - } catch (e) { - argsText = server.args - } - } - - // 解析环境变量对象为换行文本 - let envText = '' - if (server.env_vars && typeof server.env_vars === 'object') { - envText = Object.entries(server.env_vars).map(([k, v]) => k + '=' + v).join('\n') - } else if (typeof server.env_vars === 'string' && server.env_vars) { - try { - const parsed = JSON.parse(server.env_vars) - if (typeof parsed === 'object' && parsed !== null) { - envText = Object.entries(parsed).map(([k, v]) => k + '=' + v).join('\n') - } else { - envText = server.env_vars - } - } catch (e) { - envText = server.env_vars - } - } - - form.value = { - name: server.name || '', - transportType: server.transport_type || 'sse', - url: server.server_url || '', - command: server.command || '', - argsText: argsText, - envText: envText, - description: server.description || '' - } - showModal.value = true - } - - function closeModal() { - showModal.value = false - } - - // ==================== 保存 ==================== - - function buildSaveData() { - const f = form.value - const data = { - name: f.name, - transportType: f.transportType, - description: f.description - } - - if (f.transportType === 'sse') { - data.serverUrl = f.url - } else { - data.command = f.command - // 解析 args:按换行拆分,过滤空行 - if (f.argsText && f.argsText.trim()) { - data.args = f.argsText.split('\n').map(s => s.trim()).filter(Boolean) - } else { - data.args = [] - } - } - - // 解析环境变量:按换行拆分,解析 KEY=value - if (f.envText && f.envText.trim()) { - const envObj = {} - f.envText.split('\n').forEach(line => { - const trimmed = line.trim() - if (!trimmed) return - const eqIdx = trimmed.indexOf('=') - if (eqIdx > 0) { - envObj[trimmed.substring(0, eqIdx)] = trimmed.substring(eqIdx + 1) - } - }) - data.envVars = envObj - } - - return data - } - - async function save() { - const f = form.value - - // 校验 - if (!f.name || !f.name.trim()) { - toast('请填写服务名称', 'error') - return - } - if (f.transportType === 'sse') { - if (!f.url || !f.url.trim()) { - toast('请填写 Server URL', 'error') - return - } - } else { - if (!f.command || !f.command.trim()) { - toast('请填写 Command', 'error') - return - } - } - - saving.value = true - try { - const data = buildSaveData() - let json - if (modalMode.value === 'add') { - json = await api.createMcpServer(data) - } else { - json = await api.updateMcpServer(editId.value, data) - } - - if (json.success) { - toast(modalMode.value === 'add' ? '创建成功' : '更新成功', 'success') - closeModal() - load(currentPage.value) - } else { - toast(json.message || '操作失败', 'error') - } - } catch (e) { - toast('保存失败:' + e.message, 'error') - } finally { - saving.value = false - } - } - - // ==================== 删除 ==================== - - async function remove(id, name) { - if (!confirm('确定删除配置「' + (name || id) + '」?删除后不可恢复。')) return - try { - const json = await api.deleteMcpServer(id) - if (json.success) { - toast('删除成功', 'success') - load(currentPage.value) - } else { - toast(json.message || '删除失败', 'error') - } - } catch (e) { - toast('删除失败:' + e.message, 'error') - } - } - - // ==================== 启用/禁用 ==================== - - async function toggleActive(server) { - const newActive = !server.is_active - try { - const json = await api.toggleMcpServer(server.id, newActive) - if (json.success) { - toast(newActive ? '已启用' : '已禁用', 'success') - // 乐观更新本地状态,避免刷新列表 - server.is_active = newActive - } else { - toast(json.message || '操作失败', 'error') - } - } catch (e) { - toast('操作失败:' + e.message, 'error') - } - } - - // ==================== 测试连接 ==================== - - async function testConnection(server) { - testLoading.value = { ...testLoading.value, [server.id]: true } - try { - const json = await api.testMcpServer(server.id) - showTestResult(json) - } catch (e) { - showTestResult({ success: false, message: e.message }) - } finally { - testLoading.value = { ...testLoading.value, [server.id]: false } - } - } - - function showTestResult(result) { - if (testResultTimer) clearTimeout(testResultTimer) - testResult.value = { - visible: true, - success: result.success, - message: result.message || '' - } - testResultTimer = setTimeout(() => { testResult.value.visible = false }, 5000) - } - - // ==================== 刷新连接 ==================== - - async function refreshConnections() { - refreshLoading.value = true - try { - const json = await api.refreshMcpServers() - if (json.success) { - toast('连接已刷新', 'success') - load(currentPage.value) - } else { - toast(json.message || '刷新失败', 'error') - } - } catch (e) { - toast('刷新失败:' + e.message, 'error') - } finally { - refreshLoading.value = false - } - } - - // ==================== 工具函数 ==================== - - function getDisplayUrl(server) { - if (server.transport_type === 'sse') { - return server.server_url || '-' - } - // stdio 模式:显示 command + args - let display = server.command || '' - if (server.args) { - // args 可能是字符串或数组 - const argsStr = Array.isArray(server.args) ? server.args.join(' ') : server.args - display += ' ' + argsStr - } - return display || '-' - } - - // ==================== 生命周期 ==================== - - onMounted(() => { - load() - }) - - onUnmounted(() => { - if (testResultTimer) clearTimeout(testResultTimer) - }) - - return { - // 列表 - servers, currentPage, totalPages, total, - // 弹窗 - showModal, modalMode, form, saving, transportOptions, - // 测试 - testLoading, testResult, - // 刷新 - refreshLoading, - // 方法 - load, openAddModal, openEditModal, closeModal, save, remove, - toggleActive, testConnection, refreshConnections, getDisplayUrl, formatDate - } - } -} diff --git a/src/main/resources/static/components/MessageSources.js b/src/main/resources/static/components/MessageSources.js deleted file mode 100644 index 6a8c81b..0000000 --- a/src/main/resources/static/components/MessageSources.js +++ /dev/null @@ -1,63 +0,0 @@ -/** - * 引用来源 - * 展示一条 AI 回答所依据的知识库片段:按文档归并,点击可打开文档详情弹窗。 - * 仅在开启 RAG 检索且命中片段时出现。 - */ -import { computed } from 'vue' -import { store } from '../js/store.js' - -export default { - props: { - sources: { type: Array, default: () => [] } - }, - setup(props) { - // 按文档归并:同一文档的多个片段合并到一张卡片下 - const grouped = computed(() => { - const map = new Map() - for (const s of props.sources) { - const key = s.documentId || s.title || s.sourceName || 'unknown' - if (!map.has(key)) { - map.set(key, { - documentId: s.documentId, - title: s.title || s.sourceName || ('文档 ' + (s.documentId || '')), - chunks: [] - }) - } - map.get(key).chunks.push(s) - } - return Array.from(map.values()) - }) - - // distance(余弦距离,越小越相关)→ 0-100 的相关度 - function relevance(score) { - if (score == null) return null - return Math.round(Math.max(0, Math.min(1, 1 - Number(score))) * 100) - } - - function openDoc(documentId) { - if (documentId) store.openDetail(documentId) - } - - return { grouped, relevance, openDoc } - }, - template: ` -
-
- 引用来源 - {{ grouped.length }} 个文档 · {{ sources.length }} 个片段 -
-
-
- {{ g.title }} - 详情 › -
-
- 相关度 {{ relevance(c.score) }}% - {{ c.snippet }} -
-
-
- ` -} diff --git a/src/main/resources/static/components/ModelConfigManager.js b/src/main/resources/static/components/ModelConfigManager.js deleted file mode 100644 index a7ce370..0000000 --- a/src/main/resources/static/components/ModelConfigManager.js +++ /dev/null @@ -1,1251 +0,0 @@ -/** - * ⚙️ AI 模型配置管理组件(cc-switch 优化版) - * 核心三要素:API Key + API 地址 + 模型名称 - * 新增:动态获取模型列表、datalist 自动补全、应用类型标签按钮 - * F1: 连接测试 | F2: 配置复制 | F3: 高级参数面板 | F4: 健康看板 - * F6: Fallback 链 | F7: 导入导出 - */ -import { ref, computed, onMounted, onUnmounted, watch } from 'vue' -import * as api from '../js/api.js' -import { toast, formatDate } from '../js/utils.js' - -// 应用类型选项 -const APP_TYPE_OPTIONS = [ - { value: '', label: '全部类型' }, - { value: 'CHAT', label: '💬 智能客服对话' }, - { value: 'EMBEDDING', label: '📐 文本向量化' }, - { value: 'RAG_REWRITE', label: '🔄 RAG查询重写' }, - { value: 'RERANK', label: '📊 重排序' } -] - -// 应用类型标签(用于弹窗内的标签按钮组) -const APP_TYPE_TABS = [ - { value: 'CHAT', label: '💬 对话', shortLabel: '对话' }, - { value: 'EMBEDDING', label: '📐 向量化', shortLabel: '向量化' }, - { value: 'RAG_REWRITE', label: '🔄 RAG重写', shortLabel: 'RAG重写' }, - { value: 'RERANK', label: '📊 重排序', shortLabel: '重排序' } -] - -// 提供商选项(快捷预设,非必填) -const PROVIDER_OPTIONS = [ - { value: '', label: '手动填写地址' }, - { value: 'dashscope', label: '通义千问 (DashScope)' }, - { value: 'deepseek', label: 'DeepSeek (深度求索)' }, - { value: 'volcengine', label: '豆包 (字节跳动)' }, - { value: 'moonshot', label: 'Kimi (月之暗面)' }, - { value: 'zhipu', label: '智谱 AI (GLM)' }, - { value: 'openai', label: 'OpenAI' } -] - -// 提供商默认地址映射(仅用于快捷填充,不控制模型列表) -const PROVIDER_BASE_URLS = { - dashscope: 'https://dashscope.aliyuncs.com/compatible-mode', - deepseek: 'https://api.deepseek.com', - volcengine: 'https://ark.cn-beijing.volces.com/api/v3', - moonshot: 'https://api.moonshot.cn/v1', - zhipu: 'https://open.bigmodel.cn/api/paas/v4', - openai: 'https://api.openai.com' -} - -// 提供商使用提示 -const PROVIDER_TIPS = { - dashscope: '通义千问团队版/私有化部署需填写专属 Base URL,公共版可留空', - volcengine: '模型名称可在火山引擎 ARK 控制台获取,如 doubao-1-5-pro-32k', - other: '使用 OpenAI 兼容 API 的其他提供商,请填写完整 API 地址' -} - -// 格式化工具:Token 数显示 -function formatTokens(n) { - if (!n) return '未知' - if (n >= 1000000) return (n / 1000000).toFixed(1).replace(/\.0$/, '') + 'M' - if (n >= 1000) return (n / 1000).toFixed(n >= 10000 ? 0 : 1).replace(/\.0$/, '') + 'K' - return String(n) -} - -export default { - template: ` -
-

⚙️ AI 大模型配置管理

- - -
-
-
{{ healthSummary.online }}
-
🟢 在线
-
-
-
{{ healthSummary.offline }}
-
🔴 离线
-
-
-
{{ healthSummary.unknown }}
-
🟡 未检测
-
-
-
{{ healthSummary.total }}
-
📊 总计
-
-
- - -
- - - - - - -
- - -
-
- 🔗 Fallback 链(按优先级排列,主模型在顶部) - - ❓ 什么是 Fallback 链? - -
- - -
-
📖 Fallback 链使用说明
-
-

什么是 Fallback 链?

-

Fallback 链是一种高可用性机制,当主模型出现故障时,系统会自动切换到备用模型,保证服务不中断。

-

如何配置?

-
    -
  • ✅ 勾选「设为活跃配置」启用模型
  • -
  • 📊 调整「优先级」数值(数值越大,优先级越高)
  • -
  • 🔀 拖拽列表项可快速调整顺序
  • -
-
-
- 💡 提示:至少配置 2 个同类型的模型才能启用 Fallback 链功能 -
-
- -
-
{{ getAppTypeLabel(chain.appType) }}
-
- #{{ idx + 1 }} - - · - {{ item.name }} - {{ item.model_name }} - P={{ item.priority }} - - {{ healthMap[item.id].status === 'ONLINE' ? '🟢' : healthMap[item.id].status === 'UNKNOWN' ? '🟡' : '🔴' }} - {{ healthMap[item.id].latencyMs }}ms - - 🟡 -
-
-
- - -
- - - - - - - - - - - - - - - - - - - - - - - - - - -
配置名称应用类型模型名称API 地址API Key状态操作
暂无配置记录
- {{ c.name || '-' }} -
{{ c.description }}
-
- {{ getAppTypeLabel(c.app_type) }} - - {{ c.model_name }} - - {{ c.base_url || getProviderBaseUrl(c.provider) || '-' }} - {{ c.api_key || '-' }} -
- 🟢 活跃 - ⚫ 未激活 - - {{ healthMap[c.id].status === 'ONLINE' ? '●' : healthMap[c.id].status === 'UNKNOWN' ? '●' : '●' }} - {{ healthMap[c.id].latencyMs }}ms - - -
-
- - - - - - -
-
- - - - - -
-
{{ testResult.success ? '✅ 连接成功' : '❌ 连接失败' }}
-
- 延迟:{{ testResult.latencyMs }}ms - · 维度:{{ testResult.dimensions }} -
-
{{ testResult.errorMessage }}
-
-
- - - - - - - `, - setup() { - const configs = ref([]) - const currentPage = ref(1) - const totalPages = ref(1) - const total = ref(0) - const filterAppType = ref('') - const showApiKey = ref(false) - const providerTip = ref('') - const advancedParamsOpen = ref(false) - - // 动态获取的模型列表 - const fetchedModels = ref([]) - const fetchLoading = ref(false) - - // 自定义模型下拉框状态 - const modelDropdownOpen = ref(false) - const modelDropdownHighlight = ref(-1) - - // 模型列表过滤(支持搜索) - const filteredModels = computed(() => { - const query = (editModal.value.form.model_name || '').trim().toLowerCase() - if (!query) return fetchedModels.value - return fetchedModels.value.filter(m => - m.id.toLowerCase().includes(query) || - (m.owned_by && m.owned_by.toLowerCase().includes(query)) - ) - }) - - // F4: 健康状态 - const healthMap = ref({}) - const healthSummary = ref(null) - let healthTimer = null - - // 提供商 baseUrl 占位符 - const providerBaseUrlPlaceholder = ref('') - - // 配置名称建议 - const nameSuggestion = ref('') - - // F1: 测试状态 - const testLoading = ref({}) - const testResult = ref({ visible: false, success: false, latencyMs: 0 }) - let testResultTimer = null - - // F6: Fallback 链 - const fallbackChains = ref([]) - const showFallbackHelp = ref(false) - - // F7: 导入导出 - const showImportDialog = ref(false) - const importPreview = ref(null) - const importConflict = ref('skip') - const importResult = ref(null) - - // 编辑弹窗状态 - const editModal = ref({ - visible: false, - mode: 'add', - editId: null, - form: createEmptyForm() - }) - - function createEmptyForm() { - return { - name: '', - app_type: 'CHAT', - provider: '', - api_key: '', - model_name: '', - temperature: 0.7, - max_tokens: 2000, - base_url: '', - embeddingDimensions: 1024, - // 高级参数 - topP: null, - topK: null, - frequencyPenalty: null, - presencePenalty: null, - stopSequences: '', - // 基础字段 - priority: 0, - is_active: false, - description: '' - } - } - - // 检查是否配置了高级参数 - const hasAdvancedParams = computed(() => { - const f = editModal.value.form - return f.topP != null || f.topK != null || f.frequencyPenalty != null || - f.presencePenalty != null || (f.stopSequences && f.stopSequences.trim()) - }) - - // ==================== 提供商切换逻辑 ==================== - - function onProviderChange() { - const provider = editModal.value.form.provider - if (!provider) { - // 选择了「手动填写地址」,清空地址和提示 - editModal.value.form.base_url = '' - providerTip.value = '' - providerBaseUrlPlaceholder.value = '输入自定义 API 地址' - fetchedModels.value = [] - updateNameSuggestion() - return - } - - // 切换提供商时:清空地址 → 填充该提供商的默认地址 - editModal.value.form.base_url = PROVIDER_BASE_URLS[provider] || '' - - providerBaseUrlPlaceholder.value = PROVIDER_BASE_URLS[provider] || '输入自定义 API 地址' - providerTip.value = PROVIDER_TIPS[provider] || '' - - // 清空已获取的模型列表(提供商变了,需要重新获取) - fetchedModels.value = [] - - // 自动生成配置名称建议 - updateNameSuggestion() - } - - // API 地址失焦时,尝试推断提供商 - function onBaseUrlBlur() { - const url = editModal.value.form.base_url - if (!url || editModal.value.form.provider) return // 已选提供商,不覆盖 - - // 根据 URL 前缀推断提供商 - for (const [provider, defaultUrl] of Object.entries(PROVIDER_BASE_URLS)) { - if (url.startsWith(defaultUrl) || defaultUrl.startsWith(url)) { - editModal.value.form.provider = provider - providerTip.value = PROVIDER_TIPS[provider] || '' - break - } - } - } - - // API Key 填写后,如果有地址,提示可以获取模型 - function onApiKeyFilled() { - // 仅做 UI 提示,不自动触发(避免意外请求) - } - - // ==================== 模型下拉框交互 ==================== - - // 输入框获得焦点时,如果有已获取的模型,显示下拉 - function onModelInputFocus() { - if (fetchedModels.value.length > 0) { - modelDropdownOpen.value = true - modelDropdownHighlight.value = -1 - } - } - - // 输入内容变化时,实时过滤下拉列表 - function onModelInputChange() { - if (fetchedModels.value.length > 0) { - modelDropdownOpen.value = true - modelDropdownHighlight.value = -1 - } - } - - // 键盘导航 - function onDropdownKeyDown(direction) { - if (!modelDropdownOpen.value || filteredModels.value.length === 0) { - // 如果下拉没打开,且有模型列表,打开它 - if (fetchedModels.value.length > 0) { - modelDropdownOpen.value = true - modelDropdownHighlight.value = 0 - } - return - } - if (direction === 'down') { - modelDropdownHighlight.value = Math.min( - modelDropdownHighlight.value + 1, - filteredModels.value.length - 1 - ) - } else if (direction === 'up') { - modelDropdownHighlight.value = Math.max(modelDropdownHighlight.value - 1, 0) - } else if (direction === 'enter') { - if (modelDropdownHighlight.value >= 0 && modelDropdownHighlight.value < filteredModels.value.length) { - selectModel(filteredModels.value[modelDropdownHighlight.value]) - } else { - // 直接使用输入框的自定义值 - modelDropdownOpen.value = false - } - } - } - - // 选择一个模型 - function selectModel(m) { - editModal.value.form.model_name = m.id - modelDropdownOpen.value = false - modelDropdownHighlight.value = -1 - updateNameSuggestion() - } - - // 点击外部关闭下拉(通过 data 属性定位) - function onDocumentClick(e) { - // 如果点击的是模型输入框或下拉列表内部,不关闭(由各自的 handler 处理) - const el = e.target - if (el.closest && el.closest('[data-model-dropdown]')) return - modelDropdownOpen.value = false - } - - // ==================== 动态获取模型列表 ==================== - - async function fetchModelsFromApi() { - const form = editModal.value.form - if (!form.api_key || !form.api_key.trim()) { - toast('请先填写 API Key', 'error') - return - } - if (!form.base_url || !form.base_url.trim()) { - // 如果没有 base_url,但有 provider,用 provider 的默认地址 - if (form.provider && PROVIDER_BASE_URLS[form.provider]) { - form.base_url = PROVIDER_BASE_URLS[form.provider] - } else { - toast('请填写 API 地址', 'error') - return - } - } - - fetchLoading.value = true - try { - const json = await api.fetchModels(form.base_url, form.api_key, form.provider) - if (json.success && json.data) { - fetchedModels.value = json.data - toast('已获取 ' + json.data.length + ' 个模型,可在输入框中搜索选择', 'success') - // 获取成功后自动打开下拉框 - modelDropdownOpen.value = true - modelDropdownHighlight.value = 0 - } else { - toast(json.message || '获取模型列表失败', 'error') - fetchedModels.value = [] - } - } catch (e) { - toast('获取模型列表失败:' + e.message, 'error') - fetchedModels.value = [] - } finally { - fetchLoading.value = false - } - } - - // ==================== 配置名称自动生成 ==================== - - function updateNameSuggestion() { - const form = editModal.value.form - if (form.name) { - nameSuggestion.value = '' - return - } - const providerLabel = getProviderLabel(form.provider) - if (form.model_name && form.model_name.trim()) { - nameSuggestion.value = (providerLabel || '自定义') + '-' + form.model_name.trim() - } else { - nameSuggestion.value = '' - } - } - - // 监听 model_name 变化,更新名称建议 - watch(() => editModal.value.form.model_name, updateNameSuggestion) - watch(() => editModal.value.form.provider, updateNameSuggestion) - - // 监听 app_type 变化 - watch(() => editModal.value.form.app_type, () => { - if (editModal.value.visible) { - updateNameSuggestion() - } - }) - - // ==================== 数据加载 ==================== - - async function load(p = 1) { - currentPage.value = p - try { - const json = await api.listModelConfigs(p, 10, filterAppType.value || undefined) - if (json.success) { - configs.value = json.data || [] - total.value = json.total || 0 - totalPages.value = json.pages || 1 - loadHealthStatus() - buildFallbackChains() - } else { - toast(json.message || '查询失败', 'error') - } - } catch (e) { - toast('加载配置列表失败:' + e.message, 'error') - } - } - - // ==================== F4: 健康状态 ==================== - - async function loadHealthStatus() { - try { - const json = await api.getHealthStatus() - if (json.success && json.data) { - const data = { ...json.data } - healthSummary.value = data._summary || null - delete data._summary - healthMap.value = data - } - } catch (e) { - // 静默失败 - } - } - - function getHealthTooltip(configId) { - const h = healthMap.value[configId] - if (!h) return '未检测' - let tip = h.status === 'ONLINE' ? '在线' : h.status === 'UNKNOWN' ? '未支持自动检测' : '离线' - if (h.latencyMs) tip += ' · 延迟 ' + h.latencyMs + 'ms' - if (h.lastCheckTime) tip += ' · 最后检测 ' + new Date(h.lastCheckTime).toLocaleTimeString() - if (h.errorMessage) tip += ' · ' + h.errorMessage - return tip - } - - // ==================== F6: Fallback 链 ==================== - - function buildFallbackChains() { - const chains = [] - const appTypes = ['CHAT', 'EMBEDDING', 'RAG_REWRITE', 'RERANK'] - for (const appType of appTypes) { - const activeConfigs = configs.value - .filter(c => c.app_type === appType && c.is_active) - .sort((a, b) => (b.priority || 0) - (a.priority || 0)) - if (activeConfigs.length > 0) { - chains.push({ appType, configs: activeConfigs }) - } - } - fallbackChains.value = chains - } - - let dragItem = null - let dragChain = null - - function onDragStart(e, item, chain) { - dragItem = item - dragChain = chain - e.dataTransfer.effectAllowed = 'move' - } - - async function onDrop(e, targetItem, chain) { - if (!dragItem || dragItem.id === targetItem.id) return - const fromPriority = dragItem.priority || 0 - const toPriority = targetItem.priority || 0 - try { - await api.updateModelConfigPriority(dragItem.id, toPriority) - await api.updateModelConfigPriority(targetItem.id, fromPriority) - toast('优先级已调整', 'success') - load(currentPage.value) - } catch (err) { - toast('调整失败:' + err.message, 'error') - } - dragItem = null - dragChain = null - } - - // ==================== 弹窗操作 ==================== - - function openAddModal() { - editModal.value = { - visible: true, - mode: 'add', - editId: null, - form: createEmptyForm() - } - showApiKey.value = false - advancedParamsOpen.value = false - fetchedModels.value = [] - providerTip.value = '' - providerBaseUrlPlaceholder.value = '' - nameSuggestion.value = '' - modelDropdownOpen.value = false - modelDropdownHighlight.value = -1 - } - - function openEditModal(config) { - let extraConfig = {} - const rawExtra = config.extra_config || config.extraConfig - if (rawExtra) { - try { - extraConfig = typeof rawExtra === 'string' ? JSON.parse(rawExtra) : rawExtra - } catch (e) {} - } - editModal.value = { - visible: true, - mode: 'edit', - editId: config.id, - form: { - name: config.name || '', - app_type: config.app_type || 'CHAT', - provider: config.provider || '', - api_key: '', - model_name: config.model_name || '', - temperature: config.temperature, - max_tokens: config.max_tokens, - base_url: config.base_url || '', - embeddingDimensions: extraConfig.dimensions || 1024, - topP: extraConfig.topP ?? null, - topK: extraConfig.topK ?? null, - frequencyPenalty: extraConfig.frequencyPenalty ?? null, - presencePenalty: extraConfig.presencePenalty ?? null, - stopSequences: extraConfig.stopSequences || '', - priority: config.priority || 0, - is_active: config.is_active || false, - description: config.description || '' - } - } - showApiKey.value = false - advancedParamsOpen.value = false - fetchedModels.value = [] - nameSuggestion.value = '' - modelDropdownOpen.value = false - modelDropdownHighlight.value = -1 - // 设置提供商相关提示 - if (config.provider && PROVIDER_BASE_URLS[config.provider]) { - providerBaseUrlPlaceholder.value = PROVIDER_BASE_URLS[config.provider] - providerTip.value = PROVIDER_TIPS[config.provider] || '' - } else { - providerBaseUrlPlaceholder.value = '' - providerTip.value = '' - } - } - - function closeEditModal() { - editModal.value.visible = false - } - - // ==================== 保存配置 ==================== - - function toCamelCase(form) { - const data = { - name: form.name, - appType: form.app_type, - provider: form.provider || 'other', - apiKey: form.api_key, - modelName: form.model_name, - temperature: form.temperature, - maxTokens: form.max_tokens, - baseUrl: form.base_url, - priority: form.priority, - isActive: form.is_active, - description: form.description - } - const extraConfig = {} - if (form.app_type === 'EMBEDDING') { - extraConfig.dimensions = form.embeddingDimensions || 1024 - } - if (form.topP != null) extraConfig.topP = form.topP - if (form.topK != null) extraConfig.topK = form.topK - if (form.frequencyPenalty != null) extraConfig.frequencyPenalty = form.frequencyPenalty - if (form.presencePenalty != null) extraConfig.presencePenalty = form.presencePenalty - if (form.stopSequences && form.stopSequences.trim()) extraConfig.stopSequences = form.stopSequences.trim() - data.extraConfig = extraConfig - return data - } - - async function saveConfig() { - const form = editModal.value.form - - if (!form.name || !form.name.trim()) { - toast('请填写配置名称', 'error') - return - } - if (!form.app_type) { - toast('请选择应用类型', 'error') - return - } - if (!form.api_key || !form.api_key.trim()) { - if (editModal.value.mode === 'add') { - toast('请填写 API Key', 'error') - return - } - } - if (!form.model_name || !form.model_name.trim()) { - toast('请填写模型名称', 'error') - return - } - // provider 为空时自动设为 other - if (!form.provider) { - form.provider = 'other' - } - - try { - let json - const camelData = toCamelCase(form) - if (editModal.value.mode === 'add') { - json = await api.createModelConfig(camelData) - } else { - if (!camelData.apiKey || !camelData.apiKey.trim()) { - delete camelData.apiKey - } - json = await api.updateModelConfig(editModal.value.editId, camelData) - } - - if (json.success) { - toast(editModal.value.mode === 'add' ? '配置创建成功' : '配置更新成功', 'success') - closeEditModal() - load(currentPage.value) - } else { - toast(json.message || '操作失败', 'error') - } - } catch (e) { - toast('保存失败:' + e.message, 'error') - } - } - - // ==================== F1: 测试连接 ==================== - - async function testConnection(config) { - testLoading.value = { ...testLoading.value, [config.id]: true } - try { - const json = await api.testModelConfig(config.id) - showTestResult(json) - } catch (e) { - showTestResult({ success: false, errorMessage: e.message, latencyMs: 0 }) - } finally { - testLoading.value = { ...testLoading.value, [config.id]: false } - } - } - - async function testConnectionById(id) { - testLoading.value = { ...testLoading.value, [id]: true } - try { - const json = await api.testModelConfig(id) - showTestResult(json) - } catch (e) { - showTestResult({ success: false, errorMessage: e.message, latencyMs: 0 }) - } finally { - testLoading.value = { ...testLoading.value, [id]: false } - } - } - - function showTestResult(result) { - if (testResultTimer) clearTimeout(testResultTimer) - testResult.value = { - visible: true, - success: result.success, - latencyMs: result.latencyMs || 0, - dimensions: result.dimensions || null, - errorMessage: result.errorMessage || result.message || null - } - testResultTimer = setTimeout(() => { testResult.value.visible = false }, 5000) - } - - // ==================== F2: 配置复制 ==================== - - async function duplicateConfig(config) { - if (!confirm('确定复制配置「' + (config.name || config.id) + '」?')) return - try { - const json = await api.duplicateModelConfig(config.id) - if (json.success) { - toast('配置复制成功', 'success') - load(currentPage.value) - } else { - toast(json.message || '复制失败', 'error') - } - } catch (e) { - toast('复制失败:' + e.message, 'error') - } - } - - // ==================== 激活/停用 ==================== - - async function activate(id) { - if (!confirm('确定激活此配置?新配置将立即生效。')) return - try { - const json = await api.activateModelConfig(id) - if (json.success) { - toast('配置已激活,立即生效', 'success') - load(currentPage.value) - } else { - toast(json.message || '激活失败', 'error') - } - } catch (e) { - toast('激活失败:' + e.message, 'error') - } - } - - async function deactivateConfig(id) { - if (!confirm('确定停用此配置?将从 Fallback 链中移除。')) return - try { - const json = await api.deactivateModelConfig(id) - if (json.success) { - toast('配置已停用', 'success') - load(currentPage.value) - } else { - toast(json.message || '停用失败', 'error') - } - } catch (e) { - toast('停用失败:' + e.message, 'error') - } - } - - // ==================== 删除配置 ==================== - - async function remove(id, name) { - if (!confirm('确定删除配置「' + (name || id) + '」?')) return - try { - const json = await api.deleteModelConfig(id) - if (json.success) { - toast('配置删除成功', 'success') - load(currentPage.value) - } else { - toast(json.message || '删除失败', 'error') - } - } catch (e) { - toast('删除失败:' + e.message, 'error') - } - } - - // ==================== F7: 导入/导出 ==================== - - async function exportConfigs() { - try { - const json = await api.exportModelConfigs() - if (json.success) { - const blob = new Blob([JSON.stringify(json.data, null, 2)], { type: 'application/json' }) - const url = URL.createObjectURL(blob) - const a = document.createElement('a') - a.href = url - a.download = 'model-configs-' + new Date().toISOString().slice(0, 10) + '.json' - a.click() - URL.revokeObjectURL(url) - toast('导出成功:' + json.total + ' 条配置', 'success') - } else { - toast(json.message || '导出失败', 'error') - } - } catch (e) { - toast('导出失败:' + e.message, 'error') - } - } - - function onImportFileSelect(e) { - const file = e.target.files[0] - if (!file) return - const reader = new FileReader() - reader.onload = (ev) => { - try { - let data = JSON.parse(ev.target.result) - if (!Array.isArray(data) && data.data) data = data.data - if (!Array.isArray(data)) { - toast('JSON 格式不正确,需要数组格式', 'error') - return - } - importPreview.value = { configs: data } - importResult.value = null - } catch (err) { - toast('文件解析失败:' + err.message, 'error') - } - } - reader.readAsText(file) - } - - async function doImport() { - if (!importPreview.value) return - try { - const json = await api.importModelConfigs(importPreview.value.configs, importConflict.value) - if (json.success) { - importResult.value = json.data - toast('导入完成', 'success') - load(currentPage.value) - } else { - toast(json.message || '导入失败', 'error') - } - } catch (e) { - toast('导入失败:' + e.message, 'error') - } - } - - function closeImportDialog() { - showImportDialog.value = false - importPreview.value = null - importResult.value = null - } - - // ==================== 工具函数 ==================== - - function getAppTypeLabel(appType) { - const map = { CHAT: '💬 对话', EMBEDDING: '📐 向量化', RAG_REWRITE: '🔄 RAG重写', RERANK: '📊 重排序' } - return map[appType] || appType - } - - function getAppTypeBadgeClass(appType) { - const map = { CHAT: 'badge-get', EMBEDDING: '', RAG_REWRITE: '', RERANK: '' } - return map[appType] || '' - } - - function getProviderLabel(provider) { - const found = PROVIDER_OPTIONS.find(o => o.value === provider) - return found ? found.label : provider - } - - function getProviderBaseUrl(provider) { - return PROVIDER_BASE_URLS[provider] || '' - } - - // ==================== 生命周期 ==================== - - onMounted(() => { - load() - healthTimer = setInterval(loadHealthStatus, 30000) - // 点击外部关闭模型下拉框 - document.addEventListener('click', onDocumentClick, true) - }) - - onUnmounted(() => { - if (healthTimer) clearInterval(healthTimer) - if (testResultTimer) clearTimeout(testResultTimer) - document.removeEventListener('click', onDocumentClick, true) - }) - - return { - configs, currentPage, totalPages, total, filterAppType, showApiKey, editModal, - appTypeOptions: APP_TYPE_OPTIONS, appTypeTabs: APP_TYPE_TABS, providerOptions: PROVIDER_OPTIONS, - providerTip, providerBaseUrlPlaceholder, advancedParamsOpen, hasAdvancedParams, - // 动态模型列表 - fetchedModels, fetchLoading, fetchModelsFromApi, - // 模型下拉框 - modelDropdownOpen, modelDropdownHighlight, filteredModels, - onModelInputFocus, onModelInputChange, onDropdownKeyDown, selectModel, - // 名称建议 - nameSuggestion, - // F1 - testLoading, testResult, testConnection, testConnectionById, - // F2 - duplicateConfig, - // F4 - healthMap, healthSummary, getHealthTooltip, - // F6 - fallbackChains, showFallbackHelp, onDragStart, onDrop, deactivateConfig, - // F7 - showImportDialog, importPreview, importConflict, importResult, - onImportFileSelect, doImport, closeImportDialog, exportConfigs, - // 基础 - load, openAddModal, openEditModal, closeEditModal, saveConfig, activate, remove, - onProviderChange, onBaseUrlBlur, onApiKeyFilled, - getAppTypeLabel, getAppTypeBadgeClass, getProviderLabel, getProviderBaseUrl, formatDate - } - } -} diff --git a/src/main/resources/static/components/PipelineFlow.js b/src/main/resources/static/components/PipelineFlow.js deleted file mode 100644 index 70b6cbf..0000000 --- a/src/main/resources/static/components/PipelineFlow.js +++ /dev/null @@ -1,150 +0,0 @@ -/** - * AI 对话执行链路流程图组件 - * 使用 Mermaid.js 渲染从用户请求到 AI 回复的完整处理流程 - */ -import { ref, onMounted, nextTick } from 'vue' -import mermaid from 'mermaid' - -// 初始化 Mermaid 主题,匹配后台管理 UI 风格 -mermaid.initialize({ - startOnLoad: false, - theme: 'base', - themeVariables: { - primaryColor: '#f3f4f6', - primaryTextColor: '#1f2937', - primaryBorderColor: '#d1d5db', - lineColor: '#6b7280', - secondaryColor: '#dbeafe', - tertiaryColor: '#fef3c7', - fontSize: '13px' - }, - flowchart: { - useMaxWidth: true, - htmlLabels: true, - curve: 'basis' - } -}) - -// Mermaid 流程图 DSL 定义 -// 节点类型: [矩形]=处理步骤, {菱形}=决策分支, subgraph=子系统 -const GRAPH_DEFINITION = ` -flowchart TD - A["用户请求
message + roleId + accountId + chatId"] - - A --> B{"Controller
鉴权 / 角色解析
构建 ChatContext"} - - B --> C["ChatPipeline.buildRequest
编排决策入口"] - - C --> D{"enableRag ?"} - - D -- "❌ false" --> E["模式: 纯对话
systemPrompt(角色人设)
不检索知识库"] - - D -- "✅ true" --> F["IntentRouter
LLM 意图识别
FAQ / RAG / CHITCHAT"] - - F --> G{"意图分类结果"} - - G -- "FAQ
confidence ≧ 0.8" --> H["FaqMatchEngine
三级匹配策略
精确 → 关键词 → 向量语义"] - - G -- "CHITCHAT
confidence ≧ 0.6" --> I["模式: 纯对话
跳过知识库检索
不注入资料块"] - - G -- "RAG / 降级
其余情况" --> J["RagPipeline.retrieve
RAG 检索流水线入口"] - - subgraph RAG["📚 RAG 检索流水线"] - J --> K["1. FAQ 优先匹配
FaqMatchEngine 三级匹配
命中则短路返回"] - K --> L["2. 查询重写
REWRITE / TRANSLATION
COMPRESSION / MULTI_QUERY"] - L --> M{"3. 检索模式"} - M --> N["VECTOR
向量语义检索"] - M --> O["KEYWORD
全文关键词检索
PostgreSQL tsvector"] - M --> P["HYBRID
向量 + 关键词
双路检索"] - N --> Q["4. RRF 融合排序
Reciprocal Rank Fusion
k=60"] - O --> Q - P --> Q - Q --> R["5. Reranker 重排序
DashScope / OpenAI 兼容
超时 3s 自动 fallback"] - R --> S["6. 构建资料块
拼接检索文档
注入 system prompt 末尾"] - end - - H --> T["组装 ChatRequest
finalMessage + finalSystemPrompt
+ faqAnswer (可选)"] - I --> T - S --> T - E --> T - - T --> U["AssistantApp
chat / chatStream
构建 ChatClient + MCP 工具"] - - subgraph ADVISOR["🛡️ Advisor 链(环绕 LLM 调用)"] - U --> V["ContentSafetyAdvisor
🔽 before: DFA 敏感词检测
用户输入 BLOCK/MASK"] - V --> W["MessageChatMemoryAdvisor
注入历史对话记忆
DatabaseChatMemory 持久化"] - W --> X["MyLoggerAdvisor
🔽 before: 请求日志
INFO: AI Request: ..."] - X --> Y["🤖 ChatClient.call / stream
LLM 大模型调用
通义千问 / 其他提供商"] - Y --> Z["MyLoggerAdvisor
🔼 after: 响应日志
INFO: AI Response: ..."] - Z --> AA["ContentSafetyAdvisor
🔼 after: AI 输出检测
BLOCK/MASK 违规内容"] - end - - AA --> AB["返回 AI 回复
SSE 流式输出
+ MCP 工具调用事件"] - - style A fill:#dbeafe,stroke:#3b82f6,stroke-width:2px - style AB fill:#d1fae5,stroke:#10b981,stroke-width:2px - style Y fill:#fef3c7,stroke:#f59e0b,stroke-width:2px -` - -export default { - template: ` -
- -
-

AI 对话执行链路

-

- 下图展示从用户请求到 AI 回复的完整处理流程,包含意图路由、RAG 检索和 Advisor 链。 - 菱形节点 = 决策分支 · 虚线框 = 独立子系统 · 蓝/绿/黄色高亮 = 入口/出口/LLM。 -

-
-
用户请求 / 入口
-
决策分支
-
处理步骤
-
RAG 检索
-
Advisor 链
-
最终输出
-
LLM 调用
-
-
- - -
-
-

⚠️ 图表渲染失败: {{ error }}

- -
-
-

⏳ 正在生成流程图...

-
-
-
-
- `, - setup() { - const svg = ref('') - const error = ref('') - - async function render() { - error.value = '' - svg.value = '' - await nextTick() - try { - const { svg: result } = await mermaid.render('pipeline-graph', GRAPH_DEFINITION) - svg.value = result - } catch (e) { - console.error('Mermaid 渲染失败:', e) - error.value = e.message || '未知渲染错误' - } - } - - function retry() { - render() - } - - onMounted(() => { - render() - }) - - return { svg, error, retry } - } -} diff --git a/src/main/resources/static/components/RoleManager.js b/src/main/resources/static/components/RoleManager.js deleted file mode 100644 index 4235f8d..0000000 --- a/src/main/resources/static/components/RoleManager.js +++ /dev/null @@ -1,188 +0,0 @@ -/** - * 🎭 客服角色管理 - * 新增/编辑/删除角色,并为角色授权可检索的知识库分类。 - * 对话页只能选择这里配置好的角色,知识库范围由服务端按角色强制限定,无法跨域。 - */ -import { ref, reactive, onMounted } from 'vue' -import { store } from '../js/store.js' -import { getAllRoles, createRole, updateRole, deleteRole, updateRoleCategories } from '../js/api.js' -import { toast } from '../js/utils.js' - -export default { - template: ` -
-

客服角色管理

-

- 为角色设定人设,并授权其可检索的知识库分类。对话页只能选择这里配置好的角色,检索范围由服务端按角色强制限定,无法跨域。 -

- - -
-
-
- - -
-
- - -
-
- -
-
-
- - -
-
- - - -
-
- - -
暂无角色
-
-
-
-
- {{ role.name }} - 已停用 -
{{ role.description || '—' }}
-
-
- - -
-
-
- 人设:{{ role.prompt }} -
-
- 可检索知识库: - - 暂无知识库分类 - - (未授权 = 严格模式下不可检索任何知识库;普通模式下可检索全部) - -
-
-
-
- `, - setup() { - const roles = ref([]) - const editingId = ref('') - const form = reactive({ name: '', description: '', prompt: '', enabled: true }) - - async function reload() { - try { - const json = await getAllRoles() - if (json.success) { - roles.value = (json.data || []).map(r => ({ - ...r, - categoryIds: (r.categoryIds || []).map(String) - })) - } - } catch (e) { - toast('加载角色失败:' + e.message, 'error') - } - } - - function resetForm() { - editingId.value = '' - form.name = '' - form.description = '' - form.prompt = '' - form.enabled = true - } - - function startEdit(role) { - editingId.value = role.id - form.name = role.name || '' - form.description = role.description || '' - form.prompt = role.prompt || '' - form.enabled = role.enabled !== false - } - - async function submit() { - if (!form.name.trim()) { - toast('请输入角色名称', 'error') - return - } - const payload = { - name: form.name, - description: form.description, - prompt: form.prompt, - enabled: form.enabled - } - try { - const json = editingId.value - ? await updateRole(editingId.value, payload) - : await createRole(payload) - if (json.success) { - toast(editingId.value ? '角色已更新' : '角色已创建', 'success') - resetForm() - reload() - } else { - toast(json.message || '操作失败', 'error') - } - } catch (e) { - toast('操作失败:' + e.message, 'error') - } - } - - async function remove(role) { - if (!confirm(`确定删除角色「${role.name}」?删除后该角色及其知识库授权将一并移除。`)) return - try { - const json = await deleteRole(role.id) - if (json.success) { - toast('角色已删除', 'success') - if (editingId.value === role.id) resetForm() - reload() - } else { - toast(json.message || '删除失败', 'error') - } - } catch (e) { - toast('删除失败:' + e.message, 'error') - } - } - - async function toggleCategory(role, categoryId, checked) { - const set = new Set(role.categoryIds || []) - if (checked) set.add(categoryId) - else set.delete(categoryId) - const next = Array.from(set) - try { - const json = await updateRoleCategories(role.id, next) - if (json.success) { - role.categoryIds = next - toast('知识库授权已保存', 'success') - } else { - toast(json.message || '保存失败', 'error') - } - } catch (e) { - toast('保存失败:' + e.message, 'error') - } - } - - onMounted(() => { - store.loadCategories() - reload() - }) - - return { roles, editingId, form, store, reload, resetForm, startEdit, submit, remove, toggleCategory } - } -} diff --git a/src/main/resources/static/components/SensitiveWordManager.js b/src/main/resources/static/components/SensitiveWordManager.js deleted file mode 100644 index b00c4b6..0000000 --- a/src/main/resources/static/components/SensitiveWordManager.js +++ /dev/null @@ -1,243 +0,0 @@ -/** - * 敏感词管理组件 - * 支持 CRUD + 批量导入 + 审计日志查看 - */ -import { listSensitiveWords, createSensitiveWord, updateSensitiveWord, deleteSensitiveWord, batchImportSensitiveWords } from '../js/api.js' -import { toast } from '../js/utils.js' - -export default { - template: ` -
-

🛡️ 敏感词管理

- - -
- - - - -
- - - - - - - - - - - - - - - - - - - - - - - -
敏感词分类级别状态操作
加载中...
暂无数据
{{ w.word }}{{ categoryLabel(w.category) }}{{ w.level >= 2 ? '拦截' : '警告' }}{{ w.isActive ? '✅' : '❌' }} - - -
- - -
- - 第 {{ page }} / {{ Math.ceil(total / pageSize) }} 页(共 {{ total }} 条) - -
- - -
-
-

{{ editingWord ? '编辑敏感词' : '添加敏感词' }}

-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
-
- - -
-
-

批量导入敏感词

-
- - -
-
- - -
-
- - -
-
-
-
- `, - - data() { - return { - words: [], - loading: false, - page: 1, - pageSize: 20, - total: 0, - searchKeyword: '', - filterCategory: '', - showAddDialog: false, - showImportDialog: false, - editingWord: null, - form: { word: '', category: 'custom', level: 1, remark: '' }, - importText: '', - importCategory: 'custom', - importLevel: 1, - searchTimer: null - } - }, - - mounted() { - this.loadList() - }, - - methods: { - async loadList() { - this.loading = true - try { - const res = await listSensitiveWords(this.page, this.pageSize, this.searchKeyword || undefined, this.filterCategory || undefined) - if (res.success) { - this.words = res.data?.records || res.data || [] - this.total = res.data?.total || res.total || 0 - } - } catch (e) { - toast('加载失败: ' + e.message, 'error') - } - this.loading = false - }, - - debouncedSearch() { - clearTimeout(this.searchTimer) - this.searchTimer = setTimeout(() => { this.page = 1; this.loadList() }, 300) - }, - - editWord(w) { - this.editingWord = w - this.form = { word: w.word, category: w.category, level: w.level, remark: w.remark || '' } - }, - - closeDialog() { - this.showAddDialog = false - this.editingWord = null - this.form = { word: '', category: 'custom', level: 1, remark: '' } - }, - - async saveWord() { - try { - if (this.editingWord) { - const res = await updateSensitiveWord(this.editingWord.id, this.form) - if (res.success) toast('修改成功', 'success') - else toast(res.message || '修改失败', 'error') - } else { - const res = await createSensitiveWord(this.form) - if (res.success) toast('添加成功', 'success') - else toast(res.message || '添加失败', 'error') - } - this.closeDialog() - this.loadList() - } catch (e) { - toast('操作失败: ' + e.message, 'error') - } - }, - - async removeWord(id) { - if (!confirm('确认删除该敏感词?')) return - try { - const res = await deleteSensitiveWord(id) - if (res.success) { toast('删除成功', 'success'); this.loadList() } - else toast(res.message || '删除失败', 'error') - } catch (e) { - toast('删除失败: ' + e.message, 'error') - } - }, - - async doImport() { - const words = this.importText.split('\n').map(s => s.trim()).filter(Boolean) - if (words.length === 0) return toast('请输入敏感词', 'error') - try { - const res = await batchImportSensitiveWords({ words, category: this.importCategory, level: this.importLevel }) - if (res.success) { - toast(`成功导入 ${res.data || words.length} 条`, 'success') - this.showImportDialog = false - this.importText = '' - this.loadList() - } else { - toast(res.message || '导入失败', 'error') - } - } catch (e) { - toast('导入失败: ' + e.message, 'error') - } - }, - - async loadAuditLogs() { - try { - const res = await listAuditLogs(1, 50) - if (res.success) { - this.auditLogs = res.data?.records || res.data || [] - } else { - toast('加载审计日志失败: ' + (res.message || '未知错误'), 'error') - } - } catch (e) { - toast('加载审计日志失败: ' + e.message, 'error') - } - }, - - categoryLabel(c) { return { politics: '政治', porn: '色情', abuse: '辱骂', custom: '自定义' }[c] || c }, - categoryColor(c) { return { politics: '#dc3545', porn: '#e83e8c', abuse: '#fd7e14', custom: '#6c757d' }[c] || '#6c757d' } - } -} diff --git a/src/main/resources/static/components/SystemConfigManager.js b/src/main/resources/static/components/SystemConfigManager.js deleted file mode 100644 index 60879f7..0000000 --- a/src/main/resources/static/components/SystemConfigManager.js +++ /dev/null @@ -1,273 +0,0 @@ -/** - * 系统配置管理组件 - *

- * 管理后台编辑系统级配置项(SDK 保密声明等), - * 支持 HTML 编辑 + 实时预览。 - */ -import { getSystemConfig, listSystemConfigs, updateSystemConfig, deleteSystemConfig } from '../js/api.js' -import { toast } from '../js/utils.js' - -export default { - template: ` -

-

🔧 系统配置

-

- 管理系统级配置项,修改后实时生效(SDK 窗口刷新即拉取最新内容)。 -

- - -
- -
- - -
-
- 加载中... -
-
- 暂无配置项,点击「新建配置」开始 -
-
-
-
- {{ item.configKey }} -
-
- {{ item.description || '无描述' }} -
-
-
- {{ item.updateTime ? formatTime(item.updateTime) : '' }} -
- -
-
- - -
-
- - {{ isAdd ? '新建' : '编辑' }} {{ editingKey }} - - — {{ editingItem.description }} - -
- - -
-
- - -
-
- - -
-
- -
- -
- - -
- - -
- -
-
-
- 预览区域(输入内容后实时显示效果) -
-
-
-
- - -
- - -
-
-
- `, - - data() { - return { - configs: [], - loading: false, - saving: false, - editing: false, - isAdd: false, - editingKey: '', - editingItem: {}, - form: { configKey: '', configValue: '', description: '' } - } - }, - - mounted() { - this.load() - }, - - methods: { - async load() { - this.loading = true - try { - const json = await listSystemConfigs() - if (json.success) { - this.configs = json.data || [] - } - } catch (e) { - toast(e.message || '加载失败', 'error') - } finally { - this.loading = false - } - }, - - edit(item) { - this.isAdd = false - this.editingKey = item.configKey - this.editingItem = item - this.form.configKey = item.configKey - this.form.configValue = item.configValue || '' - this.form.description = item.description || '' - this.editing = true - }, - - startAdd() { - this.isAdd = true - this.editingKey = '' - this.editingItem = {} - this.form.configKey = '' - this.form.configValue = '' - this.form.description = '' - this.editing = true - }, - - cancelEdit() { - this.editing = false - this.isAdd = false - this.editingKey = '' - this.editingItem = {} - this.form.configKey = '' - this.form.configValue = '' - this.form.description = '' - }, - - async save() { - if (this.saving) return - - // 新建时需要校验 configKey - if (this.isAdd) { - const key = (this.form.configKey || '').trim() - if (!key) { - toast('请输入配置键', 'error') - return - } - if (!/^[a-zA-Z][a-zA-Z0-9_-]*$/.test(key)) { - toast('配置键只能包含字母、数字、下划线和连字符,且以字母开头', 'error') - return - } - this.editingKey = key - } - - this.saving = true - try { - const json = await updateSystemConfig( - this.editingKey, - this.form.configValue, - this.form.description || this.editingItem.description || '' - ) - if (json.success) { - toast('保存成功', 'success') - this.cancelEdit() - this.load() - } else { - toast(json.message || '保存失败', 'error') - } - } catch (e) { - toast(e.message || '保存失败', 'error') - } finally { - this.saving = false - } - }, - - async deleteItem(item) { - if (!confirm(`确定删除配置「${item.configKey}」?`)) return - try { - const json = await deleteSystemConfig(item.configKey) - if (json.success) { - toast('删除成功', 'success') - this.load() - } else { - toast(json.message || '删除失败', 'error') - } - } catch (e) { - toast(e.message || '删除失败', 'error') - } - }, - - /** 简单时间格式化 */ - formatTime(dateStr) { - if (!dateStr) return '' - const d = new Date(dateStr) - const pad = n => String(n).padStart(2, '0') - return d.getFullYear() + '-' + pad(d.getMonth() + 1) + '-' + pad(d.getDate()) + - ' ' + pad(d.getHours()) + ':' + pad(d.getMinutes()) - } - } -} diff --git a/src/main/resources/static/components/UserManager.js b/src/main/resources/static/components/UserManager.js deleted file mode 100644 index 6e47c99..0000000 --- a/src/main/resources/static/components/UserManager.js +++ /dev/null @@ -1,315 +0,0 @@ -/** - * 用户管理组件 - * 系统用户 CRUD + 角色分配 + 启用/禁用 - */ -import { ref, reactive, onMounted } from 'vue' -import { - listSysUsers, createSysUser, updateSysUser, toggleSysUser, - assignSysUserRoles, changeSysUserPassword, getAllSysRoles -} from '../js/api.js' -import { toast, formatDate } from '../js/utils.js' - -export default { - template: ` -
-
-

👥 用户管理

- -
- - -
- - - -
- - -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - -
用户名昵称邮箱角色状态最后登录创建时间操作
{{ u.username }}{{ u.nickname || '-' }}{{ u.email || '-' }} - {{ r }} - 未分配 - - - {{ u.enabled ? '启用' : '禁用' }} - - {{ u.last_login_time ? formatDate(u.last_login_time) : '从未登录' }}{{ formatDate(u.create_time) }} - - - - -
暂无用户数据
-
- - - - - - - - - - - - -
- `, - setup() { - const users = ref([]) - const total = ref(0) - const page = ref(1) - const pageSize = ref(20) - const keyword = ref('') - const filterEnabled = ref(null) - const allRoles = ref([]) - const saving = ref(false) - - const modal = reactive({ - visible: false, isEdit: false, userId: null, - username: '', password: '', nickname: '', email: '', phone: '' - }) - const roleModal = reactive({ - visible: false, userId: null, username: '', selectedRoleIds: [] - }) - const pwdModal = reactive({ - visible: false, userId: null, username: '', newPassword: '' - }) - - async function loadData() { - try { - const res = await listSysUsers(page.value, pageSize.value, keyword.value, filterEnabled.value) - if (res.success) { - users.value = res.data.records || [] - total.value = res.data.total || 0 - } - } catch (e) { - console.error('加载用户列表失败', e) - } - } - - async function loadRoles() { - try { - const res = await getAllSysRoles() - if (res.success) allRoles.value = res.data || [] - } catch (e) { - console.error('加载角色列表失败', e) - } - } - - function openCreate() { - Object.assign(modal, { - visible: true, isEdit: false, userId: null, - username: '', password: '', nickname: '', email: '', phone: '' - }) - } - - function openEdit(u) { - Object.assign(modal, { - visible: true, isEdit: true, userId: u.id, - username: u.username, nickname: u.nickname || '', - email: u.email || '', phone: u.phone || '', password: '' - }) - } - - async function handleSave() { - saving.value = true - try { - let res - if (modal.isEdit) { - res = await updateSysUser(modal.userId, { - nickname: modal.nickname, email: modal.email, phone: modal.phone - }) - } else { - if (!modal.username || !modal.password) { - toast('用户名和密码不能为空', 'error') - return - } - res = await createSysUser({ - username: modal.username, password: modal.password, - nickname: modal.nickname, email: modal.email, phone: modal.phone - }) - } - if (res.success) { - toast(res.message || '操作成功', 'success') - modal.visible = false - loadData() - } else { - toast(res.message || '操作失败', 'error') - } - } catch (e) { - toast(e.message || '操作失败', 'error') - } finally { - saving.value = false - } - } - - async function handleToggle(u) { - const action = u.enabled ? '禁用' : '启用' - if (!confirm(`确定${action}用户「${u.username}」?`)) return - try { - const res = await toggleSysUser(u.id, !u.enabled) - if (res.success) { - toast(res.message || '操作成功', 'success') - loadData() - } else { - toast(res.message || '操作失败', 'error') - } - } catch (e) { - toast(e.message || '操作失败', 'error') - } - } - - async function openRoleAssign(u) { - roleModal.userId = u.id - roleModal.username = u.username - // 预选当前角色 - const roleKeys = (u.role_keys || '').split(',').filter(Boolean) - roleModal.selectedRoleIds = allRoles.value - .filter(r => roleKeys.includes(r.roleKey)) - .map(r => r.id.toString()) - roleModal.visible = true - } - - async function handleRoleAssign() { - try { - const roleIds = roleModal.selectedRoleIds.map(Number) - const res = await assignSysUserRoles(roleModal.userId, roleIds) - if (res.success) { - toast('角色分配成功', 'success') - roleModal.visible = false - loadData() - } else { - toast(res.message || '分配失败', 'error') - } - } catch (e) { - toast(e.message || '分配失败', 'error') - } - } - - function openPasswordChange(u) { - pwdModal.userId = u.id - pwdModal.username = u.username - pwdModal.newPassword = '' - pwdModal.visible = true - } - - async function handlePasswordChange() { - if (!pwdModal.newPassword) { toast('请输入新密码', 'error'); return } - try { - const res = await changeSysUserPassword(pwdModal.userId, pwdModal.newPassword) - if (res.success) { - toast('密码修改成功', 'success') - pwdModal.visible = false - } else { - toast(res.message || '修改失败', 'error') - } - } catch (e) { - toast(e.message || '修改失败', 'error') - } - } - - onMounted(() => { loadData(); loadRoles() }) - - return { - users, total, page, pageSize, keyword, filterEnabled, allRoles, saving, - modal, roleModal, pwdModal, - loadData, openCreate, openEdit, handleSave, handleToggle, - openRoleAssign, handleRoleAssign, openPasswordChange, handlePasswordChange, - formatDate - } - } -} diff --git a/src/main/resources/static/components/WebhookManager.js b/src/main/resources/static/components/WebhookManager.js deleted file mode 100644 index 1dd2178..0000000 --- a/src/main/resources/static/components/WebhookManager.js +++ /dev/null @@ -1,216 +0,0 @@ -/** - * Webhook 管理组件 - * 支持 Webhook 的创建、编辑、删除、测试推送、分页查询 - */ -import { ref, onMounted } from 'vue' -import { listWebhooks, createWebhook, updateWebhook, deleteWebhook, testWebhook } from '../js/api.js' -import { toast } from '../js/utils.js' - -export default { - template: ` -
-

🔔 Webhook 管理

- - -
- - 共 {{ total }} 个 -
- - - - - - - - - - - - - - - - - - - - - - - -
名称URL订阅事件状态操作
加载中...
暂无 Webhook
{{ w.name || '未命名' }} - {{ w.url }} - -
- {{ ev }} -
-
- - {{ w.enabled ? '✅ 启用' : '❌ 禁用' }} - - - - - -
- - -
- - 第 {{ page }} / {{ Math.ceil(total / pageSize) }} 页(共 {{ total }} 条) - -
- - -
-
-

{{ editingId ? '编辑 Webhook' : '新建 Webhook' }}

-
- - -
-
- - -
-
- -
- -
-
-
- -
-
- - -
-
-
-
- `, - - setup() { - const webhooks = ref([]) - const loading = ref(false) - const page = ref(1) - const pageSize = ref(20) - const total = ref(0) - const showFormDialog = ref(false) - const editingId = ref(null) - const form = ref({ name: '', url: '', events: [], enabled: true }) - - const availableEvents = [ - { value: 'document.processed', label: '文档处理完成时触发' }, - { value: 'feedback.negative', label: '收到负面反馈时触发' } - ] - - async function loadList() { - loading.value = true - try { - const res = await listWebhooks(page.value, pageSize.value) - if (res.success) { - webhooks.value = res.data || [] - total.value = res.total || 0 - } - } catch (e) { - toast('加载失败: ' + e.message, 'error') - } - loading.value = false - } - - function getEvents(w) { - if (!w.events) return [] - const eventsObj = w.events - if (eventsObj.list && Array.isArray(eventsObj.list)) return eventsObj.list - // 兼容直接是数组的情况 - if (Array.isArray(eventsObj)) return eventsObj - return [] - } - - function openCreateDialog() { - editingId.value = null - form.value = { name: '', url: '', events: [], enabled: true } - showFormDialog.value = true - } - - function openEditDialog(w) { - editingId.value = w.id - form.value = { - name: w.name || '', - url: w.url || '', - events: getEvents(w).slice(), - enabled: w.enabled !== false - } - showFormDialog.value = true - } - - async function doSave() { - try { - let res - if (editingId.value) { - res = await updateWebhook(editingId.value, { - name: form.value.name, - url: form.value.url, - events: form.value.events, - enabled: form.value.enabled - }) - } else { - res = await createWebhook({ - name: form.value.name, - url: form.value.url, - events: form.value.events - }) - } - if (res.success) { - toast(editingId.value ? '更新成功' : '创建成功', 'success') - showFormDialog.value = false - loadList() - } else { - toast(res.message || '操作失败', 'error') - } - } catch (e) { - toast('操作失败: ' + e.message, 'error') - } - } - - async function doTest(id) { - try { - const res = await testWebhook(id) - if (res.success) toast('测试推送已发送', 'success') - else toast(res.message || '测试失败', 'error') - } catch (e) { - toast('测试失败: ' + e.message, 'error') - } - } - - async function doDelete(id) { - if (!confirm('确认删除该 Webhook?')) return - try { - const res = await deleteWebhook(id) - if (res.success) { toast('已删除', 'success'); loadList() } - else toast(res.message || '删除失败', 'error') - } catch (e) { - toast('删除失败: ' + e.message, 'error') - } - } - - onMounted(() => loadList()) - - return { - webhooks, loading, page, pageSize, total, - showFormDialog, editingId, form, availableEvents, - loadList, getEvents, openCreateDialog, openEditDialog, - doSave, doTest, doDelete - } - } -} diff --git a/src/main/resources/static/css/main.css b/src/main/resources/static/css/main.css deleted file mode 100644 index e17b920..0000000 --- a/src/main/resources/static/css/main.css +++ /dev/null @@ -1,585 +0,0 @@ -/* ==================== CSS 变量 ==================== */ -:root { - --bg:#f5f6f8; --card:#ffffff; --surface:#fafbfc; - --primary:#111827; --primary-d:#030712; --primary2:#374151; - --success:#10b981; --warn:#f59e0b; --danger:#ef4444; - --text:#1f2937; --sub:#6b7280; --muted:#9ca3af; - --border:#e7e9ee; --border-strong:#d1d5db; - --radius:12px; --radius-sm:8px; --radius-lg:16px; - --shadow-sm:0 1px 2px rgba(16,24,40,.04), 0 1px 3px rgba(16,24,40,.06); - --shadow:0 2px 8px rgba(16,24,40,.06), 0 1px 3px rgba(16,24,40,.04); - --shadow-lg:0 10px 30px rgba(16,24,40,.10); - --ring:0 0 0 3px rgba(17,24,39,.12); -} - -* { margin:0; padding:0; box-sizing:border-box; } -html, body { height:100%; } -body { font-family:-apple-system,BlinkMacSystemFont,"Segoe UI","Microsoft YaHei",sans-serif; background:var(--bg); color:var(--text); overflow:hidden; -webkit-font-smoothing:antialiased; -moz-osx-font-smoothing:grayscale; line-height:1.55; } -#app { height:100vh; display:flex; flex-direction:column; min-height:0; } - -/* ==================== 顶部导航 ==================== */ -.topbar { background:#1f2937; color:#fff; padding:0 24px; height:56px; display:flex; align-items:center; gap:12px; flex:none; z-index:100; } -.topbar .logo { font-size:20px; font-weight:700; } -.topbar .ver { font-size:12px; opacity:.7; margin-left:4px; } -.topbar .links { margin-left:auto; display:flex; gap:12px; } -.topbar .links a { color:#fff; opacity:.8; text-decoration:none; font-size:13px; transition:opacity .2s; } -.topbar .links a:hover { opacity:1; } -.menu-toggle { display:none; align-items:center; justify-content:center; width:36px; height:36px; border:none; background:rgba(255,255,255,.1); color:#fff; border-radius:6px; cursor:pointer; font-size:18px; margin-right:4px; flex:none; } - -/* ==================== 布局主体(侧边栏 + 内容区) ==================== */ -.layout-body { display:flex; flex:1; min-height:0; overflow:hidden; } - -/* ==================== 侧边栏 ==================== */ -.sidebar { width:220px; flex:none; background:#1f2937; color:#e5e7eb; display:flex; flex-direction:column; transition:width .25s cubic-bezier(.4,0,.2,1); overflow:hidden; z-index:50; } -.sidebar.collapsed { width:56px; } -.sidebar-inner { display:flex; flex-direction:column; flex:1; min-height:0; overflow:hidden; } -.sidebar-nav { flex:1; min-height:0; overflow-y:auto; overflow-x:hidden; padding:8px 0 12px; } - -/* 菜单项 */ -.menu-item { display:flex; align-items:center; gap:10px; width:100%; padding:10px 16px; border:none; background:none; color:#d1d5db; font-size:14px; cursor:pointer; transition:background .15s, color .15s; text-align:left; font-family:inherit; white-space:nowrap; position:relative; } -.menu-item:hover { background:rgba(255,255,255,.08); color:#fff; } -.menu-item.active { background:rgba(255,255,255,.12); color:#fff; font-weight:600; } -.menu-icon { font-size:18px; flex:none; width:24px; text-align:center; } -.child-icon { font-size:15px; } -.menu-label { flex:1; overflow:hidden; text-overflow:ellipsis; } -.menu-arrow { font-size:12px; transition:transform .2s; flex:none; } -.menu-arrow.expanded { transform:rotate(0deg); } -.menu-arrow:not(.expanded) { transform:rotate(-90deg); } - -/* 子菜单 */ -.menu-child { padding-left:30px; font-size:13px; } -.submenu { overflow:hidden; transition:max-height .25s ease; max-height:0; } -.menu-group.open .submenu { max-height:600px; } - -/* 折叠按钮 */ -.sidebar-collapse-btn { border:none; background:rgba(255,255,255,.06); color:#9ca3af; padding:10px; cursor:pointer; font-size:12px; text-align:center; transition:background .15s; flex:none; } -.sidebar-collapse-btn:hover { background:rgba(255,255,255,.12); color:#fff; } -.sidebar-collapse-btn span { display:inline-block; transition:transform .25s; } - -/* 折叠态:仅显示图标 */ -.sidebar.collapsed .menu-label, -.sidebar.collapsed .menu-arrow, -.sidebar.collapsed .submenu { display:none; } -.sidebar.collapsed .menu-item { justify-content:center; padding:12px 0; } -.sidebar.collapsed .menu-icon { width:auto; } - -/* ==================== 内容区 ==================== */ -.main { flex:1; min-width:0; padding:20px; overflow-y:auto; } -/* 聊天页:圣杯布局,内容撑满视口、外层不滚动 */ -.main.main-chat { overflow:hidden; padding:16px; display:flex; } -.main.main-chat .page-pane { flex:1; display:flex; min-height:0; min-width:0; } -.page-pane { min-width:0; } -@keyframes fadeIn { from{opacity:0;transform:translateY(8px);} to{opacity:1;transform:translateY(0);} } - -/* ==================== 卡片 ==================== */ -.card { background:var(--card); border-radius:var(--radius); padding:22px; margin-bottom:16px; border:1px solid var(--border); } -.card h2 { font-size:16px; font-weight:600; margin-bottom:16px; display:flex; align-items:center; gap:8px; } -.card h3 { font-size:13px; font-weight:600; margin-bottom:8px; color:var(--sub); } - -/* ==================== 表单 ==================== */ -.input-row { display:flex; gap:8px; margin-bottom:12px; flex-wrap:wrap; } -.input, .textarea, .select { padding:10px 14px; border:1px solid var(--border); border-radius:8px; font-size:14px; font-family:inherit; outline:none; transition:border-color .2s; } -.input:focus, .textarea:focus, .select:focus { border-color:var(--primary); box-shadow:var(--ring); } -.input { flex:1; min-width:200px; width:100%; } -.textarea { width:100%; resize:vertical; min-height:120px; } -.select { min-width:160px; } -.input-sm { width:120px; flex:none; } -.input-field-sm { display:flex; flex-direction:column; gap:4px; flex:none; } -.input-field-sm span { font-size:12px; color:var(--sub); font-weight:600; } - -.btn { padding:10px 20px; border:none; border-radius:8px; font-size:14px; cursor:pointer; font-family:inherit; font-weight:500; transition:all .2s; display:inline-flex; align-items:center; gap:6px; white-space:nowrap; } -.btn:disabled { opacity:.5; cursor:not-allowed; } -.btn-primary { background:var(--primary); color:#fff; } -.btn-primary:hover:not(:disabled) { background:#000; } -.btn-purple { background:var(--primary2); color:#fff; } -.btn-purple:hover:not(:disabled) { background:#1f2937; } -.btn-success { background:var(--success); color:#fff; } -.btn-outline { background:#fff; color:var(--primary); border:1px solid var(--primary); } -.btn-sm { padding:6px 12px; font-size:12px; } -.btn-danger { background:var(--danger); color:#fff; } -.btn-warn { background:var(--warn); color:#fff; } - -/* ==================== 消息区 ==================== */ -.msg-area { border:1px solid var(--border); border-radius:var(--radius); height:400px; overflow-y:auto; padding:16px; background:#fff; margin-bottom:12px; display:flex; flex-direction:column; gap:10px; } -.msg { display:flex; gap:12px; max-width:88%; animation: fadeIn .25s; } -.msg.user { align-self:flex-end; flex-direction:row-reverse; } -.msg.assistant { align-self:flex-start; } -.msg-bubble { line-height:1.65; font-size:15px; word-break:break-word; white-space:pre-wrap; } -.msg.user .msg-bubble { background:#f4f4f5; color:var(--text); padding:10px 14px; border-radius:14px; } -.msg.assistant .msg-bubble { background:transparent; padding:1px 0; } - -/* ==================== 商品信息展示 ==================== */ -.result-grid { display:grid; grid-template-columns:repeat(auto-fill, minmax(280px, 1fr)); gap:12px; margin-top:12px; } -.result-item { background:#fafafa; padding:12px 16px; border-radius:10px; border:1px solid var(--border); } -.result-item .label { font-size:12px; color:var(--sub); margin-bottom:4px; } -.result-item .value { font-size:14px; font-weight:500; } -.result-json { background:#1e293b; color:#e2e8f0; padding:16px; border-radius:8px; font-family:'Fira Code',monospace; font-size:13px; white-space:pre-wrap; overflow-x:auto; } - -/* ==================== 文件上传区 ==================== */ -.upload-zone { border:2px dashed var(--border); border-radius:var(--radius); padding:32px; text-align:center; transition:all .2s; cursor:pointer; margin-bottom:12px; } -.upload-zone:hover, .upload-zone.drag-over { border-color:var(--primary); background:#f3f4f6; } -.upload-zone p { color:var(--sub); } -.upload-zone .icon { font-size:40px; margin-bottom:8px; } - -/* ==================== Toast ==================== */ -.toast-container { position:fixed; top:70px; right:20px; z-index:9999; display:flex; flex-direction:column; gap:8px; } -.toast { padding:10px 18px; border-radius:8px; color:#fff; font-size:13px; animation:slideIn .3s; box-shadow:0 4px 12px rgba(0,0,0,.15); } -.toast.success { background:var(--success); } -.toast.error { background:var(--danger); } -.toast.info { background:var(--primary); } -@keyframes slideIn { from{opacity:0;transform:translateX(100px);} to{opacity:1;transform:translateX(0);} } - -/* ==================== 流式结果比较 ==================== */ -.stream-compare { display:grid; grid-template-columns:1fr 1fr; gap:16px; } -.stream-compare .card { margin-bottom:0; } - -/* ==================== 表格样式 ==================== */ -.data-table { width:100%; border-collapse:collapse; font-size:13px; } -.data-table th { background:#fafafa; padding:11px 12px; text-align:left; font-weight:600; font-size:12px; color:var(--sub); border-bottom:1px solid var(--border); white-space:nowrap; } -.data-table td { padding:11px 12px; border-bottom:1px solid var(--border); vertical-align:middle; } -.data-table tr:hover { background:#fafafa; } -.data-table td .btn { margin:2px 0; } -.data-table td .btn + .btn { margin-left:6px; } -.data-table .status-ready { color:var(--success); font-weight:600; } -.data-table .status-processing { color:var(--warn); font-weight:600; } -.data-table .status-failed { color:var(--danger); font-weight:600; } - -/* ==================== 分页 ==================== */ -.pagination { display:flex; gap:4px; justify-content:center; margin-top:12px; } -.pagination button { padding:6px 12px; border:1px solid var(--border); background:#fff; border-radius:6px; cursor:pointer; font-size:12px; } -.pagination button:hover:not(:disabled) { background:var(--primary); color:#fff; border-color:var(--primary); } -.pagination button:disabled { opacity:.5; cursor:not-allowed; } -.pagination button.active { background:var(--primary); color:#fff; border-color:var(--primary); } - -/* ==================== 统计卡片 ==================== */ -.stat-grid { display:grid; grid-template-columns:repeat(auto-fit, minmax(180px, 1fr)); gap:12px; margin-bottom:16px; } -.stat-card { background:#fafafa; border-radius:10px; padding:18px 16px; text-align:center; border:1px solid var(--border); } -.stat-card .number { font-size:26px; font-weight:700; color:var(--text); letter-spacing:-.01em; } -.stat-card .label { font-size:12px; color:var(--muted); margin-top:5px; } - -/* ==================== 弹窗 ==================== */ -.modal-overlay { position:fixed; top:0; left:0; right:0; bottom:0; background:rgba(0,0,0,.5); z-index:300; display:none; align-items:center; justify-content:center; } -.modal-overlay.active { display:flex; } -.modal-box { background:#fff; border-radius:var(--radius); width:90%; max-width:800px; max-height:85vh; overflow-y:auto; padding:24px; position:relative; } -.modal-box h2 { margin-bottom:16px; font-size:18px; } -.modal-close { position:absolute; top:16px; right:20px; font-size:24px; cursor:pointer; color:var(--sub); background:none; border:none; } -.modal-close:hover { color:var(--text); } - -/* 模型配置弹窗双栏网格 */ -.model-form { display:grid; grid-template-columns:1fr 1fr; gap:14px 16px; margin-top:16px; } -.model-form-full { grid-column:1 / -1; } -@media(max-width:640px) { .model-form { grid-template-columns:1fr; } } - -/* ==================== 分类标签 ==================== */ -.category-tag { display:inline-block; padding:2px 8px; border-radius:12px; font-size:11px; background:#f3f4f6; color:var(--text); } - -/* ==================== 搜索结果 ==================== */ -.search-result { background:#fafafa; border-radius:10px; padding:12px 14px; margin-bottom:8px; border:1px solid var(--border); } -.search-result .score { font-size:12px; color:var(--sub); font-weight:600; } -.search-result .meta { font-size:12px; color:var(--sub); margin-top:4px; } -.search-result .content { font-size:13px; margin-top:6px; line-height:1.5; } - -/* ==================== 端点标签 ==================== */ -.badge { display:inline-block; padding:2px 8px; border-radius:12px; font-size:11px; font-weight:600; } -.badge-get { background:#dbeafe; color:#1d4ed8; } -.badge-post { background:#fef3c7; color:#b45309; } -.endpoint-info { display:flex; align-items:center; gap:6px; margin-bottom:8px; flex-wrap:wrap; } - -/* ==================== 消息列表样式(会话管理) ==================== */ -.msg-item { border:1px solid var(--border); } -.msg-item.msg-user { background:#fafafa; border-left:3px solid #111827; } -.msg-item.msg-assistant { background:#fff; border-left:3px solid var(--border-strong); } -.msg-item.msg-system { background:#fafafa; border-left:3px solid var(--muted); } - -/* ==================== 响应式 ==================== */ -.sidebar-overlay { display:none; } -@media(max-width:768px) { - .sidebar { position:fixed; top:56px; left:0; bottom:0; width:260px; transform:translateX(-100%); transition:transform .3s cubic-bezier(.4,0,.2,1); z-index:200; } - .sidebar.open { transform:translateX(0); } - .sidebar.collapsed { width:260px; } - .sidebar-overlay { display:block; position:fixed; top:56px; left:0; right:0; bottom:0; background:rgba(0,0,0,.4); z-index:199; } - .menu-toggle { display:flex; } - .sidebar-collapse-btn { display:none; } - .main { padding:16px; } - .main.main-chat { padding:12px; } - .stream-compare { grid-template-columns:1fr; } - .stat-grid { grid-template-columns:1fr 1fr; } -} - -/* ==================== Chat Panel ==================== */ -.chat-shell { display:grid; grid-template-columns:260px minmax(0, 1fr); gap:16px; flex:1; min-width:0; min-height:0; } -.chat-sidebar, .chat-main { background:var(--card); border:1px solid var(--border); border-radius:var(--radius); } -.chat-sidebar { padding:16px; display:flex; flex-direction:column; gap:20px; overflow:hidden; min-height:0; } -.agent-card { display:flex; align-items:center; gap:12px; } -.agent-avatar { width:40px; height:40px; border-radius:10px; display:flex; align-items:center; justify-content:center; background:#4f46e5; color:#fff; font-weight:700; font-size:14px; } -.agent-name { font-weight:600; font-size:14px; } -.agent-status { margin-top:3px; color:var(--muted); font-size:12px; display:flex; align-items:center; gap:6px; } -.agent-status span { width:6px; height:6px; border-radius:999px; background:var(--success); display:inline-block; } -.side-section { display:flex; flex-direction:column; gap:8px; } -.side-label { font-size:12px; color:var(--muted); font-weight:500; } -.toggle-line { display:flex; align-items:center; gap:8px; font-size:13px; cursor:pointer; } -.toggle-line input { width:16px; height:16px; } -.chat-select { width:100%; min-width:0; } -.chat-main { display:flex; flex-direction:column; min-width:0; min-height:0; overflow:hidden; } -.chat-header { padding:18px 24px; border-bottom:1px solid var(--border); display:flex; align-items:center; justify-content:space-between; gap:16px; } -.chat-title h2 { font-size:16px; font-weight:600; } -.chat-subline { margin-top:5px; color:var(--muted); font-size:12px; display:flex; align-items:center; gap:7px; flex-wrap:wrap; } -.chat-subline .dot-sep { color:var(--border-strong); } -.rag-dot { width:7px; height:7px; border-radius:999px; background:var(--muted); display:inline-block; } -.rag-dot.on { background:var(--success); } -.chat-actions { display:flex; align-items:center; gap:10px; flex-wrap:wrap; justify-content:flex-end; } -.model-chip { height:40px; min-width:190px; max-width:280px; padding:0 12px; border:1px solid var(--border); border-radius:8px; display:flex; align-items:center; gap:8px; background:#fff; color:var(--text); font-size:12px; } -.model-chip span { color:var(--muted); white-space:nowrap; } -.model-chip strong { min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; font-size:13px; font-weight:600; } -.model-chip-error strong { color:var(--warn); } -.rag-toggle { height:40px; padding:0 12px; border:1px solid var(--border); border-radius:8px; display:flex; align-items:center; gap:8px; font-size:13px; font-weight:600; background:#fff; cursor:pointer; user-select:none; } -.rag-toggle input { width:16px; height:16px; accent-color:var(--primary); } -.rag-strategy { min-width:112px; font-size:13px; } -.chat-mode { min-width:130px; font-size:13px; } -.quick-row { padding:18px 24px 0; display:flex; gap:8px; flex-wrap:wrap; } -.quick-row button { border:none; background:#f7f7f8; border-radius:999px; padding:7px 14px; font-size:13px; color:var(--sub); cursor:pointer; transition:all .15s; } -.quick-row button:hover { background:#ececee; color:var(--text); } -.chat-msg-area { flex:1; height:auto; min-height:0; margin:0; border:none; border-radius:0; padding:24px; background:#fff; gap:18px; } -.chat-msg-area .msg { max-width:100%; } -.chat-msg-area .msg.user { max-width:80%; } -.chat-msg-area .msg-content { min-width:0; } -.edit-textarea { width:100%; min-width:min(260px, 100%); } -.edit-actions { display:flex; align-items:center; gap:8px; margin-top:8px; flex-wrap:wrap; } -.edit-actions span { font-size:12px; color:var(--muted); } -.msg-tools { margin-top:7px; display:flex; align-items:center; gap:4px; font-size:12px; color:var(--muted); flex-wrap:wrap; opacity:0; transition:opacity .15s; } -.msg:hover .msg-tools, .msg.streaming .msg-tools { opacity:1; } -.msg-tools span { margin-right:4px; } -.msg-tools button { border:none; background:transparent; color:var(--muted); cursor:pointer; font-size:12px; padding:2px 7px; border-radius:6px; line-height:1.4; transition:all .15s; } -.msg-tools button:hover { background:#f4f4f5; color:var(--text); } -.msg-tools button:focus-visible { outline:none; box-shadow:var(--ring); } - -/* ==================== MCP 工具调用展示 ==================== */ -.mcp-tool-calls { margin-bottom:8px; border-left:3px solid var(--primary, #4f46e5); padding-left:10px; } -.mcp-tool-item { padding:4px 0; } -.mcp-tool-header { display:flex; align-items:center; gap:6px; font-size:12px; color:#6b7280; } -.mcp-tool-icon { font-size:14px; } -.mcp-tool-name { font-family:monospace; background:#f3f4f6; padding:1px 6px; border-radius:4px; font-size:11px; } -.mcp-tool-latency { color:#9ca3af; font-size:11px; } - -.chat-composer { border-top:1px solid var(--border); padding:16px 24px 20px; background:#fff; } -.composer-box { display:grid; grid-template-columns:minmax(0,1fr) auto; gap:8px; align-items:end; border:1px solid var(--border); border-radius:14px; padding:8px 8px 8px 14px; transition:border-color .15s; } -.composer-box:focus-within { border-color:var(--text); } -.chat-textarea { border:none; outline:none; background:transparent; resize:none; min-height:40px; max-height:160px; padding:8px 0; font-size:15px; font-family:inherit; line-height:1.5; } -.send-btn { height:40px; min-width:72px; border:none; border-radius:10px; background:var(--text); color:#fff; font-size:14px; font-weight:500; cursor:pointer; transition:opacity .15s; } -.send-btn:hover:not(:disabled) { opacity:.85; } -.send-btn:disabled { opacity:.35; cursor:not-allowed; } - -@media(max-width:860px) { - .main.main-chat { overflow-y:auto; display:block; } - .page-pane { display:block; } - .chat-shell { grid-template-columns:1fr; height:auto; min-height:0; } - .chat-sidebar { order:1; overflow:visible; } - .chat-main { order:2; min-height:560px; } - .chat-msg-area { min-height:360px; } -} -@media(max-width:640px) { - .chat-header { flex-direction:column; align-items:flex-start; } - .chat-actions { width:100%; justify-content:flex-start; } - .chat-msg-area .msg, .chat-msg-area .msg.user { max-width:100%; } - .quick-row { padding:14px 16px 0; } - .chat-msg-area { padding:16px; } - .chat-composer { padding:12px 16px 16px; } -} - -/* ==================== 侧边栏暗色滚动条 ==================== */ -.sidebar-nav { scrollbar-width:thin; scrollbar-color:rgba(255,255,255,.15) transparent; } -.sidebar-nav::-webkit-scrollbar { width:6px; } -.sidebar-nav::-webkit-scrollbar-thumb { background:rgba(255,255,255,.15); border-radius:999px; } -.sidebar-nav::-webkit-scrollbar-thumb:hover { background:rgba(255,255,255,.25); } -.sidebar-nav::-webkit-scrollbar-track { background:transparent; } - -/* ==================== 滚动条 ==================== */ -* { scrollbar-width:thin; scrollbar-color:#cbd0d8 transparent; } -::-webkit-scrollbar { width:10px; height:10px; } -::-webkit-scrollbar-thumb { background:#cbd0d8; border-radius:999px; border:2px solid transparent; background-clip:content-box; } -::-webkit-scrollbar-thumb:hover { background:#aab0bb; background-clip:content-box; } -::-webkit-scrollbar-track { background:transparent; } - -/* ==================== Markdown 渲染(AI 回复) ==================== */ -.msg-bubble.markdown-body { white-space:normal; } -.markdown-body { font-size:14px; line-height:1.7; word-break:break-word; } -.markdown-body > *:first-child { margin-top:0; } -.markdown-body > *:last-child { margin-bottom:0; } -.markdown-body p { margin:8px 0; } -.markdown-body h1,.markdown-body h2,.markdown-body h3,.markdown-body h4 { margin:14px 0 8px; line-height:1.3; font-weight:700; } -.markdown-body h1 { font-size:20px; } .markdown-body h2 { font-size:18px; } .markdown-body h3 { font-size:16px; } .markdown-body h4 { font-size:14px; } -.markdown-body ul,.markdown-body ol { margin:8px 0; padding-left:22px; } -.markdown-body li { margin:3px 0; } -.markdown-body li>p { margin:2px 0; } -.markdown-body a { color:var(--primary); text-decoration:none; } -.markdown-body a:hover { text-decoration:underline; } -.markdown-body code { font-family:'Fira Code',ui-monospace,SFMono-Regular,Menlo,monospace; font-size:.88em; background:#f1f1f2; color:#374151; padding:.12em .4em; border-radius:5px; } -.markdown-body pre { background:#1e293b; color:#e2e8f0; padding:14px 16px; border-radius:10px; overflow-x:auto; margin:10px 0; } -.markdown-body pre code { background:none; color:inherit; padding:0; font-size:13px; } -.markdown-body blockquote { margin:10px 0; padding:6px 14px; border-left:2px solid var(--border-strong); background:#fafafa; color:var(--sub); border-radius:0 8px 8px 0; } -.markdown-body table { border-collapse:collapse; margin:10px 0; font-size:13px; width:100%; } -.markdown-body th,.markdown-body td { border:1px solid var(--border); padding:6px 10px; text-align:left; } -.markdown-body th { background:#f3f4f6; font-weight:600; } -.markdown-body hr { border:none; border-top:1px solid var(--border); margin:14px 0; } -.markdown-body img { max-width:100%; border-radius:8px; } - -/* 思考中光标 */ -.msg .thinking { color:var(--sub); } -.msg .thinking::after { content:'▋'; margin-left:2px; animation:blink 1s steps(2) infinite; } -@keyframes blink { 0%,50%{opacity:1;} 51%,100%{opacity:0;} } - -/* ==================== 对话页 · 助手卡片(左) ==================== */ -.side-section-grow { flex:1; min-height:0; } -.assistant-list { display:flex; flex-direction:column; gap:4px; overflow-y:auto; padding-right:2px; margin:0 -6px; } -.assistant-card { width:100%; border:none; background:transparent; border-radius:9px; padding:9px 10px; display:flex; gap:10px; align-items:center; text-align:left; cursor:pointer; transition:background .12s; font-family:inherit; } -.assistant-card:hover { background:#f4f4f5; } -.assistant-card.active { background:#f4f4f5; } -.assistant-avatar-sm { flex:none; width:30px; height:30px; border-radius:8px; display:flex; align-items:center; justify-content:center; font-size:12px; font-weight:600; color:#fff; background:#0d9488; } -.assistant-list .assistant-card:nth-child(6n+1) .assistant-avatar-sm { background:#0d9488; } -.assistant-list .assistant-card:nth-child(6n+2) .assistant-avatar-sm { background:#ea580c; } -.assistant-list .assistant-card:nth-child(6n+3) .assistant-avatar-sm { background:#2563eb; } -.assistant-list .assistant-card:nth-child(6n+4) .assistant-avatar-sm { background:#16a34a; } -.assistant-list .assistant-card:nth-child(6n+5) .assistant-avatar-sm { background:#db2777; } -.assistant-list .assistant-card:nth-child(6n+6) .assistant-avatar-sm { background:#0891b2; } -.assistant-meta { min-width:0; display:flex; flex-direction:column; gap:1px; } -.assistant-meta strong { font-size:13px; font-weight:600; color:var(--text); } -.assistant-meta small { font-size:12px; color:var(--muted); line-height:1.4; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; } - -/* ==================== 引用来源(回答下方) ==================== */ -.msg-sources { margin-top:14px; padding-top:12px; border-top:1px solid var(--border); } -.msg-sources-head { font-size:12px; font-weight:500; color:var(--muted); margin-bottom:10px; display:flex; align-items:center; justify-content:space-between; gap:10px; flex-wrap:wrap; } -.source-doc { border:none; border-radius:0; background:transparent; padding:0; margin-bottom:12px; } -.source-doc:last-child { margin-bottom:0; } -.source-doc.clickable { cursor:pointer; } -.source-doc.clickable:hover .source-doc-title { color:var(--primary); } -.source-doc-head { display:flex; align-items:center; gap:8px; margin-bottom:5px; flex-wrap:wrap; } -.source-doc-title { font-size:13px; font-weight:600; color:var(--text); word-break:break-word; min-width:0; transition:color .15s; } -.source-open { font-size:11px; color:var(--muted); flex:none; } -.source-chunk { font-size:12px; line-height:1.6; color:var(--sub); border-left:2px solid var(--border); padding-left:10px; margin-top:4px; } -.source-rel { display:inline-block; font-size:11px; color:var(--muted); margin-right:6px; font-weight:600; } -.source-snippet { color:var(--sub); } - -/* ==================== 登录页 ==================== */ -.login-page { position:fixed; top:0; left:0; right:0; bottom:0; background:linear-gradient(135deg,#1f2937 0%,#111827 100%); display:flex; align-items:center; justify-content:center; z-index:9999; } -.login-card { background:#fff; border-radius:var(--radius-lg); padding:40px; width:100%; max-width:400px; box-shadow:var(--shadow-lg); } -.login-header { text-align:center; margin-bottom:32px; } -.login-logo { font-size:48px; margin-bottom:8px; } -.login-header h1 { font-size:22px; font-weight:700; color:var(--text); } -.login-subtitle { font-size:13px; color:var(--muted); margin-top:4px; } -.login-form .form-group { margin-bottom:16px; } -.login-form label { display:block; font-size:13px; font-weight:600; color:var(--sub); margin-bottom:6px; } -.login-form input { width:100%; padding:12px 14px; border:1px solid var(--border); border-radius:8px; font-size:14px; font-family:inherit; outline:none; transition:border-color .2s; } -.login-form input:focus { border-color:var(--primary); box-shadow:var(--ring); } -.btn-login { width:100%; padding:12px; background:var(--primary); color:#fff; border:none; border-radius:8px; font-size:15px; font-weight:600; cursor:pointer; transition:opacity .2s; margin-top:8px; } -.btn-login:hover:not(:disabled) { opacity:.9; } -.btn-login:disabled { opacity:.5; cursor:not-allowed; } -.login-error { color:var(--danger); font-size:13px; text-align:center; margin-top:12px; } -.login-footer { text-align:center; margin-top:20px; font-size:12px; color:var(--muted); } - -/* ==================== 用户栏(topbar 右侧) ==================== */ -.user-bar { margin-left:auto; display:flex; align-items:center; gap:12px; } -.user-info { display:flex; align-items:center; gap:8px; } -.user-avatar { width:28px; height:28px; border-radius:50%; background:rgba(255,255,255,.2); color:#fff; display:flex; align-items:center; justify-content:center; font-size:13px; font-weight:600; } -.user-name { font-size:13px; color:#e5e7eb; } -.user-role-tag { font-size:10px; padding:2px 6px; border-radius:4px; background:rgba(255,255,255,.15); color:#d1d5db; } -.btn-logout { background:rgba(255,255,255,.1); color:#e5e7eb; border:1px solid rgba(255,255,255,.2); font-size:12px; padding:4px 12px; } -.btn-logout:hover { background:rgba(255,255,255,.2); color:#fff; } - -/* ==================== 弹窗(用户管理等通用) ==================== */ -.modal-card { background:#fff; border-radius:var(--radius); width:90%; max-width:500px; max-height:85vh; overflow-y:auto; padding:24px; } -.modal-card h3 { font-size:16px; font-weight:600; margin-bottom:20px; } -.modal-card .form-group { margin-bottom:14px; } -.modal-card label { display:block; font-size:13px; font-weight:600; color:var(--sub); margin-bottom:4px; } -.modal-card input { width:100%; padding:10px 12px; border:1px solid var(--border); border-radius:8px; font-size:13px; font-family:inherit; outline:none; } -.modal-card input:focus { border-color:var(--primary); box-shadow:var(--ring); } -.modal-card input:disabled { background:#f5f5f5; color:var(--muted); } -.modal-actions { display:flex; justify-content:flex-end; gap:8px; margin-top:20px; padding-top:16px; border-top:1px solid var(--border); } - -/* ==================== 工具栏 ==================== */ -.toolbar { display:flex; gap:8px; margin-bottom:16px; align-items:center; flex-wrap:wrap; } -.search-input { padding:8px 12px; border:1px solid var(--border); border-radius:8px; font-size:13px; outline:none; min-width:200px; flex:1; max-width:300px; } -.search-input:focus { border-color:var(--primary); box-shadow:var(--ring); } -.search-select { padding:8px 12px; border:1px solid var(--border); border-radius:8px; font-size:13px; background:#fff; outline:none; } - -/* ==================== 复选框标签 ==================== */ -.checkbox-label { display:flex; align-items:center; gap:8px; padding:8px 0; font-size:13px; cursor:pointer; } -.checkbox-label input[type="checkbox"] { width:16px; height:16px; accent-color:var(--primary); } - -/* ==================== 标签 ==================== */ -.tag { display:inline-block; padding:2px 8px; border-radius:4px; font-size:11px; background:#e5e7eb; color:var(--text); margin-right:4px; } -.tag-muted { background:#f3f4f6; color:var(--muted); } - -/* ==================== 空状态 ==================== */ -.empty { text-align:center; color:var(--muted); padding:20px; font-size:13px; } - -/* ==================== 卡片头部 ==================== */ -.card-header { display:flex; align-items:center; justify-content:space-between; margin-bottom:16px; } -.card-header h2 { margin-bottom:0; } - -/* ==================== 操作按钮组 ==================== */ -.actions { white-space:nowrap; } -.actions .btn { margin:2px; } - -/* ==================== 表单组 ==================== */ -.form-group { margin-bottom:14px; } -.form-group label { display:block; font-size:13px; font-weight:600; color:var(--sub); margin-bottom:4px; } -.form-group input, .form-group select, .form-group textarea { width:100%; padding:10px 12px; border:1px solid var(--border); border-radius:8px; font-size:13px; font-family:inherit; outline:none; } -.form-group input:focus, .form-group select:focus, .form-group textarea:focus { border-color:var(--primary); box-shadow:var(--ring); } - -@media(max-width:768px) { - .user-bar { gap:8px; } - .user-name, .user-role-tag { display:none; } -} - -/* ==================== P1: 启用/禁用开关 ==================== */ -.switch-toggle { - position: relative; - display: inline-block; - width: 36px; - height: 20px; - cursor: pointer; -} -.switch-toggle input { opacity: 0; width: 0; height: 0; } -.switch-slider { - position: absolute; - top: 0; left: 0; right: 0; bottom: 0; - background: #d1d5db; - border-radius: 20px; - transition: 0.2s; -} -.switch-slider::before { - content: ''; - position: absolute; - width: 16px; height: 16px; - left: 2px; bottom: 2px; - background: white; - border-radius: 50%; - transition: 0.2s; -} -.switch-toggle input:checked + .switch-slider { - background: #10b981; -} -.switch-toggle input:checked + .switch-slider::before { - transform: translateX(16px); -} - -/* ==================== P1: 标签输入组件 ==================== */ -.tag-input-wrapper { - display: flex; - flex-wrap: wrap; - gap: 4px; - padding: 4px 8px; - border: 1px solid var(--border); - border-radius: 8px; - background: var(--card); - min-height: 38px; - align-items: center; - cursor: text; -} -.tag-input-wrapper:focus-within { - border-color: var(--primary); - box-shadow: var(--ring); -} -.tag-input-wrapper .tag-badge { - display: inline-flex; - align-items: center; - gap: 4px; - background: #e0f2fe; - color: #0369a1; - padding: 2px 8px; - border-radius: 4px; - font-size: 12px; - white-space: nowrap; -} -.tag-input-wrapper .tag-badge .tag-remove { - cursor: pointer; - font-size: 14px; - line-height: 1; - opacity: 0.6; -} -.tag-input-wrapper .tag-badge .tag-remove:hover { opacity: 1; } -.tag-input-wrapper input { - border: none; - outline: none; - flex: 1; - min-width: 80px; - font-size: 13px; - padding: 4px 0; - background: transparent; -} -.tag-suggestions { - position: absolute; - top: 100%; - left: 0; - right: 0; - background: var(--card); - border: 1px solid var(--border); - border-radius: 8px; - box-shadow: 0 4px 12px rgba(0,0,0,0.1); - z-index: 100; - max-height: 150px; - overflow-y: auto; -} -.tag-suggestions .tag-suggestion-item { - padding: 6px 12px; - font-size: 13px; - cursor: pointer; -} -.tag-suggestions .tag-suggestion-item:hover { - background: #f3f4f6; -} - -/* ==================== AI 执行链流程图 ==================== */ -.pipeline-diagram { - width: 100%; - overflow-x: auto; - padding: 8px 0; -} -.pipeline-diagram svg { - max-width: 100%; - height: auto; - display: block; - margin: 0 auto; -} -.pipeline-loading { - text-align: center; - padding: 40px; - color: var(--muted); - font-size: 14px; -} -.pipeline-error { - text-align: center; - padding: 24px; - color: var(--danger); - font-size: 14px; -} -.pipeline-error .btn { - margin-top: 12px; -} - -/* 图例 */ -.pipeline-legend { - display: flex; - flex-wrap: wrap; - gap: 10px 20px; -} -.legend-item { - display: flex; - align-items: center; - gap: 8px; - font-size: 12px; - color: var(--sub); -} -.legend-dot { - width: 14px; - height: 14px; - border-radius: 3px; - flex: none; -} -.legend-input { background: #dbeafe; border: 1px solid #3b82f6; } -.legend-decision { background: #fef3c7; border: 1px solid #f59e0b; transform: rotate(45deg); border-radius: 2px; } -.legend-process { background: #f3f4f6; border: 1px solid #d1d5db; } -.legend-rag { background: #e0e7ff; border: 1px solid #a5b4fc; } -.legend-advisor { background: #fce7f3; border: 1px solid #f9a8d4; } -.legend-output { background: #d1fae5; border: 1px solid #10b981; } -.legend-llm { background: #fef3c7; border: 1px solid #f59e0b; } diff --git a/src/main/resources/static/favicon.svg b/src/main/resources/static/favicon.svg new file mode 100644 index 0000000..7c16b21 --- /dev/null +++ b/src/main/resources/static/favicon.svg @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/main/resources/static/index.html b/src/main/resources/static/index.html index 7ee6043..4059cee 100644 --- a/src/main/resources/static/index.html +++ b/src/main/resources/static/index.html @@ -1,24 +1,17 @@ - - - - AI 智能客服系统 - Support Bot - - - - -
- - + + + + + AI 智能客服系统 - Support Bot + + + + + + + +
+ diff --git a/src/main/resources/static/js/api.js b/src/main/resources/static/js/api.js deleted file mode 100644 index d2983e8..0000000 --- a/src/main/resources/static/js/api.js +++ /dev/null @@ -1,1158 +0,0 @@ -/** - * 统一 API 请求层 - * 封装所有后端接口调用,组件只需调用函数处理业务逻辑 - * P1: 所有请求自动附加 Bearer Token,401 时触发未登录事件 - */ -import { API_BASE, authHeaders } from './utils.js' - -// ==================== 通用请求 ==================== - -/** - * 处理 401 未授权响应 - * 仅清除 access_token,保留 refresh_token 供后续刷新使用。 - * auth:unauthorized 事件由 app.js 监听并尝试刷新 Token, - * 刷新成功则自动恢复,刷新失败时再清除 refresh_token。 - */ -function handleUnauthorized() { - localStorage.removeItem('sb_token') - localStorage.removeItem('sb_user') - window.dispatchEvent(new Event('auth:unauthorized')) -} - -/** - * GET 请求,返回 JSON - */ -async function getJSON(path) { - const res = await fetch(API_BASE + path, { headers: authHeaders() }) - if (res.status === 401) { handleUnauthorized(); throw new Error('401') } - if (res.status === 403) { - const body = await res.json().catch(() => ({})) - throw new Error(body.message || '403') - } - return res.json() -} - -/** - * POST JSON 请求,返回 JSON - */ -async function postJSON(path, body) { - const res = await fetch(API_BASE + path, { - method: 'POST', - headers: { 'Content-Type': 'application/json', ...authHeaders() }, - body: JSON.stringify(body) - }) - const json = await res.json().catch(() => ({ message: res.statusText || `HTTP ${res.status}` })) - if (res.status === 401) { handleUnauthorized(); throw new Error(json.message || '未登录或登录已过期') } - if (res.status === 403) throw new Error(json.message || '权限不足') - if (!res.ok) throw new Error(json.message || `HTTP ${res.status}`) - return json -} - -/** - * DELETE 请求 + JSON body,返回 JSON(用于批量删除等场景) - */ -async function deleteJSONWithBody(path, body) { - const res = await fetch(API_BASE + path, { - method: 'DELETE', - headers: { 'Content-Type': 'application/json', ...authHeaders() }, - body: JSON.stringify(body) - }) - if (res.status === 401) { handleUnauthorized(); throw new Error('401') } - return res.json() -} - -/** - * PUT 请求 + JSON body,返回 JSON(用于批量操作等场景) - */ -async function putJSONWithBody(path, body) { - const res = await fetch(API_BASE + path, { - method: 'PUT', - headers: { 'Content-Type': 'application/json', ...authHeaders() }, - body: JSON.stringify(body) - }) - if (res.status === 401) { handleUnauthorized(); throw new Error('401') } - return res.json() -} - -/** - * DELETE 请求,返回 JSON(无 body) - */ -async function deleteJSON(path) { - const res = await fetch(API_BASE + path, { method: 'DELETE', headers: authHeaders() }) - if (res.status === 401) { handleUnauthorized(); throw new Error('401') } - return res.json() -} - -/** - * PUT 请求,返回 JSON(参数走 query string) - */ -async function putJSON(path, params) { - let url = API_BASE + path - if (params) { - const qs = Object.entries(params) - .filter(([, v]) => v !== undefined && v !== null && v !== '') - .map(([k, v]) => `${k}=${encodeURIComponent(v)}`) - .join('&') - if (qs) url += (url.includes('?') ? '&' : '?') + qs - } - const res = await fetch(url, { method: 'PUT', headers: authHeaders() }) - if (res.status === 401) { handleUnauthorized(); throw new Error('401') } - return res.json() -} - -/** - * POST FormData 请求,返回 JSON - */ -async function postForm(path, formData) { - const res = await fetch(API_BASE + path, { - method: 'POST', - headers: authHeaders(), - body: formData - }) - if (res.status === 401) { handleUnauthorized(); throw new Error('401') } - return res.json() -} - -// ==================== AI 对话 ==================== - -/** - * 同步对话 - * @param {string} message 用户消息(原样发送,不做包装) - * @param {string} chatId 会话ID - * @param {string} [roleId] 客服角色ID,命中后由服务端套用该角色人设 - */ -export function chatSync(message, chatId, roleId, accountId) { - let url = API_BASE + `/ai/assistant_app/chat/sync?message=${encodeURIComponent(message)}&chatId=${encodeURIComponent(chatId)}` - if (roleId) url += `&roleId=${encodeURIComponent(roleId)}` - if (accountId) url += `&accountId=${encodeURIComponent(accountId)}` - return fetch(url).then(res => res.text()) -} - -/** - * RAG 同步对话 - * 知识库范围由服务端依据 roleId 强制限定,前端无需(也无法)跨域指定分类。 - * @param {string} message 用户消息 - * @param {string} chatId 会话ID - * @param {string} strategy 查询重写策略 - * @param {string} [roleId] 客服角色ID - */ -export function chatRagSync(message, chatId, strategy, roleId, accountId) { - let url = API_BASE + `/ai/assistant_app/chat/rag/sync?message=${encodeURIComponent(message)}&chatId=${encodeURIComponent(chatId)}&rewriteStrategy=${encodeURIComponent(strategy)}` - if (roleId) url += `&roleId=${encodeURIComponent(roleId)}` - if (accountId) url += `&accountId=${encodeURIComponent(accountId)}` - return fetch(url).then(res => res.text()) -} - -/** - * 获取普通 SSE 流式对话 URL - * @param {string} message - * @param {string} chatId - * @param {string} [roleId] - * @returns {string} - */ -export function chatSSEUrl(message, chatId, roleId, accountId) { - let url = API_BASE + `/ai/assistant_app/chat/sse?message=${encodeURIComponent(message)}&chatId=${encodeURIComponent(chatId)}` - if (roleId) url += `&roleId=${encodeURIComponent(roleId)}` - if (accountId) url += `&accountId=${encodeURIComponent(accountId)}` - return url -} - -/** - * 获取 RAG 流式对话 URL(知识库范围由服务端依据 roleId 强制限定) - * @param {string} message - * @param {string} chatId - * @param {string} strategy - * @param {string} [roleId] - * @returns {string} - */ -export function chatRagSSEUrl(message, chatId, strategy, roleId, accountId) { - let url = API_BASE + `/ai/assistant_app/chat/rag/sse?message=${encodeURIComponent(message)}&chatId=${encodeURIComponent(chatId)}&rewriteStrategy=${encodeURIComponent(strategy)}` - if (roleId) url += `&roleId=${encodeURIComponent(roleId)}` - if (accountId) url += `&accountId=${encodeURIComponent(accountId)}` - return url -} - -/** - * 获取本次 RAG 回答命中的知识库片段(引用来源) - */ -export function ragSources(message, chatId, strategy, roleId, accountId) { - let path = `/ai/assistant_app/rag/sources?message=${encodeURIComponent(message)}&chatId=${encodeURIComponent(chatId)}&rewriteStrategy=${encodeURIComponent(strategy)}` - if (roleId) path += `&roleId=${encodeURIComponent(roleId)}` - if (accountId) path += `&accountId=${encodeURIComponent(accountId)}` - return getJSON(path) -} - -export function getRoleList() { - return getJSON('/role/list') -} - -/** 管理用:列出全部角色(含已停用) */ -export function getAllRoles() { - return getJSON('/role/all') -} - -/** 新增角色 */ -export function createRole(role) { - return postJSON('/role', role) -} - -/** 编辑角色基本信息 */ -export function updateRole(roleId, role) { - return putJSONWithBody(`/role/${roleId}`, role) -} - -/** 删除角色(逻辑删除) */ -export function deleteRole(roleId) { - return deleteJSON(`/role/${roleId}`) -} - -export function updateRoleCategories(roleId, categoryIds) { - return putJSONWithBody(`/role/${roleId}/categories`, { categoryIds }) -} - -export function getAccountList() { - return getJSON('/account/list') -} - -export function getAllAccounts() { - return getJSON('/account/all') -} - -export function createAccount(account) { - return postJSON('/account', account) -} - -export function updateAccount(accountId, account) { - return putJSONWithBody(`/account/${accountId}`, account) -} - -export function deleteAccount(accountId) { - return deleteJSON(`/account/${accountId}`) -} - -// ==================== 文档管理 ==================== - -/** - * 文档列表(分页 + 过滤 + 关键词搜索 + 标签筛选) - */ -export function listDocuments(page = 1, size = 10, categoryId, status, keyword, tag) { - let path = `/document/list?page=${page}&size=${size}` - if (categoryId) path += `&categoryId=${categoryId}` - if (status) path += `&status=${status}` - if (keyword) path += `&keyword=${encodeURIComponent(keyword)}` - if (tag) path += `&tag=${encodeURIComponent(tag)}` - return getJSON(path) -} - -/** - * 文档详情 - */ -export function getDocumentDetail(id) { - return getJSON(`/document/${id}`) -} - -/** - * 文档分块列表 - */ -export function getDocumentChunks(id) { - return getJSON(`/document/${id}/chunks`) -} - -/** - * 下载文档原始文件 - * 使用 fetch 携带 Token,通过 Blob 触发浏览器下载 - */ -export async function downloadDocument(id) { - try { - const response = await fetch(`/document/download/${id}`, { - headers: authHeaders() - }) - - if (response.status === 401) { - handleUnauthorized() - throw new Error('未登录或登录已过期') - } - - if (!response.ok) { - const error = await response.json().catch(() => ({ message: '下载失败' })) - throw new Error(error.message || `HTTP ${response.status}`) - } - - // 从 Content-Disposition 获取文件名 - const disposition = response.headers.get('Content-Disposition') - let filename = 'download' - if (disposition) { - // 解析 filename*=UTF-8''xxx 或 filename="xxx" - const utf8Match = disposition.match(/filename\*=UTF-8''(.+?)(?:;|$)/i) - if (utf8Match) { - filename = decodeURIComponent(utf8Match[1]) - } else { - const quotedMatch = disposition.match(/filename="(.+?)"/i) - if (quotedMatch) { - filename = decodeURIComponent(quotedMatch[1]) - } - } - } - - // 创建 Blob 并触发下载 - const blob = await response.blob() - const url = window.URL.createObjectURL(blob) - const a = document.createElement('a') - a.href = url - a.download = filename - document.body.appendChild(a) - a.click() - document.body.removeChild(a) - window.URL.revokeObjectURL(url) - } catch (e) { - console.error('下载失败:', e) - alert('下载失败:' + e.message) - } -} - -/** - * 删除文档 - */ -export function deleteDocument(id) { - return deleteJSON(`/document/${id}`) -} - -/** - * 更新文档元信息 - */ -export function updateDocument(id, title, categoryId, tags) { - return putJSON(`/document/${id}`, { title, categoryId, tags }) -} - -/** - * 重新处理文档 - */ -export function reprocessDocument(id) { - return putJSON(`/document/${id}/reprocess`) -} - -/** - * 批量删除文档 - */ -export function batchDeleteDocuments(ids) { - return postJSON('/document/batch/delete', { ids }) -} - -/** - * 批量重新处理文档 - */ -export function batchReprocessDocuments(ids) { - return postJSON('/document/batch/reprocess', { ids }) -} - -/** - * P1-2.1: 切换文档启用/禁用状态 - */ -export function toggleDocument(id) { - return putJSON(`/document/${id}/toggle`) -} - -/** - * P1-2.1: 批量启用/禁用文档 - */ -export function batchToggleDocuments(ids, enabled) { - return postJSON('/document/batch/toggle', { ids, enabled }) -} - -/** - * P1-2.2: 批量移动文档分类 - */ -export function batchMoveDocuments(ids, categoryId) { - return postJSON('/document/batch/move', { ids, categoryId }) -} - -/** - * P1-2.3: 获取标签列表(聚合去重,含使用次数) - */ -export function getTagList() { - return getJSON('/tag/list') -} - -/** - * P1-2.5: 更新单个分块内容 - */ -export function updateChunk(docId, chunkIndex, content) { - return putJSONWithBody(`/document/${docId}/chunk/${chunkIndex}`, { content }) -} - -/** - * P1-2.5: 删除单个分块 - */ -export function deleteChunk(docId, chunkIndex) { - return deleteJSON(`/document/${docId}/chunk/${chunkIndex}`) -} - -/** - * 语义搜索 - */ -export function searchDocuments(query, topK, similarityThreshold, categoryId, categoryIds, searchMode) { - const body = { query, topK, similarityThreshold } - if (categoryIds && categoryIds.length) body.categoryIds = categoryIds - if (categoryId) body.categoryId = categoryId - if (searchMode) body.searchMode = searchMode - return postJSON('/document/search', body) -} - -// ==================== 统计 ==================== - -/** - * 知识库统计 - */ -export function getStats() { - return getJSON('/document/stats') -} - -// ==================== 上传 ==================== - -/** - * 上传文件(带进度回调) - * 使用 XMLHttpRequest 替代 fetch,支持监听上传进度 - * - * @param {string} path 请求路径 - * @param {FormData} formData 表单数据 - * @param {function(number): void} onProgress 进度回调,参数为 0-100 的百分比 - * @returns {Promise} - */ -function postFormWithProgress(path, formData, onProgress) { - return new Promise((resolve, reject) => { - const xhr = new XMLHttpRequest() - xhr.open('POST', API_BASE + path) - - // 附加认证头 - const token = localStorage.getItem('sb_token') - if (token) xhr.setRequestHeader('Authorization', 'Bearer ' + token) - - // 上传进度监听 - xhr.upload.addEventListener('progress', (e) => { - if (e.lengthComputable && onProgress) { - onProgress(Math.round((e.loaded / e.total) * 100)) - } - }) - - xhr.addEventListener('load', () => { - if (xhr.status === 401) { - handleUnauthorized() - reject(new Error('401')) - return - } - try { - resolve(JSON.parse(xhr.responseText)) - } catch (e) { - reject(new Error('响应解析失败')) - } - }) - - xhr.addEventListener('error', () => reject(new Error('网络错误'))) - xhr.addEventListener('abort', () => reject(new Error('上传已取消'))) - xhr.send(formData) - }) -} - -/** - * 上传普通文件(带进度) - */ -export function uploadFile(formData, onProgress) { - return postFormWithProgress('/upload/file', formData, onProgress) -} - -/** - * 上传普通文件(无进度,兼容旧调用) - */ -export function uploadFileSimple(formData) { - return postForm('/upload/file', formData) -} - -/** - * 上传文本内容 - */ -export function uploadString(content, title, categoryId, tags) { - let url = `/upload/string?title=${encodeURIComponent(title)}` - if (categoryId) url += `&categoryId=${categoryId}` - if (tags) url += `&tags=${encodeURIComponent(tags)}` - return fetch(API_BASE + url, { - method: 'POST', - headers: { 'Content-Type': 'text/plain', ...authHeaders() }, - body: content - }).then(res => res.json()) -} - -/** - * 上传 Markdown(带进度) - */ -export function uploadMarkdown(formData, onProgress) { - return postFormWithProgress('/upload/markdown', formData, onProgress) -} - -/** - * 上传 JSON(基本方式,带进度) - */ -export function uploadJsonBasic(formData, onProgress) { - return postFormWithProgress('/upload/json/basic', formData, onProgress) -} - -/** - * 上传 JSON(按字段提取,带进度) - */ -export function uploadJsonFields(formData, fields, onProgress) { - return postFormWithProgress(`/upload/json/fields?fields=${encodeURIComponent(fields)}`, formData, onProgress) -} - -/** - * 上传 JSON(按指针拆分,带进度) - */ -export function uploadJsonPointer(formData, pointer, onProgress) { - return postFormWithProgress(`/upload/json/pointer?pointer=${encodeURIComponent(pointer)}`, formData, onProgress) -} - -// ==================== 分类 ==================== - -/** - * 分类树 - */ -export function getCategoryTree() { - return getJSON('/category/tree') -} - -/** - * 分类列表 - */ -export function getCategoryList() { - return getJSON('/category/list') -} - -/** - * 创建分类 - */ -export function createCategory(data) { - return postJSON('/category', data) -} - -/** - * 更新分类 - */ -export function updateCategory(id, data) { - return putJSONWithBody(`/category/${id}`, data) -} - -/** - * 删除分类 - */ -export function deleteCategory(id) { - return deleteJSON(`/category/${id}`) -} - -// ==================== 会话管理 ==================== - -/** - * 会话列表(分页) - */ -export function listConversations(page = 1, size = 10, keyword, accountId, roleId) { - let path = `/conversation/list?page=${page}&size=${size}` - if (keyword) path += `&keyword=${encodeURIComponent(keyword)}` - if (accountId) path += `&accountId=${encodeURIComponent(accountId)}` - if (roleId) path += `&roleId=${encodeURIComponent(roleId)}` - return getJSON(path) -} - -/** - * 会话详情 - */ -export function getConversationDetail(conversationId) { - return getJSON(`/conversation/${conversationId}`) -} - -/** - * 会话消息列表 - */ -export function getConversationMessages(conversationId) { - return getJSON(`/conversation/${conversationId}/messages`) -} - -/** - * 删除会话 - */ -export function deleteConversation(conversationId) { - return deleteJSON(`/conversation/${conversationId}`) -} - -/** - * 导出会话记录 - */ -export function exportConversation(conversationId) { - return fetch(API_BASE + `/conversation/${conversationId}/export`, { headers: authHeaders() }) - .then(res => res.text()) -} - -/** - * 会话统计 - */ -export function getConversationStats() { - return getJSON('/conversation/stats') -} - -// ==================== Model config management ==================== - -export function listModelConfigs(page = 1, size = 10, appType) { - let path = `/model-config/list?page=${page}&size=${size}` - if (appType) path += `&appType=${encodeURIComponent(appType)}` - return getJSON(path) -} - -export function getModelConfigDetail(id) { - return getJSON(`/model-config/${id}`) -} - -export function getActiveModelConfig(appType) { - return getJSON(`/model-config/active/${encodeURIComponent(appType)}`) -} - -export function createModelConfig(data) { - return postJSON('/model-config', data) -} - -export function updateModelConfig(id, data) { - return putJSONWithBody(`/model-config/${id}`, data) -} - -export function activateModelConfig(id) { - return putJSON(`/model-config/${id}/activate`) -} - -export function deleteModelConfig(id) { - return deleteJSON(`/model-config/${id}`) -} - -// ==================== F1: 连接测试 ==================== - -/** - * 测试模型配置连接可用性 - */ -export function testModelConfig(id) { - return postJSON(`/model-config/${id}/test`, {}) -} - -// ==================== F2: 配置复制 ==================== - -/** - * 复制模型配置 - */ -export function duplicateModelConfig(id) { - return postJSON(`/model-config/${id}/duplicate`, {}) -} - -// ==================== F4: 健康状态 ==================== - -/** - * 获取所有配置的健康状态 - */ -export function getHealthStatus() { - return getJSON('/model-config/health') -} - -/** - * 手动触发单个配置的健康检查 - */ -export function manualHealthCheck(id) { - return postJSON(`/model-config/${id}/health-check`, {}) -} - -// ==================== F6: 优先级管理 ==================== - -/** - * 更新配置优先级 - */ -export function updateModelConfigPriority(id, priority) { - return putJSONWithBody(`/model-config/${id}/priority`, { priority }) -} - -/** - * 停用配置(从 Fallback 链中移除) - */ -export function deactivateModelConfig(id) { - return putJSON(`/model-config/${id}/deactivate`) -} - -// ==================== F7: 配置导入/导出 ==================== - -/** - * 导出所有模型配置(API Key 始终脱敏) - */ -export function exportModelConfigs() { - return getJSON('/model-config/export') -} - -/** - * 批量导入模型配置 - */ -export function importModelConfigs(configs, onConflict = 'skip') { - return postJSON('/model-config/import', { configs, onConflict }) -} - -/** - * 动态获取提供商的模型列表 - * @param {string} baseUrl - API 基础地址 - * @param {string} apiKey - API Key - * @param {string} provider - 提供商标识(可选) - */ -export function fetchModels(baseUrl, apiKey, provider = '') { - return postJSON('/model-config/fetch-models', { baseUrl, apiKey, provider }) -} - -/** - * Truncate a conversation from the given user turn onward. - */ -export function truncateConversation(conversationId, userTurn) { - return postJSON(`/conversation/${conversationId}/truncate`, { userTurn }) -} - -// ==================== P0-002: 用户反馈 ==================== - -/** - * 提交/修改消息反馈(upsert 语义) - * @param {Object} feedback - { messageId, conversationId, feedbackType, reasonCategory, reasonComment } - */ -export function submitFeedback(feedback) { - return postJSON('/feedback', feedback) -} - -/** - * 获取反馈统计 - */ -export function getFeedbackStats(startDate, endDate) { - let path = '/feedback/stats' - const params = [] - if (startDate) params.push(`startDate=${encodeURIComponent(startDate)}`) - if (endDate) params.push(`endDate=${encodeURIComponent(endDate)}`) - if (params.length) path += '?' + params.join('&') - return getJSON(path) -} - -/** - * 按会话查询反馈 - */ -export function getFeedbackByConversation(conversationId) { - return getJSON(`/feedback/by-conversation/${encodeURIComponent(conversationId)}`) -} - -/** - * 批量查询消息反馈状态(供回显) - */ -export function getFeedbackBatch(messageIds) { - return getJSON(`/feedback/batch?messageIds=${encodeURIComponent(messageIds.join(','))}`) -} - -// ==================== P0-004: 敏感词管理 ==================== - -/** - * 敏感词分页列表 - */ -export function listSensitiveWords(page = 1, size = 20, keyword, category) { - let path = `/sensitive-word/list?page=${page}&size=${size}` - if (keyword) path += `&keyword=${encodeURIComponent(keyword)}` - if (category) path += `&category=${encodeURIComponent(category)}` - return getJSON(path) -} - -/** - * 新增敏感词 - */ -export function createSensitiveWord(data) { - return postJSON('/sensitive-word', data) -} - -/** - * 修改敏感词 - */ -export function updateSensitiveWord(id, data) { - return putJSONWithBody(`/sensitive-word/${id}`, data) -} - -/** - * 删除敏感词 - */ -export function deleteSensitiveWord(id) { - return deleteJSON(`/sensitive-word/${id}`) -} - -/** - * 批量导入敏感词 - */ -export function batchImportSensitiveWords(data) { - return postJSON('/sensitive-word/batch-import', data) -} - -/** - * 查询审计日志 - */ -export function listAuditLogs(page = 1, size = 20, sessionId) { - let path = `/sensitive-word/audit-log?page=${page}&size=${size}` - if (sessionId) path += `&sessionId=${encodeURIComponent(sessionId)}` - return getJSON(path) -} - -// ==================== P0-003: FAQ 管理 ==================== - -/** - * FAQ 分页列表 - */ -export function listFaqs(page = 1, size = 20, keyword, categoryId, status) { - let path = `/faq/list?page=${page}&size=${size}` - if (keyword) path += `&keyword=${encodeURIComponent(keyword)}` - if (categoryId) path += `&categoryId=${categoryId}` - if (status) path += `&status=${encodeURIComponent(status)}` - return getJSON(path) -} - -/** - * 新增 FAQ - */ -export function createFaq(data) { - return postJSON('/faq', data) -} - -/** - * 修改 FAQ - */ -export function updateFaq(id, data) { - return putJSONWithBody(`/faq/${id}`, data) -} - -/** - * 删除 FAQ - */ -export function deleteFaq(id) { - return deleteJSON(`/faq/${id}`) -} - -/** - * 启用/禁用 FAQ - */ -export function toggleFaqStatus(id, status) { - return putJSON(`/faq/${id}/toggle`, { status }) -} - -/** - * 批量导入 FAQ - */ -export function batchImportFaqs(data) { - return postJSON('/faq/batch-import', data) -} - -/** - * 导出所有 FAQ - */ -export function exportFaqs() { - return getJSON('/faq/export') -} - -/** - * FAQ 匹配统计 - */ -export function getFaqStats() { - return getJSON('/faq/stats') -} - -// ==================== P1-001: 认证管理 ==================== - -/** - * 用户登录 - */ -export function login(username, password) { - return postJSON('/auth/login', { username, password }) -} - -/** - * 刷新 Token - */ -export function refreshToken(refreshToken) { - return postJSON('/auth/refresh', { refreshToken }) -} - -/** - * 获取当前登录用户信息 - */ -export function getCurrentUser() { - return getJSON('/auth/me') -} - -// ==================== P1-001: 系统用户管理 ==================== - -/** - * 系统用户分页列表 - */ -export function listSysUsers(page = 1, size = 20, keyword, enabled) { - let path = `/sys-user/list?page=${page}&size=${size}` - if (keyword) path += `&keyword=${encodeURIComponent(keyword)}` - if (enabled !== undefined && enabled !== null) path += `&enabled=${enabled}` - return getJSON(path) -} - -/** - * 获取用户详情 - */ -export function getSysUserDetail(id) { - return getJSON(`/sys-user/${id}`) -} - -/** - * 创建系统用户 - */ -export function createSysUser(data) { - return postJSON('/sys-user', data) -} - -/** - * 更新系统用户 - */ -export function updateSysUser(id, data) { - return putJSONWithBody(`/sys-user/${id}`, data) -} - -/** - * 启用/禁用系统用户 - */ -export function toggleSysUser(id, enabled) { - return putJSON(`/sys-user/${id}/toggle`, { enabled }) -} - -/** - * 分配角色 - */ -export function assignSysUserRoles(id, roleIds) { - return putJSONWithBody(`/sys-user/${id}/roles`, { roleIds }) -} - -/** - * 修改密码 - */ -export function changeSysUserPassword(id, newPassword) { - return putJSONWithBody(`/sys-user/${id}/password`, { newPassword }) -} - -/** - * 获取所有角色(分配角色下拉用) - */ -export function getAllSysRoles() { - return getJSON('/sys-user/roles') -} - -/** - * 获取系统角色列表 - */ -export function listSysRoles() { - return getJSON('/sys-role/list') -} - -/** - * 创建系统角色 - */ -export function createSysRole(data) { - return postJSON('/sys-role', data) -} - -/** - * 更新系统角色 - */ -export function updateSysRole(id, data) { - return putJSONWithBody(`/sys-role/${id}`, data) -} - -// ==================== P1-002: 运营看板 ==================== - -/** - * 看板概览(今日实时指标) - */ -export function getDashboardOverview() { - return getJSON('/dashboard/overview') -} - -/** - * 趋势数据(7/30天) - */ -export function getDashboardTrend(days = 7) { - return getJSON(`/dashboard/trend?days=${days}`) -} - -/** - * 知识库分析(命中TOP-10 + 未命中问题) - */ -export function getDashboardKnowledge() { - return getJSON('/dashboard/knowledge-analysis') -} - -/** - * 自定义时间范围数据 - */ -export function getDashboardCustom(startDate, endDate) { - return getJSON(`/dashboard/custom?startDate=${encodeURIComponent(startDate)}&endDate=${encodeURIComponent(endDate)}`) -} - -// ==================== P1-003: API Key 管理 ==================== - -/** - * API Key 列表 - */ -export function listApiKeys(page = 1, size = 20) { - return getJSON(`/api-key/list?page=${page}&size=${size}`) -} - -/** - * 创建 API Key - */ -export function createApiKey(data) { - return postJSON('/api-key', data) -} - -/** - * 吊销 API Key - */ -export function revokeApiKey(id) { - return putJSON(`/api-key/${id}/revoke`) -} - -/** - * 启用 API Key - */ -export function enableApiKey(id) { - return putJSON(`/api-key/${id}/enable`) -} - -/** - * 删除 API Key - */ -export function deleteApiKey(id) { - return deleteJSON(`/api-key/${id}`) -} - -// ==================== P1-003: Webhook 管理 ==================== - -/** - * Webhook 列表 - */ -export function listWebhooks(page = 1, size = 20) { - return getJSON(`/webhook/list?page=${page}&size=${size}`) -} - -/** - * 创建 Webhook - */ -export function createWebhook(data) { - return postJSON('/webhook', data) -} - -/** - * 更新 Webhook - */ -export function updateWebhook(id, data) { - return putJSONWithBody(`/webhook/${id}`, data) -} - -/** - * 删除 Webhook - */ -export function deleteWebhook(id) { - return deleteJSON(`/webhook/${id}`) -} - -/** - * 测试 Webhook 推送 - */ -export function testWebhook(id) { - return postJSON(`/webhook/${id}/test`, {}) -} - -// ==================== MCP 服务管理 ==================== - -/** - * MCP Server 配置列表(分页) - */ -export function listMcpServers(page = 1, size = 10) { - return getJSON(`/mcp-server/list?page=${page}&size=${size}`) -} - -/** - * MCP Server 配置详情 - */ -export function getMcpServerDetail(id) { - return getJSON(`/mcp-server/${id}`) -} - -/** - * 新建 MCP Server 配置 - */ -export function createMcpServer(data) { - return postJSON('/mcp-server', data) -} - -/** - * 更新 MCP Server 配置 - */ -export function updateMcpServer(id, data) { - return putJSONWithBody(`/mcp-server/${id}`, data) -} - -/** - * 删除 MCP Server 配置 - */ -export function deleteMcpServer(id) { - return deleteJSON(`/mcp-server/${id}`) -} - -/** - * 启用/禁用 MCP Server 配置 - */ -export function toggleMcpServer(id, active) { - return putJSONWithBody(`/mcp-server/${id}/toggle`, { active }) -} - -/** - * 测试 MCP Server 连接 - */ -export function testMcpServer(id) { - return postJSON(`/mcp-server/${id}/test`, {}) -} - -/** - * 刷新所有 MCP 客户端连接 - */ -export function refreshMcpServers() { - return postJSON('/mcp-server/refresh', {}) -} - -// ==================== 系统配置管理 ==================== - -/** 获取单个系统配置(SDK 端 + 管理后台共用) */ -export function getSystemConfig(key) { - return getJSON(`/system-config/${key}`) -} - -/** 获取所有系统配置列表(管理后台) */ -export function listSystemConfigs() { - return getJSON('/system-config/list') -} - -/** 更新系统配置(管理后台,upsert 语义) */ -export function updateSystemConfig(key, configValue, description) { - return putJSONWithBody(`/system-config/${key}`, { configValue, description }) -} - -/** 删除系统配置(管理后台,逻辑删除) */ -export function deleteSystemConfig(key) { - return deleteJSON(`/system-config/${key}`) -} - -// ==================== API Key 角色绑定 ==================== - -/** - * 更新 API Key 绑定的客服角色 - * @param {string} id API Key ID - * @param {number[]} roleIds 角色 ID 列表 - */ -export function updateApiKeyRoles(id, roleIds) { - return putJSONWithBody(`/api-key/${id}/roles`, { roleIds }) -} diff --git a/src/main/resources/static/js/app.js b/src/main/resources/static/js/app.js deleted file mode 100644 index 68ae65e..0000000 --- a/src/main/resources/static/js/app.js +++ /dev/null @@ -1,355 +0,0 @@ -/** - * Vue 应用入口 - * 侧边栏导航 + 内容区布局,数据驱动菜单渲染 - * P1: 登录守卫 + 用户信息展示 + 新页面注册 - */ -import { createApp } from 'vue' -import { store, MENU_ITEMS } from './store.js' -import { isLoggedIn, clearTokens, getUserInfo, toast, getRefreshToken } from './utils.js' -import { getCurrentUser, refreshToken } from './api.js' - -// 导入组件 -import ChatPanel from '../components/ChatPanel.js' -import DocStats from '../components/DocStats.js' -import DocSearch from '../components/DocSearch.js' -import CategoryManager from '../components/CategoryManager.js' -import RoleManager from '../components/RoleManager.js' -import DocList from '../components/DocList.js' -import DocUpload from '../components/DocUpload.js' -import DocDetail from '../components/DocDetail.js' -import ConversationManager from '../components/ConversationManager.js' -import ModelConfigManager from '../components/ModelConfigManager.js' -import SensitiveWordManager from '../components/SensitiveWordManager.js' -import FaqManager from '../components/FaqManager.js' -import AuditLogManager from '../components/AuditLogManager.js' -import LoginPage from '../components/LoginPage.js' -import UserManager from '../components/UserManager.js' -import DashboardPanel from '../components/DashboardPanel.js' -import ApiKeyManager from '../components/ApiKeyManager.js' -import WebhookManager from '../components/WebhookManager.js' -import McpServerManager from '../components/McpServerManager.js' -import SystemConfigManager from '../components/SystemConfigManager.js' -import PipelineFlow from '../components/PipelineFlow.js' - -const app = createApp({ - setup() { - const menuItems = MENU_ITEMS - - /** 判断父菜单是否高亮(任一子页面激活时高亮父菜单) */ - function isMenuActive(parentItem) { - if (!parentItem.children) return store.activePage === parentItem.id - return parentItem.children.some(c => c.id === store.activePage) - } - - /** 判断菜单项是否对当前用户可见(roles 限制) */ - function isMenuVisible(item) { - if (!item.roles || !item.roles.length) return true - return item.roles.some(r => store.hasRole(r)) - } - - /** 登录成功回调 */ - function onLoginSuccess(user) { - store.currentUser = user || getUserInfo() - store.isLoggedIn = true - } - - /** 退出登录 */ - function handleLogout() { - clearTokens() - store.isLoggedIn = false - store.currentUser = {} - store.activePage = 'chat' - toast('已退出登录', 'info') - } - - /** 尝试刷新 Token */ - async function tryRefreshToken() { - const rt = getRefreshToken() - if (!rt) return false - try { - const res = await refreshToken(rt) - if (res.success && res.data) { - const { setToken: st, setRefreshToken: srt } = await import('./utils.js') - st(res.data.accessToken) - srt(res.data.refreshToken) - return true - } - } catch (e) { /* 刷新失败 */ } - return false - } - - // 监听 401 未授权事件 - window.addEventListener('auth:unauthorized', async () => { - const refreshed = await tryRefreshToken() - if (!refreshed) { - store.isLoggedIn = false - store.currentUser = {} - toast('登录已过期,请重新登录', 'error') - } - }) - - // 启动时验证登录态 - if (isLoggedIn()) { - getCurrentUser().then(res => { - if (res.success && res.data) { - store.currentUser = res.data - store.isLoggedIn = true - } else { - // 业务失败(如用户被禁用),尝试刷新 - tryRefreshToken().then(ok => { - if (!ok) { - store.isLoggedIn = false - clearTokens() - } else { - // 刷新成功后重新获取用户信息 - getCurrentUser().then(r => { - if (r.success && r.data) { - store.currentUser = r.data - store.isLoggedIn = true - } else { - store.isLoggedIn = false - clearTokens() - } - }).catch(() => { store.isLoggedIn = false; clearTokens() }) - } - }) - } - }).catch(() => { - // 401 等错误,尝试刷新(不要先清除 token!) - tryRefreshToken().then(ok => { - if (!ok) { - store.isLoggedIn = false - clearTokens() - } else { - // 刷新成功后重新获取用户信息 - getCurrentUser().then(r => { - if (r.success && r.data) { - store.currentUser = r.data - store.isLoggedIn = true - } else { - store.isLoggedIn = false - clearTokens() - } - }).catch(() => { store.isLoggedIn = false; clearTokens() }) - } - }) - }) - } - - return { menuItems, isMenuActive, isMenuVisible, store, onLoginSuccess, handleLogout } - }, - template: ` - - - - - - - -
- ` -}) - -// 注册组件 -app.component('chat-panel', ChatPanel) -app.component('doc-stats', DocStats) -app.component('doc-search', DocSearch) -app.component('category-manager', CategoryManager) -app.component('role-manager', RoleManager) -app.component('doc-list', DocList) -app.component('doc-upload', DocUpload) -app.component('doc-detail', DocDetail) -app.component('conversation-manager', ConversationManager) -app.component('model-config-manager', ModelConfigManager) -app.component('sensitive-word-manager', SensitiveWordManager) -app.component('faq-manager', FaqManager) -app.component('audit-log-manager', AuditLogManager) -app.component('login-page', LoginPage) -app.component('user-manager', UserManager) -app.component('dashboard-panel', DashboardPanel) -app.component('api-key-manager', ApiKeyManager) -app.component('webhook-manager', WebhookManager) -app.component('mcp-server-manager', McpServerManager) -app.component('system-config-manager', SystemConfigManager) -app.component('pipeline-flow', PipelineFlow) - -app.mount('#app') diff --git a/src/main/resources/static/js/store.js b/src/main/resources/static/js/store.js deleted file mode 100644 index dc290fa..0000000 --- a/src/main/resources/static/js/store.js +++ /dev/null @@ -1,257 +0,0 @@ -/** - * 共享响应式状态 - * 跨组件共享的分类数据、统计数据、导航状态、弹窗状态 - */ -import { reactive } from 'vue' -import * as api from './api.js' -import { getUserInfo, isLoggedIn } from './utils.js' - -// ==================== 菜单配置 ==================== -// 数据驱动菜单渲染,未来增删菜单只改此数组 -export const MENU_ITEMS = [ - { - id: 'chat', - label: '智能客服对话', - icon: '💬' - }, - { - id: 'knowledge', - label: '知识库', - icon: '📚', - children: [ - { id: 'stats', label: '统计概览', icon: '📊' }, - { id: 'doc-manage', label: '文档管理', icon: '📄' }, - { id: 'category', label: '分类管理', icon: '🏷️' }, - { id: 'search-test', label: '搜索测试', icon: '🔍' }, - { id: 'faq', label: 'FAQ 管理', icon: '❓' } - ] - }, - { - id: 'conversation', - label: '会话管理', - icon: '💬' - }, - { - id: 'dashboard', - label: '运营看板', - icon: '📈' - }, - { - id: 'settings', - label: '系统设置', - icon: '⚙️', - children: [ - { id: 'role', label: '客服角色管理', icon: '🎭' }, - { id: 'model-config', label: '模型配置', icon: '🤖' }, - { id: 'sensitive', label: '敏感词管理', icon: '🛡️' }, - { id: 'audit-log', label: '审计日志', icon: '📋' }, - { id: 'user-manage', label: '用户管理', icon: '👥', roles: ['admin'] }, - { id: 'api-key', label: 'API Key 管理', icon: '🔑', roles: ['admin'] }, - { id: 'webhook', label: 'Webhook 管理', icon: '🔔', roles: ['admin'] }, - { id: 'mcp-server', label: 'MCP 服务管理', icon: '🔌', roles: ['admin'] }, - { id: 'pipeline-flow',label: 'AI 执行链', icon: '🔀' }, - { id: 'system-config',label: '系统配置', icon: '🔧', roles: ['admin'] } - ] - } -] - -export const store = reactive({ - // ==================== 当前登录用户 ==================== - currentUser: getUserInfo(), - isLoggedIn: isLoggedIn(), - - /** - * 判断当前用户是否包含指定角色 - */ - hasRole(role) { - return (this.currentUser.roles || []).includes(role) - }, - - /** - * 判断当前用户是否为管理员 - */ - get isAdmin() { - return this.hasRole('admin') - }, - - // ==================== 分类数据 ==================== - categories: [], - categoryMap: {}, - - /** - * 加载分类列表,同步更新 categoryMap - */ - async loadCategories() { - try { - const json = await api.getCategoryList() - if (json.success) { - this.categories = json.data || [] - this.categoryMap = {} - this.categories.forEach(c => { this.categoryMap[c.id] = c.name }) - } - } catch (e) { - console.error('加载分类失败', e) - } - }, - - /** - * 获取分类名称 - */ - getCategoryName(categoryId) { - return this.categoryMap[categoryId] || (categoryId && categoryId !== '0' ? '未知' : '未分类') - }, - - // ==================== 统计数据 ==================== - stats: null, - - /** - * 加载统计数据 - */ - async loadStats() { - try { - const json = await api.getStats() - if (json.success) { - this.stats = json.data - } - } catch (e) { - console.error('加载统计失败', e) - } - }, - - // ==================== 标签数据 ==================== - tags: [], - - /** - * P1-2.3: 加载标签列表(聚合去重,含使用次数) - */ - async loadTags() { - try { - const json = await api.getTagList() - if (json.success) { - this.tags = json.data || [] - } - } catch (e) { - console.error('加载标签列表失败', e) - } - }, - - // ==================== 文档详情弹窗 ==================== - detailModal: { - visible: false, - doc: null, - chunks: [], - chunksError: '' - }, - - /** - * 打开文档详情弹窗 - */ - async openDetail(docId) { - try { - const [detailRes, chunksRes] = await Promise.all([ - api.getDocumentDetail(docId), - api.getDocumentChunks(docId) - ]) - if (!detailRes.success) return - this.detailModal.doc = detailRes.data - this.detailModal.chunks = chunksRes.success ? (chunksRes.data || []) : [] - this.detailModal.chunksError = chunksRes.success ? '' : (chunksRes.message || '分块详情加载失败') - this.detailModal.visible = true - } catch (e) { - console.error('加载文档详情失败', e) - } - }, - - /** - * 关闭文档详情弹窗 - */ - closeDetail() { - this.detailModal.visible = false - this.detailModal.doc = null - this.detailModal.chunks = [] - this.detailModal.chunksError = '' - }, - - // ==================== 导航状态 ==================== - activePage: 'chat', - expandedMenus: ['knowledge'], - sidebarCollapsed: false, - mobileMenuOpen: false, - - /** - * 页面跳转:切换页面 + 自动展开父菜单 + 关闭移动端菜单 + 触发数据加载 - */ - navigateTo(pageId) { - this.activePage = pageId - // 自动展开所属父菜单 - for (const item of MENU_ITEMS) { - if (item.children && item.children.some(c => c.id === pageId)) { - if (!this.expandedMenus.includes(item.id)) { - this.expandedMenus.push(item.id) - } - } - } - // 关闭移动端菜单 - this.mobileMenuOpen = false - // 页面数据加载钩子 - this._onPageEnter(pageId) - }, - - /** - * 展开/折叠父菜单;折叠状态下先展开侧边栏 - */ - toggleMenu(menuId) { - if (this.sidebarCollapsed) { - this.sidebarCollapsed = false - if (!this.expandedMenus.includes(menuId)) { - this.expandedMenus.push(menuId) - } - return - } - const idx = this.expandedMenus.indexOf(menuId) - if (idx >= 0) { - this.expandedMenus.splice(idx, 1) - } else { - this.expandedMenus.push(menuId) - } - }, - - /** - * 侧边栏折叠/展开切换 - */ - toggleSidebar() { - this.sidebarCollapsed = !this.sidebarCollapsed - }, - - /** - * 页面进入时的数据加载钩子 - */ - _onPageEnter(pageId) { - switch (pageId) { - case 'stats': - case 'doc-manage': - case 'search-test': - this.loadCategories() - this.loadStats() - this.loadTags() - break - case 'category': - case 'faq': - this.loadCategories() - break - case 'role': - this.loadCategories() - break - case 'dashboard': - case 'user-manage': - case 'api-key': - break - case 'webhook': - case 'mcp-server': - case 'system-config': - case 'pipeline-flow': - // 新页面无额外数据预加载 - break - } - } -}) diff --git a/src/main/resources/static/sdk/chatbot-sdk.js b/src/main/resources/static/sdk/chatbot-sdk.js index 1545cb1..dbbae05 100644 --- a/src/main/resources/static/sdk/chatbot-sdk.js +++ b/src/main/resources/static/sdk/chatbot-sdk.js @@ -5060,6 +5060,12 @@ var ChatbotSDK = (function () { function destroy() { if (!isInitialized) return; + // 中断正在进行的流式请求,释放网络资源,避免浏览器窗口切换异常 + if (abortController) { + abortController.abort(); + abortController = null; + } + isSending = false; if (teaserTimer) { clearTimeout(teaserTimer); teaserTimer = null;