Browse Source

重构后台前端项目,整体迁移到TDesign Starter for Vue架构下,并已全部测试通过

TDesign-Vue-Next-1.20.6
wanghanlin 4 weeks ago
parent
commit
ba3393d897
  1. 4
      .gitignore
  2. 3
      frontend/.gitignore
  3. 10
      frontend/auto-imports.d.ts
  4. 47
      frontend/components.d.ts
  5. 27
      frontend/cp-to-static.mjs
  6. 13
      frontend/index.html
  7. 3823
      frontend/package-lock.json
  8. 33
      frontend/package.json
  9. 23
      frontend/public/favicon.svg
  10. 48
      frontend/src/App.vue
  11. 8
      frontend/src/api/account.ts
  12. 13
      frontend/src/api/api-key.ts
  13. 17
      frontend/src/api/auth.ts
  14. 8
      frontend/src/api/category.ts
  15. 53
      frontend/src/api/chat.ts
  16. 21
      frontend/src/api/conversation.ts
  17. 9
      frontend/src/api/dashboard.ts
  18. 134
      frontend/src/api/document.ts
  19. 17
      frontend/src/api/faq.ts
  20. 14
      frontend/src/api/feedback.ts
  21. 11
      frontend/src/api/mcp-server.ts
  22. 23
      frontend/src/api/model-config.ts
  23. 41
      frontend/src/api/request.ts
  24. 11
      frontend/src/api/role.ts
  25. 18
      frontend/src/api/sensitive-word.ts
  26. 9
      frontend/src/api/system-config.ts
  27. 49
      frontend/src/api/upload.ts
  28. 19
      frontend/src/api/user.ts
  29. 8
      frontend/src/api/webhook.ts
  30. 37
      frontend/src/components/FormDialog.vue
  31. 47
      frontend/src/components/MessageSources.vue
  32. 38
      frontend/src/composables/useConfirm.ts
  33. 70
      frontend/src/composables/useCrudForm.ts
  34. 33
      frontend/src/composables/useDebounce.ts
  35. 36
      frontend/src/composables/useLoading.ts
  36. 51
      frontend/src/composables/usePagination.ts
  37. 68
      frontend/src/layouts/MainLayout.vue
  38. 104
      frontend/src/layouts/Sidebar.vue
  39. 52
      frontend/src/layouts/Topbar.vue
  40. 17
      frontend/src/main.ts
  41. 76
      frontend/src/router/index.ts
  42. 7
      frontend/src/shims-vue.d.ts
  43. 59
      frontend/src/stores/auth.ts
  44. 32
      frontend/src/stores/category.ts
  45. 40
      frontend/src/stores/dialog.ts
  46. 27
      frontend/src/stores/document.ts
  47. 44
      frontend/src/stores/navigation.ts
  48. 49
      frontend/src/styles/shared.css
  49. 23
      frontend/src/types/api.ts
  50. 234
      frontend/src/types/models.ts
  51. 3
      frontend/src/types/page.ts
  52. 8
      frontend/src/types/sse.ts
  53. 24
      frontend/src/utils/format.ts
  54. 22
      frontend/src/utils/markdown.ts
  55. 196
      frontend/src/utils/sse.ts
  56. 8
      frontend/src/utils/toast.ts
  57. 46
      frontend/src/utils/token.ts
  58. 58
      frontend/src/views/AccountManager.vue
  59. 108
      frontend/src/views/ApiKeyManager.vue
  60. 39
      frontend/src/views/AuditLogManager.vue
  61. 124
      frontend/src/views/CategoryManager.vue
  62. 684
      frontend/src/views/ChatPanel.vue
  63. 83
      frontend/src/views/ConversationManager.vue
  64. 144
      frontend/src/views/DashboardPanel.vue
  65. 130
      frontend/src/views/DocDetail.vue
  66. 264
      frontend/src/views/DocList.vue
  67. 33
      frontend/src/views/DocSearch.vue
  68. 19
      frontend/src/views/DocStats.vue
  69. 147
      frontend/src/views/DocUpload.vue
  70. 157
      frontend/src/views/FaqManager.vue
  71. 78
      frontend/src/views/LoginPage.vue
  72. 117
      frontend/src/views/McpServerManager.vue
  73. 719
      frontend/src/views/ModelConfigManager.vue
  74. 197
      frontend/src/views/PipelineFlow.vue
  75. 89
      frontend/src/views/RoleManager.vue
  76. 104
      frontend/src/views/SensitiveWordManager.vue
  77. 94
      frontend/src/views/SystemConfigManager.vue
  78. 117
      frontend/src/views/UserManager.vue
  79. 80
      frontend/src/views/WebhookManager.vue
  80. 259
      frontend/test_automation.py
  81. 192
      frontend/test_interact.py
  82. 61
      frontend/test_targeted.py
  83. 26
      frontend/tsconfig.json
  84. 78
      frontend/vite.config.ts
  85. 52
      pom.xml
  86. 62
      src/main/java/com/wok/supportbot/app/AssistantApp.java
  87. 64
      src/main/java/com/wok/supportbot/security/SdkAuthFilter.java
  88. 4
      src/main/java/com/wok/supportbot/security/SdkJwtTokenProvider.java
  89. 18
      src/main/java/com/wok/supportbot/security/SecurityConfig.java
  90. 8
      src/main/java/com/wok/supportbot/service/DocumentService.java
  91. 155
      src/main/resources/static/components/AccountManager.js
  92. 365
      src/main/resources/static/components/ApiKeyManager.js
  93. 103
      src/main/resources/static/components/AuditLogManager.js
  94. 322
      src/main/resources/static/components/CategoryManager.js
  95. 529
      src/main/resources/static/components/ChatPanel.js
  96. 255
      src/main/resources/static/components/ConversationManager.js
  97. 340
      src/main/resources/static/components/DashboardPanel.js
  98. 186
      src/main/resources/static/components/DocDetail.js
  99. 431
      src/main/resources/static/components/DocList.js
  100. 105
      src/main/resources/static/components/DocSearch.js

4
.gitignore

@ -37,4 +37,8 @@ build/
/client/node_modules/ /client/node_modules/
/client/node/ /client/node/
/client/dist/ /client/dist/
/frontend/node_modules/
/frontend/node/
/frontend/dist/
/.claude/ /.claude/
/src/main/resources/static/assets/

3
frontend/.gitignore

@ -0,0 +1,3 @@
node_modules/
dist/
node/

10
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 {
}

47
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']
}
}

27
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);
}

13
frontend/index.html

@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>AI 智能客服系统 - Support Bot</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

3823
frontend/package-lock.json
File diff suppressed because it is too large
View File

33
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"
}
}

23
frontend/public/favicon.svg

@ -0,0 +1,23 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<defs>
<linearGradient id="bg" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#4F46E5"/>
<stop offset="100%" style="stop-color:#7C3AED"/>
</linearGradient>
<linearGradient id="spark" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#FACC15"/>
<stop offset="100%" style="stop-color:#F97316"/>
</linearGradient>
</defs>
<!-- 圆角矩形背景 -->
<rect x="2" y="2" width="28" height="28" rx="6" ry="6" fill="url(#bg)"/>
<!-- 聊天气泡 -->
<path d="M8 10 C8 8.343 9.343 7 11 7H21C22.657 7 24 8.343 24 10V17C24 18.657 22.657 20 21 20H16L12 24V20H11C9.343 20 8 18.657 8 17V10Z" fill="white" opacity="0.95"/>
<!-- AI 火花/星星 -->
<path d="M15.5 11.5L16 10L16.5 11.5L18 12L16.5 12.5L16 14L15.5 12.5L14 12L15.5 11.5Z" fill="url(#spark)"/>
<!-- 波形线 -->
<path d="M12 15.5C12.5 14.5 13.5 14.5 14 15.5" stroke="#7C3AED" stroke-width="0.8" fill="none" stroke-linecap="round"/>
<path d="M14 15.5C14.5 13.5 16 13.5 16.5 15.5" stroke="#7C3AED" stroke-width="0.8" fill="none" stroke-linecap="round"/>
<path d="M16.5 15.5C17 14 18.5 14 19 15.5" stroke="#7C3AED" stroke-width="0.8" fill="none" stroke-linecap="round"/>
<path d="M19 15.5C19.5 14.5 20.5 14.5 21 15.5" stroke="#7C3AED" stroke-width="0.8" fill="none" stroke-linecap="round"/>
</svg>

48
frontend/src/App.vue

@ -0,0 +1,48 @@
<template>
<!-- 未登录 登录页 -->
<LoginPage v-if="!auth.loggedIn" @login-success="onLoginSuccess" />
<!-- 已登录 管理后台 -->
<MainLayout v-else />
</template>
<script setup lang="ts">
import { onMounted } from 'vue'
import { useAuthStore } from '@/stores/auth'
import LoginPage from '@/views/LoginPage.vue'
import MainLayout from '@/layouts/MainLayout.vue'
import { isLoggedIn, clearTokens } from '@/utils/token'
import { toast } from '@/utils/toast'
const auth = useAuthStore()
function onLoginSuccess(user: any) {
auth.onLoginSuccess(user)
}
onMounted(async () => {
//
if (isLoggedIn()) {
const ok = await auth.fetchUser()
if (!ok) {
// Token
const refreshed = await auth.tryRefreshToken()
if (refreshed) {
await auth.fetchUser().catch(() => {})
} else {
auth.logout()
}
}
}
// 401
window.addEventListener('auth:unauthorized', async () => {
const refreshed = await auth.tryRefreshToken()
if (refreshed) {
await auth.fetchUser().catch(() => {})
} else {
auth.logout()
toast('登录已过期,请重新登录', 'error')
}
})
})
</script>

8
frontend/src/api/account.ts

@ -0,0 +1,8 @@
import request from './request'
import type { ApiResponse } from '@/types/api'
export function getAccountList(): Promise<ApiResponse> { return request.get('/account/list').then(r => r.data) }
export function getAllAccounts(): Promise<ApiResponse> { return request.get('/account/all').then(r => r.data) }
export function createAccount(account: any): Promise<ApiResponse> { return request.post('/account', account).then(r => r.data) }
export function updateAccount(accountId: string, account: any): Promise<ApiResponse> { return request.put(`/account/${accountId}`, account).then(r => r.data) }
export function deleteAccount(accountId: string): Promise<ApiResponse> { return request.delete(`/account/${accountId}`).then(r => r.data) }

13
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<ApiResponse> {
return request.get(`/api-key/list?page=${page}&size=${size}`).then(r => r.data)
}
export function createApiKey(data: any): Promise<ApiResponse> { return request.post('/api-key', data).then(r => r.data) }
export function revokeApiKey(id: string): Promise<ApiResponse> { return request.put(`/api-key/${id}/revoke`).then(r => r.data) }
export function enableApiKey(id: string): Promise<ApiResponse> { return request.put(`/api-key/${id}/enable`).then(r => r.data) }
export function deleteApiKey(id: string): Promise<ApiResponse> { return request.delete(`/api-key/${id}`).then(r => r.data) }
export function updateApiKeyRoles(id: string, roleIds: string[]): Promise<ApiResponse> {
return request.put(`/api-key/${id}/roles`, { roleIds }).then(r => r.data)
}

17
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<ApiResponse<TokenResponse>> {
return request.post('/auth/login', { username, password }).then(r => r.data)
}
/** 刷新 Token */
export function refreshToken(refreshToken: string): Promise<ApiResponse<TokenResponse>> {
return request.post('/auth/refresh', { refreshToken }).then(r => r.data)
}
/** 获取当前登录用户信息 */
export function getCurrentUser(): Promise<ApiResponse<any>> {
return request.get('/auth/me').then(r => r.data)
}

8
frontend/src/api/category.ts

@ -0,0 +1,8 @@
import request from './request'
import type { ApiResponse } from '@/types/api'
export function getCategoryTree(): Promise<ApiResponse> { return request.get('/category/tree').then(r => r.data) }
export function getCategoryList(): Promise<ApiResponse> { return request.get('/category/list').then(r => r.data) }
export function createCategory(data: any): Promise<ApiResponse> { return request.post('/category', data).then(r => r.data) }
export function updateCategory(id: string, data: any): Promise<ApiResponse> { return request.put(`/category/${id}`, data).then(r => r.data) }
export function deleteCategory(id: string): Promise<ApiResponse> { return request.delete(`/category/${id}`).then(r => r.data) }

53
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<string, string> {
const token = getToken()
const headers: Record<string, string> = {}
if (token) headers['Authorization'] = `Bearer ${token}`
return headers
}
/** 同步对话 */
export function chatSync(message: string, chatId: string, roleId?: string, accountId?: string): Promise<string> {
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<string> {
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<ApiResponse> {
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)
}

21
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<ApiResponse> {
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<ApiResponse> { return request.get(`/conversation/${conversationId}`).then(r => r.data) }
export function getConversationMessages(conversationId: string): Promise<ApiResponse> { return request.get(`/conversation/${conversationId}/messages`).then(r => r.data) }
export function deleteConversation(conversationId: string): Promise<ApiResponse> { return request.delete(`/conversation/${conversationId}`).then(r => r.data) }
export function exportConversation(conversationId: string): Promise<string> {
const token = localStorage.getItem('sb_token')
return fetch(`/conversation/${conversationId}/export`, { headers: token ? { Authorization: `Bearer ${token}` } : {} }).then(res => res.text())
}
export function getConversationStats(): Promise<ApiResponse> { return request.get('/conversation/stats').then(r => r.data) }
export function truncateConversation(conversationId: string, userTurn: number): Promise<ApiResponse> {
return request.post(`/conversation/${conversationId}/truncate`, { userTurn }).then(r => r.data)
}

9
frontend/src/api/dashboard.ts

@ -0,0 +1,9 @@
import request from './request'
import type { ApiResponse } from '@/types/api'
export function getDashboardOverview(): Promise<ApiResponse> { return request.get('/dashboard/overview').then(r => r.data) }
export function getDashboardTrend(days = 7): Promise<ApiResponse> { return request.get(`/dashboard/trend?days=${days}`).then(r => r.data) }
export function getDashboardKnowledge(): Promise<ApiResponse> { return request.get('/dashboard/knowledge-analysis').then(r => r.data) }
export function getDashboardCustom(startDate: string, endDate: string): Promise<ApiResponse> {
return request.get(`/dashboard/custom?startDate=${encodeURIComponent(startDate)}&endDate=${encodeURIComponent(endDate)}`).then(r => r.data)
}

134
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<string, string> {
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<ApiResponse> {
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<ApiResponse> {
return request.get(`/document/${id}`).then(r => r.data)
}
/** 文档分块列表 */
export function getDocumentChunks(id: string): Promise<ApiResponse> {
return request.get(`/document/${id}/chunks`).then(r => r.data)
}
/** 下载文档原始文件 */
export async function downloadDocument(id: string): Promise<void> {
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<ApiResponse> {
return request.delete(`/document/${id}`).then(r => r.data)
}
/** 更新文档元信息 */
export function updateDocument(id: string, title: string, categoryId: string, tags?: string[]): Promise<ApiResponse> {
return request.put(`/document/${id}`, { title, categoryId, tags }).then(r => r.data)
}
/** 重新处理文档 */
export function reprocessDocument(id: string): Promise<ApiResponse> {
return request.put(`/document/${id}/reprocess`).then(r => r.data)
}
/** 批量删除文档 */
export function batchDeleteDocuments(ids: string[]): Promise<ApiResponse> {
return request.post('/document/batch/delete', { ids }).then(r => r.data)
}
/** 批量重新处理文档 */
export function batchReprocessDocuments(ids: string[]): Promise<ApiResponse> {
return request.post('/document/batch/reprocess', { ids }).then(r => r.data)
}
/** 切换文档启用/禁用 */
export function toggleDocument(id: string): Promise<ApiResponse> {
return request.put(`/document/${id}/toggle`).then(r => r.data)
}
/** 批量启用/禁用 */
export function batchToggleDocuments(ids: string[], enabled: boolean): Promise<ApiResponse> {
return request.post('/document/batch/toggle', { ids, enabled }).then(r => r.data)
}
/** 批量移动分类 */
export function batchMoveDocuments(ids: string[], categoryId: string): Promise<ApiResponse> {
return request.post('/document/batch/move', { ids, categoryId }).then(r => r.data)
}
/** 更新单个分块内容 */
export function updateChunk(docId: string, chunkIndex: number, content: string): Promise<ApiResponse> {
return request.put(`/document/${docId}/chunk/${chunkIndex}`, { content }).then(r => r.data)
}
/** 删除单个分块 */
export function deleteChunk(docId: string, chunkIndex: number): Promise<ApiResponse> {
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<ApiResponse> {
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<ApiResponse> {
return request.get('/document/stats').then(r => r.data)
}
// ==================== 标签 ====================
/** 获取标签列表 */
export function getTagList(): Promise<ApiResponse> {
return request.get('/tag/list').then(r => r.data)
}

17
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<ApiResponse> {
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<ApiResponse> { return request.post('/faq', data).then(r => r.data) }
export function updateFaq(id: string, data: any): Promise<ApiResponse> { return request.put(`/faq/${id}`, data).then(r => r.data) }
export function deleteFaq(id: string): Promise<ApiResponse> { return request.delete(`/faq/${id}`).then(r => r.data) }
export function toggleFaqStatus(id: string, status: string): Promise<ApiResponse> { return request.put(`/faq/${id}/toggle?status=${encodeURIComponent(status)}`).then(r => r.data) }
export function batchImportFaqs(data: { faqs: any[] }): Promise<ApiResponse> { return request.post('/faq/batch-import', data).then(r => r.data) }
export function exportFaqs(): Promise<ApiResponse> { return request.get('/faq/export').then(r => r.data) }
export function getFaqStats(): Promise<ApiResponse> { return request.get('/faq/stats').then(r => r.data) }

14
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<ApiResponse> { return request.post('/feedback', feedback).then(r => r.data) }
export function getFeedbackStats(startDate?: string, endDate?: string): Promise<ApiResponse> {
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<ApiResponse> { return request.get(`/feedback/by-conversation/${encodeURIComponent(conversationId)}`).then(r => r.data) }
export function getFeedbackBatch(messageIds: string[]): Promise<ApiResponse> { return request.get(`/feedback/batch?messageIds=${encodeURIComponent(messageIds.join(','))}`).then(r => r.data) }

11
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<ApiResponse> { return request.get(`/mcp-server/list?page=${page}&size=${size}`).then(r => r.data) }
export function getMcpServerDetail(id: string): Promise<ApiResponse> { return request.get(`/mcp-server/${id}`).then(r => r.data) }
export function createMcpServer(data: any): Promise<ApiResponse> { return request.post('/mcp-server', data).then(r => r.data) }
export function updateMcpServer(id: string, data: any): Promise<ApiResponse> { return request.put(`/mcp-server/${id}`, data).then(r => r.data) }
export function deleteMcpServer(id: string): Promise<ApiResponse> { return request.delete(`/mcp-server/${id}`).then(r => r.data) }
export function toggleMcpServer(id: string, active: boolean): Promise<ApiResponse> { return request.put(`/mcp-server/${id}/toggle?active=${active}`).then(r => r.data) }
export function testMcpServer(id: string): Promise<ApiResponse> { return request.post(`/mcp-server/${id}/test`, {}).then(r => r.data) }
export function refreshMcpServers(): Promise<ApiResponse> { return request.post('/mcp-server/refresh', {}).then(r => r.data) }

23
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<ApiResponse> {
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<ApiResponse> { return request.get(`/model-config/${id}`).then(r => r.data) }
export function getActiveModelConfig(appType: string): Promise<ApiResponse> { return request.get(`/model-config/active/${encodeURIComponent(appType)}`).then(r => r.data) }
export function createModelConfig(data: any): Promise<ApiResponse> { return request.post('/model-config', data).then(r => r.data) }
export function updateModelConfig(id: string, data: any): Promise<ApiResponse> { return request.put(`/model-config/${id}`, data).then(r => r.data) }
export function activateModelConfig(id: string): Promise<ApiResponse> { return request.put(`/model-config/${id}/activate`).then(r => r.data) }
export function deleteModelConfig(id: string): Promise<ApiResponse> { return request.delete(`/model-config/${id}`).then(r => r.data) }
export function testModelConfig(id: string): Promise<ApiResponse> { return request.post(`/model-config/${id}/test`, {}).then(r => r.data) }
export function duplicateModelConfig(id: string): Promise<ApiResponse> { return request.post(`/model-config/${id}/duplicate`, {}).then(r => r.data) }
export function getHealthStatus(): Promise<ApiResponse> { return request.get('/model-config/health').then(r => r.data) }
export function manualHealthCheck(id: string): Promise<ApiResponse> { return request.post(`/model-config/${id}/health-check`, {}).then(r => r.data) }
export function updateModelConfigPriority(id: string, priority: number): Promise<ApiResponse> { return request.put(`/model-config/${id}/priority`, { priority }).then(r => r.data) }
export function deactivateModelConfig(id: string): Promise<ApiResponse> { return request.put(`/model-config/${id}/deactivate`).then(r => r.data) }
export function exportModelConfigs(): Promise<ApiResponse> { return request.get('/model-config/export').then(r => r.data) }
export function importModelConfigs(configs: any[], onConflict = 'skip'): Promise<ApiResponse> { return request.post('/model-config/import', { configs, onConflict }).then(r => r.data) }
export function fetchModels(baseUrl: string, apiKey: string, provider?: string): Promise<ApiResponse> { return request.post('/model-config/fetch-models', { baseUrl, apiKey, provider }).then(r => r.data) }

41
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

11
frontend/src/api/role.ts

@ -0,0 +1,11 @@
import request from './request'
import type { ApiResponse } from '@/types/api'
export function getRoleList(): Promise<ApiResponse> { return request.get('/role/list').then(r => r.data) }
export function getAllRoles(): Promise<ApiResponse> { return request.get('/role/all').then(r => r.data) }
export function createRole(role: any): Promise<ApiResponse> { return request.post('/role', role).then(r => r.data) }
export function updateRole(roleId: string, role: any): Promise<ApiResponse> { return request.put(`/role/${roleId}`, role).then(r => r.data) }
export function deleteRole(roleId: string): Promise<ApiResponse> { return request.delete(`/role/${roleId}`).then(r => r.data) }
export function updateRoleCategories(roleId: string, categoryIds: string[]): Promise<ApiResponse> {
return request.put(`/role/${roleId}/categories`, { categoryIds }).then(r => r.data)
}

18
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<ApiResponse> {
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<ApiResponse> { return request.post('/sensitive-word', data).then(r => r.data) }
export function updateSensitiveWord(id: string, data: any): Promise<ApiResponse> { return request.put(`/sensitive-word/${id}`, data).then(r => r.data) }
export function deleteSensitiveWord(id: string): Promise<ApiResponse> { return request.delete(`/sensitive-word/${id}`).then(r => r.data) }
export function batchImportSensitiveWords(data: { words: string[]; category: string; level: number }): Promise<ApiResponse> { return request.post('/sensitive-word/batch-import', data).then(r => r.data) }
export function listAuditLogs(page = 1, size = 20, sessionId?: string): Promise<ApiResponse> {
let path = `/sensitive-word/audit-log?page=${page}&size=${size}`
if (sessionId) path += `&sessionId=${encodeURIComponent(sessionId)}`
return request.get(path).then(r => r.data)
}

9
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<ApiResponse> { return request.get(`/system-config/${key}`).then(r => r.data) }
export function listSystemConfigs(): Promise<ApiResponse> { return request.get('/system-config/list').then(r => r.data) }
export function updateSystemConfig(key: string, configValue: string, description?: string): Promise<ApiResponse> {
return request.put(`/system-config/${key}`, { configValue, description }).then(r => r.data)
}
export function deleteSystemConfig(key: string): Promise<ApiResponse> { return request.delete(`/system-config/${key}`).then(r => r.data) }

49
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<any> {
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<ApiResponse> {
return postFormWithProgress('/upload/file', formData, onProgress)
}
/** 上传 Markdown(带进度) */
export function uploadMarkdown(formData: FormData, onProgress?: (pct: number) => void): Promise<ApiResponse> {
return postFormWithProgress('/upload/markdown', formData, onProgress)
}
/** 上传 JSON 基本方式 */
export function uploadJsonBasic(formData: FormData, onProgress?: (pct: number) => void): Promise<ApiResponse> {
return postFormWithProgress('/upload/json/basic', formData, onProgress)
}
/** 上传 JSON 按字段提取 */
export function uploadJsonFields(formData: FormData, fields: string, onProgress?: (pct: number) => void): Promise<ApiResponse> {
return postFormWithProgress(`/upload/json/fields?fields=${encodeURIComponent(fields)}`, formData, onProgress)
}
/** 上传 JSON 按指针拆分 */
export function uploadJsonPointer(formData: FormData, pointer: string, onProgress?: (pct: number) => void): Promise<ApiResponse> {
return postFormWithProgress(`/upload/json/pointer?pointer=${encodeURIComponent(pointer)}`, formData, onProgress)
}

19
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<ApiResponse> {
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<ApiResponse> { return request.get(`/sys-user/${id}`).then(r => r.data) }
export function createSysUser(data: any): Promise<ApiResponse> { return request.post('/sys-user', data).then(r => r.data) }
export function updateSysUser(id: string, data: any): Promise<ApiResponse> { return request.put(`/sys-user/${id}`, data).then(r => r.data) }
export function toggleSysUser(id: string, enabled: boolean): Promise<ApiResponse> { return request.put(`/sys-user/${id}/toggle?enabled=${enabled}`).then(r => r.data) }
export function assignSysUserRoles(id: string, roleIds: string[]): Promise<ApiResponse> { return request.put(`/sys-user/${id}/roles`, { roleIds }).then(r => r.data) }
export function changeSysUserPassword(id: string, newPassword: string): Promise<ApiResponse> { return request.put(`/sys-user/${id}/password`, { newPassword }).then(r => r.data) }
export function getAllSysRoles(): Promise<ApiResponse> { return request.get('/sys-user/roles').then(r => r.data) }
export function listSysRoles(): Promise<ApiResponse> { return request.get('/sys-role/list').then(r => r.data) }
export function createSysRole(data: any): Promise<ApiResponse> { return request.post('/sys-role', data).then(r => r.data) }
export function updateSysRole(id: string, data: any): Promise<ApiResponse> { return request.put(`/sys-role/${id}`, data).then(r => r.data) }

8
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<ApiResponse> { return request.get(`/webhook/list?page=${page}&size=${size}`).then(r => r.data) }
export function createWebhook(data: any): Promise<ApiResponse> { return request.post('/webhook', data).then(r => r.data) }
export function updateWebhook(id: string, data: any): Promise<ApiResponse> { return request.put(`/webhook/${id}`, data).then(r => r.data) }
export function deleteWebhook(id: string): Promise<ApiResponse> { return request.delete(`/webhook/${id}`).then(r => r.data) }
export function testWebhook(id: string): Promise<ApiResponse> { return request.post(`/webhook/${id}/test`, {}).then(r => r.data) }

37
frontend/src/components/FormDialog.vue

@ -0,0 +1,37 @@
<template>
<!--
通用新增/编辑弹窗壳组件
封装 t-dialog + 页脚按钮行表单内容通过 slot 注入
-->
<t-dialog
:visible="visible"
:header="title"
:width="width"
:footer="false"
:close-on-overlay-click="!saving"
@close="$emit('update:visible', false)"
>
<slot />
<div class="dialog-footer">
<t-button variant="outline" :disabled="saving" @click="$emit('update:visible', false)">取消</t-button>
<slot name="extra-buttons" />
<t-button theme="primary" :loading="saving" :disabled="disableConfirm" @click="$emit('confirm')">{{ confirmText }}</t-button>
</div>
</t-dialog>
</template>
<script setup lang="ts">
defineProps({
visible: { type: Boolean, default: false },
title: { type: String, default: '' },
width: { type: String, default: '480px' },
saving: { type: Boolean, default: false },
confirmText: { type: String, default: '保存' },
disableConfirm: { type: Boolean, default: false },
})
defineEmits<{
'update:visible': [value: boolean]
confirm: []
}>()
</script>

47
frontend/src/components/MessageSources.vue

@ -0,0 +1,47 @@
<template>
<div class="message-sources" v-if="sources && sources.length">
<t-collapse>
<t-collapse-panel :header="'📚 引用来源 (' + sources.length + ')'">
<div v-for="(group, key) in grouped" :key="key" class="source-group">
<div class="source-doc-title">📄 {{ group.title || '文档 ' + key }}</div>
<div class="source-chunks">
<div v-for="chunk in group.chunks" :key="chunk.chunkIndex" class="source-chunk">
<span class="chunk-badge">#{{ chunk.chunkIndex }}</span>
<span v-html="renderMarkdown(chunk.content?.substring(0, 300) + (chunk.content?.length > 300 ? '...' : ''))"></span>
</div>
</div>
</div>
</t-collapse-panel>
</t-collapse>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { renderMarkdown } from '@/utils/markdown'
const props = defineProps<{ sources: any[] }>()
/** 按文档 ID 归并 chunk */
const grouped = computed(() => {
const groups: Record<string, { title?: string; chunks: any[] }> = {}
for (const s of (props.sources || [])) {
const docId = s.documentId || s.metadata?.documentId || 'unknown'
if (!groups[docId]) {
groups[docId] = {
title: s.metadata?.title || s.sourceName,
chunks: [],
}
}
groups[docId].chunks.push(s)
}
return groups
})
</script>
<style scoped>
.source-group { padding: 8px 0; border-bottom: 1px solid var(--td-border-level-1-color); }
.source-doc-title { font-weight: 600; font-size: 13px; color: var(--td-text-color-primary); margin-bottom: 4px; }
.source-chunk { font-size: 13px; color: var(--td-text-color-secondary); margin: 4px 0; padding: 4px 8px; background: var(--td-bg-color-secondarycontainer); border-radius: 4px; }
.chunk-badge { display: inline-block; background: var(--td-brand-color); color: #fff; font-size: 11px; padding: 1px 6px; border-radius: 3px; margin-right: 6px; }
</style>

38
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<boolean> true/ false
*/
function confirm(title: string, body?: string): Promise<boolean> {
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 }
}

70
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<T> {
/** 默认表单数据(创建时使用) */
defaultForm: T
/** 创建 API 函数 */
createFn: (data: T) => Promise<ApiResponse>
/** 更新 API 函数 */
updateFn: (id: string | number, data: T) => Promise<ApiResponse>
/** 保存成功后的回调(如刷新列表) */
onSuccess?: () => void
}
export function useCrudForm<T extends Record<string, any>>(options: CrudFormOptions<T>) {
const visible = ref(false)
const editingId = ref<string | number | null>(null)
const saving = ref(false)
const form = ref<T>({ ...options.defaultForm }) as ReturnType<typeof ref<T>>
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<boolean> {
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 }
}

33
frontend/src/composables/useDebounce.ts

@ -0,0 +1,33 @@
/**
* composable
* setTimeout/clearTimeout
*/
export function useDebounce() {
let timer: ReturnType<typeof setTimeout> | null = null
/**
*
* @param fn
* @param delay 300ms
* @returns
*/
function debounce<T extends (...args: any[]) => void>(fn: T, delay = 300): (...args: Parameters<T>) => void {
return (...args: Parameters<T>) => {
if (timer !== null) clearTimeout(timer)
timer = setTimeout(() => {
timer = null
fn(...args)
}, delay)
}
}
/** 清除等待中的定时器 */
function cancel() {
if (timer !== null) {
clearTimeout(timer)
timer = null
}
}
return { debounce, cancel }
}

36
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<T>(fn: () => Promise<T>): Promise<T> {
loading.value = true
try {
return await fn()
} finally {
loading.value = false
}
}
/**
* saving
*/
async function withSaving<T>(fn: () => Promise<T>): Promise<T> {
saving.value = true
try {
return await fn()
} finally {
saving.value = false
}
}
return { loading, saving, withLoading, withSaving }
}

51
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<T> {
/** 每页条数 */
pageSize?: number
/** 数据加载函数,接收 page 和 pageSize,返回 API 响应 */
fetchFn: (page: number, pageSize: number) => Promise<ApiResponse<PageData<T>>>
}
export function usePagination<T = any>(options: PaginationOptions<T>) {
const page = ref(1)
const total = ref(0)
const pageSize = options.pageSize ?? 20
const loading = ref(false)
const data = ref<T[]>([]) as ReturnType<typeof ref<T[]>>
/** 加载数据 */
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 }
}

68
frontend/src/layouts/MainLayout.vue

@ -0,0 +1,68 @@
<template>
<div class="layout-container">
<!-- 覆盖移动端菜单遮罩 -->
<div class="sidebar-overlay" v-if="mobileMenuOpen" @click="mobileMenuOpen = false"></div>
<!-- 顶部导航 -->
<Topbar @toggle-mobile-menu="mobileMenuOpen = !mobileMenuOpen" @logout="handleLogout" />
<!-- 主体 -->
<div class="layout-body">
<!-- 侧边栏 -->
<Sidebar
:collapsed="sidebarCollapsed"
:mobile-open="mobileMenuOpen"
@toggle-collapse="sidebarCollapsed = !sidebarCollapsed"
@close-mobile-menu="mobileMenuOpen = false"
/>
<!-- 内容区 -->
<main class="main-content">
<router-view />
</main>
</div>
<!-- 文档详情弹窗 -->
<DocDetail v-if="dialog.detailModal.visible" :visible="dialog.detailModal.visible"
:doc="dialog.detailModal.doc" :chunks="dialog.detailModal.chunks"
:chunks-error="dialog.detailModal.chunksError"
@close="dialog.closeDetail" />
</div>
</template>
<script setup lang="ts">
import { ref, provide } from 'vue'
import { useRouter } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
import { useDialogStore } from '@/stores/dialog'
import { toast } from '@/utils/toast'
import Topbar from './Topbar.vue'
import Sidebar from './Sidebar.vue'
import DocDetail from '@/views/DocDetail.vue'
const router = useRouter()
const auth = useAuthStore()
const dialog = useDialogStore()
const sidebarCollapsed = ref(false)
const mobileMenuOpen = ref(false)
//
provide('openDocDetail', (docId: string) => dialog.openDetail(docId))
function handleLogout() {
auth.logout()
toast('已退出登录', 'info')
router.push('/chat')
}
</script>
<style scoped>
.layout-container { display: flex; flex-direction: column; height: 100vh; overflow: hidden; }
.layout-body { display: flex; flex: 1; overflow: hidden; }
.main-content { flex: 1; overflow: auto; padding: 0; background: var(--td-bg-color-page); }
.sidebar-overlay { display: none; }
@media (max-width: 768px) {
.sidebar-overlay { display: block; position: fixed; inset: 0; background: rgba(0,0,0,.3); z-index: 98; }
}
</style>

104
frontend/src/layouts/Sidebar.vue

@ -0,0 +1,104 @@
<template>
<aside class="sidebar" :class="{ collapsed: collapsed && !mobileOpen, open: mobileOpen }">
<div class="sidebar-inner">
<t-menu
:value="activeMenu"
:default-expanded="expandedKeys"
:collapsed="collapsed"
@change="onMenuChange"
@expand="onExpand"
>
<template v-for="item in visibleMenuItems" :key="item.id">
<t-menu-item v-if="!item.children" :value="item.path || item.id">
<template #icon><span>{{ item.icon }}</span></template>
{{ item.label }}
</t-menu-item>
<t-submenu v-else :value="item.id" :title="item.label">
<template #icon><span>{{ item.icon }}</span></template>
<t-menu-item
v-for="child in item.children.filter(c => isMenuVisible(c))"
:key="child.id"
:value="child.path || child.id"
>
<template #icon><span>{{ child.icon }}</span></template>
{{ child.label }}
</t-menu-item>
</t-submenu>
</template>
</t-menu>
<div class="sidebar-bottom">
<t-button variant="text" size="small" @click="$emit('toggleCollapse')" :title="collapsed ? '展开侧边栏' : '折叠侧边栏'">
<span :style="{ display: 'inline-block', transform: collapsed ? 'rotate(180deg)' : '' }"></span>
</t-button>
</div>
</div>
</aside>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
import { MENU_ITEMS, type MenuItem } from '@/stores/navigation'
const props = defineProps<{ collapsed: boolean; mobileOpen: boolean }>()
const emit = defineEmits<{ toggleCollapse: []; closeMobileMenu: [] }>()
const router = useRouter()
const route = useRoute()
const auth = useAuthStore()
const activeMenu = computed(() => route.path)
const expandedKeys = computed(() => {
const keys: string[] = []
for (const item of MENU_ITEMS) {
if (item.children && item.children.some(c => c.path === route.path)) {
keys.push(item.id)
}
}
return keys
})
/** 隐藏无权限的 admin 菜单 */
const visibleMenuItems = computed(() => {
return MENU_ITEMS.filter(item => {
if (!auth.isAdmin) {
//
if (item.children) {
const visibleChildren = item.children.filter(c => isMenuVisible(c))
if (visibleChildren.length === 0) return false
// item.children
return true
}
}
return true
})
})
function isMenuVisible(item: MenuItem): boolean {
if (!item.roles || !item.roles.length) return true
return item.roles.some(r => auth.hasRole(r))
}
function onMenuChange(value: string) {
router.push(value)
emit('closeMobileMenu')
}
function onExpand(_value: string[]) {}
</script>
<style scoped>
.sidebar {
width: 220px; background: var(--td-bg-color-container); border-right: 1px solid var(--td-border-level-1-color);
flex-shrink: 0; display: flex; flex-direction: column; transition: width .25s; overflow: hidden;
}
.sidebar.collapsed { width: 60px; }
.sidebar-inner { flex: 1; display: flex; flex-direction: column; overflow: hidden; }
.sidebar-bottom { padding: 8px; border-top: 1px solid var(--td-border-level-1-color); text-align: center; }
@media (max-width: 768px) {
.sidebar { position: fixed; left: -260px; top: 48px; bottom: 0; z-index: 99; width: 260px; transition: left .25s; }
.sidebar.open { left: 0; }
}
</style>

52
frontend/src/layouts/Topbar.vue

@ -0,0 +1,52 @@
<template>
<div class="topbar">
<t-button class="menu-toggle" variant="text" @click="$emit('toggleMobileMenu')"></t-button>
<span class="logo">🤖 Support Bot</span>
<span class="ver">AI 智能客服系统</span>
<span class="links">
<a href="/sdk/test.html" target="_blank">🧪 SDK 测试面板</a>
<a href="/doc.html" target="_blank">📖 API 文档</a>
</span>
<div class="user-bar">
<span class="user-info">
<span class="user-avatar">{{ avatarChar }}</span>
<span class="user-name">{{ displayName }}</span>
<t-tag v-for="r in roles" :key="r" size="small" variant="light" theme="primary">{{ r }}</t-tag>
</span>
<t-button size="small" variant="outline" theme="default" @click="$emit('logout')">退出</t-button>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useAuthStore } from '@/stores/auth'
defineEmits<{ toggleMobileMenu: []; logout: [] }>()
const auth = useAuthStore()
const displayName = computed(() => auth.currentUser.nickname || auth.currentUser.username || '')
const avatarChar = computed(() => (auth.currentUser.nickname || auth.currentUser.username || 'U').charAt(0))
const roles = computed(() => auth.currentUser.roles || [])
</script>
<style scoped>
.topbar {
display: flex; align-items: center; height: 48px; padding: 0 16px;
background: #fff; border-bottom: 1px solid var(--td-border-level-1-color, #e7e7e7);
flex-shrink: 0; z-index: 100;
}
.logo { font-weight: 700; font-size: 16px; margin-right: 8px; }
.ver { font-size: 12px; color: #999; margin-right: auto; }
.links a { font-size: 13px; color: var(--td-brand-color, #0052d9); text-decoration: none; margin-left: 12px; }
.links a:hover { text-decoration: underline; }
.user-bar { display: flex; align-items: center; gap: 10px; margin-left: 16px; }
.user-info { display: flex; align-items: center; gap: 6px; }
.user-avatar {
width: 28px; height: 28px; border-radius: 50%; background: var(--td-brand-color, #0052d9);
color: #fff; display: flex; align-items: center; justify-content: center; font-size: 13px; font-weight: 600;
}
.user-name { font-size: 13px; color: #333; max-width: 120px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.menu-toggle { display: none; }
@media (max-width: 768px) { .menu-toggle { display: inline-flex; } .ver { display: none; } .links { display: none; } }
</style>

17
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')

76
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

7
frontend/src/shims-vue.d.ts

@ -0,0 +1,7 @@
/// <reference types="vite/client" />
declare module '*.vue' {
import type { DefineComponent } from 'vue'
const component: DefineComponent<{}, {}, any>
export default component
}

59
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<Record<string, any>>(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<boolean> {
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<boolean> {
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<string, any>): void {
currentUser.value = user || getUserInfo()
setUserInfo(user || {})
loggedIn.value = true
}
return { currentUser, loggedIn, isAdmin, hasRole, fetchUser, tryRefreshToken, logout, onLoginSuccess }
})

32
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<any[]>([])
const categoryMap = computed<Record<string, string>>(() => {
const map: Record<string, string> = {}
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<void> {
try {
const res = await getCategoryList()
if (res.success) {
categories.value = res.data || []
}
} catch (e) {
console.error('加载分类失败', e)
}
}
return { categories, categoryMap, getCategoryName, loadCategories }
})

40
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<void> {
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 }
})

27
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<any>(null)
const tags = ref<any[]>([])
async function loadStats(): Promise<void> {
try {
const res = await getStats()
if (res.success) stats.value = res.data
} catch (e) { console.error('加载统计失败', e) }
}
async function loadTags(): Promise<void> {
try {
const res = await getTagList()
if (res.success) tags.value = res.data || []
} catch (e) { console.error('加载标签列表失败', e) }
}
return { stats, tags, loadStats, loadTags }
})

44
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[]
}

49
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;
}

23
frontend/src/types/api.ts

@ -0,0 +1,23 @@
/** 通用 API 响应结构 */
export interface ApiResponse<T = any> {
success: boolean
code?: number
message?: string
data: T
}
/** 分页请求参数 */
export interface PageParams {
page?: number
pageSize?: number
keyword?: string
[key: string]: any
}
/** 分页响应 */
export interface PageData<T> {
records: T[]
total: number
page: number
pageSize: number
}

234
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<string, any>
}
// ==================== 分类管理 ====================
/** 知识库分类 */
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<string, any>
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<string, string>
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
}

3
frontend/src/types/page.ts

@ -0,0 +1,3 @@
/** 分页参数/响应类型 */
import type { PageParams, PageData } from './api'
export type { PageParams, PageData }

8
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
}

24
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}`
}

22
frontend/src/utils/markdown.ts

@ -0,0 +1,22 @@
/**
* Markdown utils.js
*
* Markdown HTMLmarked + 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)
}

196
src/main/resources/static/js/utils.js → frontend/src/utils/sse.ts

@ -1,146 +1,54 @@
/** /**
*
* Toast SSE Markdown Token
* SSE utils.js
*
* Flux<String> / 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<string, string>): Record<string, string> {
const token = getToken() 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<string, string> = 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<String> / ServerSentEvent / SseEmitter SSE
* SSE Flux<String> / 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<string, string>,
signal?: AbortSignal
): Promise<void> {
const res = await fetch(url, { headers: authHeaders(headers), signal })
if (!res.ok) throw new Error('HTTP ' + res.status) if (!res.ok) throw new Error('HTTP ' + res.status)
const reader = res.body.getReader()
const reader = res.body!.getReader()
const decoder = new TextDecoder() const decoder = new TextDecoder()
let buffer = '' let buffer = ''
// SSE 规范:同一事件内的多行 data: 字段用 \n 拼接,事件间用空行分隔 // SSE 规范:同一事件内的多行 data: 字段用 \n 拼接,事件间用空行分隔
let eventDataLines = []
let eventDataLines: string[] = []
let currentEvent = 'message'
const flushEvent = () => { const flushEvent = () => {
if (eventDataLines.length === 0) return if (eventDataLines.length === 0) return
const text = eventDataLines.join('\n') const text = eventDataLines.join('\n')
eventDataLines = [] eventDataLines = []
const ev = currentEvent
currentEvent = 'message'
if (text === '[DONE]') return if (text === '[DONE]') return
// 跳过 status / faq 等系统事件,不显示在对话框中
if (ev === 'status') return
// 空事件视为 LLM 流式输出的换行符(Spring 将 "\n" 编码为单条空 data: 事件) // 空事件视为 LLM 流式输出的换行符(Spring 将 "\n" 编码为单条空 data: 事件)
onChunk(text || '\n') onChunk(text || '\n')
} }
@ -157,6 +65,9 @@ export async function readSSEStream(url, onChunk, onDone, headers) {
if (line === '') { if (line === '') {
// 空行 = SSE 事件边界 // 空行 = SSE 事件边界
flushEvent() flushEvent()
} else if (line.startsWith('event:')) {
// 记录事件类型,用于 flushEvent 时过滤系统事件
currentEvent = line.slice(6).trim()
} else if (line.startsWith('data:')) { } else if (line.startsWith('data:')) {
// 累积 data 字段;仅剥离 data: 后的一个可选空格,保留 markdown 列表缩进 // 累积 data 字段;仅剥离 data: 后的一个可选空格,保留 markdown 列表缩进
let data = line.slice(5) let data = line.slice(5)
@ -178,25 +89,31 @@ export async function readSSEStream(url, onChunk, onDone, headers) {
* SSE * SSE
* SSE event: 字段 * SSE event: 字段
* *
* @param {string} url
* @param {object} handlers
* @param url
* @param handlers
* - onMessage(chunk): event: message event data * - onMessage(chunk): event: message event data
* - onToolCallStart(data): event: tool_call_start * - onToolCallStart(data): event: tool_call_start
* - onToolCallResult(data): event: tool_call_result * - onToolCallResult(data): event: tool_call_result
* - onError(data): event: error * - onError(data): event: error
* - onDone(): * - 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<string, string>,
signal?: AbortSignal
): Promise<void> {
const { onMessage, onToolCallStart, onToolCallResult, onError, onDone } = handlers 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) if (!res.ok) throw new Error('HTTP ' + res.status)
const reader = res.body.getReader()
const reader = res.body!.getReader()
const decoder = new TextDecoder() const decoder = new TextDecoder()
let buffer = '' let buffer = ''
let currentEvent = 'message' let currentEvent = 'message'
// SSE 规范:同一事件内的多行 data: 字段用 \n 拼接 // SSE 规范:同一事件内的多行 data: 字段用 \n 拼接
let eventDataLines = []
let eventDataLines: string[] = []
const flushEvent = () => { const flushEvent = () => {
if (eventDataLines.length === 0) return if (eventDataLines.length === 0) return
@ -218,6 +135,9 @@ export async function readSSEStreamWithEvents(url, handlers, headers) {
case 'error': case 'error':
if (onError) onError(JSON.parse(text)) if (onError) onError(JSON.parse(text))
break break
case 'status':
// 系统状态事件(generating / faq_hit 等),不显示在对话框中
break
case 'message': case 'message':
default: default:
if (onMessage) onMessage(text) if (onMessage) onMessage(text)
@ -253,17 +173,3 @@ export async function readSSEStreamWithEvents(url, handlers, headers) {
flushEvent() flushEvent()
if (onDone) onDone() if (onDone) onDone()
} }
// ==================== Markdown 渲染 ====================
/**
* Markdown HTMLmarked + 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)
}

8
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)
}

46
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<string, any> {
try {
return JSON.parse(localStorage.getItem(USER_KEY) || '{}')
} catch {
return {}
}
}
export function setUserInfo(info: Record<string, any>): void {
localStorage.setItem(USER_KEY, JSON.stringify(info))
}
export function isLoggedIn(): boolean {
return !!getToken()
}

58
frontend/src/views/AccountManager.vue

@ -0,0 +1,58 @@
<template>
<t-card title="👤 账号管理" :bordered="false">
<p class="desc-text">管理外部对接账号为账号绑定客服角色以控制使用权限</p>
<div class="toolbar">
<t-button theme="primary" size="small" @click="openCreate"> 新建账号</t-button>
<t-button variant="outline" size="small" @click="loadList">🔄 刷新</t-button>
</div>
<t-table :data="accounts" :columns="columns" row-key="id">
<template #roles="{row}"><t-tag v-for="r in (row.roleNames||[])" :key="r" size="small" variant="light" theme="primary" style="margin-right:4px;">{{ r }}</t-tag></template>
<template #op="{row}">
<t-space :size="4">
<t-button size="small" variant="text" @click="openEdit(row)">编辑</t-button>
<t-button size="small" variant="text" theme="danger" @click="doDelete(row.id)">删除</t-button>
</t-space>
</template>
</t-table>
<t-dialog v-model:visible="modal.visible" :header="modal.isEdit?'编辑账号':'新建账号'" width="480px" :footer="false">
<t-form label-align="top">
<t-form-item label="账号名称 *"><t-input v-model="modal.name" :disabled="modal.isEdit" placeholder="外部账号标识" /></t-form-item>
</t-form>
<div class="dialog-footer">
<t-button variant="outline" @click="modal.visible=false">取消</t-button>
<t-button theme="primary" @click="handleSave">保存</t-button>
</div>
</t-dialog>
</t-card>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue'
import { getAccountList, createAccount, updateAccount, deleteAccount } from '@/api/account'
import { toast } from '@/utils/toast'
import { useConfirm } from '@/composables/useConfirm'
const { confirm } = useConfirm()
const accounts=ref<any[]>([])
const columns=[{colKey:'name',title:'账号名称',width:160},{colKey:'roles',title:'绑定角色',width:240},{colKey:'op',title:'操作',width:120}]
const modal=reactive({visible:false,isEdit:false,id:null as any,name:''})
onMounted(()=>loadList())
async function loadList(){try{const r=await getAccountList();if(r.success)accounts.value=r.data||[]}catch(e:any){toast('加载失败:'+e.message,'error')}}
function openCreate(){Object.assign(modal,{visible:true,isEdit:false,id:null,name:''})}
function openEdit(a:any){Object.assign(modal,{visible:true,isEdit:true,id:a.id,name:a.name||''})}
async function handleSave(){
try{let r;if(modal.isEdit)r=await updateAccount(modal.id,{name:modal.name});else r=await createAccount({name:modal.name})
if(r.success){toast('保存成功','success');modal.visible=false;loadList()}else toast(r.message||'操作失败','error')}catch(e:any){toast('操作失败:'+e.message,'error')}}
async function doDelete(id:string){if(!await confirm('确定删除?'))return
try{const r=await deleteAccount(id);if(r.success){toast('删除成功','success');loadList()}else toast(r.message||'删除失败','error')}catch(e:any){toast('删除失败:'+e.message,'error')}}
</script>
<style scoped></style>

108
frontend/src/views/ApiKeyManager.vue

@ -0,0 +1,108 @@
<template>
<t-card title="🔑 API Key 管理" :bordered="false">
<div class="toolbar">
<t-button theme="primary" size="small" @click="openCreateDialog"> 新建 API Key</t-button>
<div style="flex:1;" /><span class="total-hint"> {{ total }} Key</span>
</div>
<t-table :data="keys" :columns="columns" row-key="id" :loading="loading"
:pagination="{current:page,total:total,pageSize:pageSize,showJumper:true}" @page-change="onPageChange">
<template #keyValue="{row}">
<code class="key-code">{{ maskKey(row.keyValue) }}</code>
<t-button size="small" variant="text" @click="copyKey(row.keyValue)" title="复制">📋</t-button>
</template>
<template #roleIds="{row}">
<span v-if="getBoundRoleCount(row.roleIds)" style="color:var(--td-brand-color);">{{ getBoundRoleCount(row.roleIds) }} 个角色</span>
<span v-else style="color:#999;">不限</span>
</template>
<template #enabled="{row}"><span :style="{color:row.enabled?'var(--td-success-color)':'var(--td-error-color)'}">{{ row.enabled?'✅ 有效':'❌ 已吊销' }}</span></template>
<template #op="{row}">
<t-space :size="4">
<t-button size="small" variant="text" @click="openRoleDialog(row)">绑定角色</t-button>
<t-button v-if="row.enabled" size="small" variant="text" theme="danger" @click="doRevoke(row.id)">吊销</t-button>
<t-button v-else size="small" variant="text" theme="success" @click="doEnable(row.id)">启用</t-button>
<t-button size="small" variant="text" theme="danger" @click="doDelete(row.id)">删除</t-button>
</t-space>
</template>
</t-table>
<!-- 新建弹窗 -->
<t-dialog v-model:visible="showCreateDialog" header="新建 API Key" width="520px" :footer="false">
<t-form label-align="top">
<t-form-item label="名称 *"><t-input v-model="form.name" placeholder="如:生产环境 Key" /></t-form-item>
<t-form-item label="描述"><t-input v-model="form.description" placeholder="用途说明(可选)" /></t-form-item>
<t-row :gutter="16"><t-col :span="6"><t-form-item label="频率限制/分钟"><t-input-number v-model="form.rateLimit" :min="1" /></t-form-item></t-col></t-row>
</t-form>
<!-- 创建成功显示 Key -->
<div v-if="createdKey" class="created-key-box">
<p>API Key 创建成功仅显示一次请妥善保管</p>
<code class="created-key">{{ createdKey }}</code>
<t-button size="small" @click="copyKey(createdKey)">📋 复制</t-button>
</div>
<div class="dialog-footer">
<t-button variant="outline" @click="showCreateDialog=false">关闭</t-button>
<t-button theme="primary" @click="doCreate" :disabled="!form.name">创建</t-button>
</div>
</t-dialog>
<!-- 角色绑定弹窗 -->
<t-dialog v-model:visible="showRoleDialog" :header="'绑定角色 — '+bindingKeyName" width="480px" :footer="false">
<t-checkbox-group v-model="selectedRoleIds">
<t-checkbox v-for="r in allRoles" :key="r.id" :value="String(r.id)">{{ r.name }} ({{ r.roleKey }})</t-checkbox>
</t-checkbox-group>
<div class="dialog-footer">
<t-button variant="outline" @click="showRoleDialog=false">取消</t-button>
<t-button theme="primary" @click="doBindRoles">保存</t-button>
</div>
</t-dialog>
</t-card>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { listApiKeys, createApiKey, revokeApiKey, enableApiKey, deleteApiKey, updateApiKeyRoles } from '@/api/api-key'
import { getAllRoles } from '@/api/role'
import { toast } from '@/utils/toast'
import { useConfirm } from '@/composables/useConfirm'
const { confirm } = useConfirm()
const keys=ref<any[]>([]);const loading=ref(false);const page=ref(1);const pageSize=ref(20);const total=ref(0)
const showCreateDialog=ref(false);const showRoleDialog=ref(false)
const form=ref({name:'',description:'',rateLimit:60})
const createdKey=ref('')
const allRoles=ref<any[]>([]);const selectedRoleIds=ref<string[]>([]);const bindingKeyId=ref<any>(null);const bindingKeyName=ref('')
const columns=[{colKey:'name',title:'名称',width:140},{colKey:'keyValue',title:'Key',width:200},{colKey:'rateLimit',title:'频率限制',width:80},{colKey:'roleIds',title:'角色',width:90},{colKey:'enabled',title:'状态',width:80},{colKey:'op',title:'操作',width:260}]
onMounted(()=>{loadList();loadRoles()})
function maskKey(k:string){if(!k||k.length<12)return k||'';return k.slice(0,6)+'****'+k.slice(-4)}
function getBoundRoleCount(ids:any){try{const a=typeof ids==='string'?JSON.parse(ids):ids;return Array.isArray(a)?a.length:0}catch{return 0}}
async function loadList(){loading.value=true
try{const r=await listApiKeys(page.value,pageSize.value);if(r.success){keys.value=r.data?.records||r.data||[];total.value=r.data?.total||r.total||0}}catch(e:any){toast('加载失败:'+e.message,'error')}finally{loading.value=false}}
function onPageChange(i:{current:number;pageSize:number}){page.value=i.current;pageSize.value=i.pageSize;loadList()}
async function loadRoles(){try{const r=await getAllRoles();if(r.success)allRoles.value=r.data||[]}catch(e){console.error(e)}}
function openCreateDialog(){createdKey.value='';form.value={name:'',description:'',rateLimit:60};showCreateDialog.value=true}
async function doCreate(){try{const r=await createApiKey(form.value);if(r.success){createdKey.value=r.data?.apiKey||r.data?.key||'';toast('创建成功','success');loadList()}else toast(r.message||'创建失败','error')}catch(e:any){toast('创建失败:'+e.message,'error')}}
async function doRevoke(id:string){if(!await confirm('确定吊销?'))return;try{const r=await revokeApiKey(id);if(r.success){toast('已吊销','success');loadList()}else toast(r.message||'吊销失败','error')}catch(e:any){toast('吊销失败:'+e.message,'error')}}
async function doEnable(id:string){try{const r=await enableApiKey(id);if(r.success){toast('已启用','success');loadList()}else toast(r.message||'启用失败','error')}catch(e:any){toast('启用失败:'+e.message,'error')}}
async function doDelete(id:string){if(!await confirm('确定删除?'))return;try{const r=await deleteApiKey(id);if(r.success){toast('删除成功','success');loadList()}else toast(r.message||'删除失败','error')}catch(e:any){toast('删除失败:'+e.message,'error')}}
function openRoleDialog(k:any){bindingKeyId.value=k.id;bindingKeyName.value=k.name||'未命名'
let ids:string[]=[];try{const a=typeof k.roleIds==='string'?JSON.parse(k.roleIds):k.roleIds;if(Array.isArray(a))ids=a.map(String)}catch{};selectedRoleIds.value=ids;showRoleDialog.value=true}
async function doBindRoles(){try{const r=await updateApiKeyRoles(bindingKeyId.value,selectedRoleIds.value);if(r.success){toast('保存成功','success');showRoleDialog.value=false;loadList()}else toast(r.message||'保存失败','error')}catch(e:any){toast('保存失败:'+e.message,'error')}}
async function copyKey(k:string){try{await navigator.clipboard.writeText(k);toast('已复制','success')}catch{toast('复制失败','error')}}
</script>
<style scoped>.key-code{font-size:12px;background:#f3f4f6;padding:2px 6px;border-radius:4px;}.created-key-box{margin-top:12px;padding:12px;background:#f0fdf4;border:1px solid #bbf7d0;border-radius:8px;}.created-key-box p{font-size:12px;color:#166534;margin-bottom:6px;}.created-key{font-size:13px;word-break:break-all;display:block;margin-bottom:6px;}</style>

39
frontend/src/views/AuditLogManager.vue

@ -0,0 +1,39 @@
<template>
<t-card title="📋 内容审计日志" :bordered="false">
<p class="desc-text">记录所有敏感词命中事件包含输入/输出方向违规内容命中词及处理方式</p>
<t-table :data="logs" :columns="columns" row-key="id" :loading="loading"
:pagination="{current:page,total:total,pageSize:pageSize,showJumper:true}" @page-change="onPageChange">
<template #direction="{row}"><span :style="{color:row.direction==='INPUT'?'#007bff':'#28a745'}">{{ row.direction==='INPUT'?'输入':'输出' }}</span></template>
<template #originalText="{row}"><span :title="row.originalText" class="ellipsis-td">{{ row.originalText }}</span></template>
<template #actionTaken="{row}"><span :style="{color:row.actionTaken==='BLOCK'?'#dc3545':row.actionTaken==='MASK'?'#ffc107':'#28a745'}">{{ row.actionTaken==='BLOCK'?'拦截':row.actionTaken==='MASK'?'脱敏':'通过' }}</span></template>
<template #hitWords="{row}"><span class="hit-words">{{ formatHitWords(row.hitWords) }}</span></template>
</t-table>
</t-card>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { listAuditLogs } from '@/api/sensitive-word'
import { toast } from '@/utils/toast'
import { formatDate } from '@/utils/format'
const logs=ref<any[]>([]);const loading=ref(false);const page=ref(1);const pageSize=ref(30);const total=ref(0)
const columns=[{colKey:'createTime',title:'时间',width:160,cell:(_:any,{row}:any)=>formatDate(row.createTime)},{colKey:'direction',title:'方向',width:60},{colKey:'originalText',title:'违规内容',width:260},{colKey:'hitWords',title:'命中词',width:180},{colKey:'actionTaken',title:'处理方式',width:80}]
onMounted(()=>loadLogs())
async function loadLogs(){loading.value=true
try{const r=await listAuditLogs(page.value,pageSize.value);if(r.success){logs.value=r.data?.records||r.data||[];total.value=r.data?.total||r.total||0}else toast('加载失败:'+(r.message||''),'error')}catch(e:any){toast('加载失败:'+e.message,'error')}finally{loading.value=false}}
function onPageChange(i:{current:number;pageSize:number}){page.value=i.current;pageSize.value=i.pageSize;loadLogs()}
function formatHitWords(hw:any):string{
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?.hits&&Array.isArray(hw.hits))return hw.hits.map((h:any)=>`${h.word||''}(${h.category||''})`).join('、')
if(Array.isArray(hw))return hw.map((h:any)=>typeof h==='string'?h:h.word||'').join('、')
return String(hw)
}
</script>
<style scoped>.ellipsis-td{max-width:260px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;display:block;}.hit-words{font-size:12px;}</style>

124
frontend/src/views/CategoryManager.vue

@ -0,0 +1,124 @@
<template>
<t-card title="🏷️ 分类管理" :bordered="false">
<!-- 创建新分类 -->
<div class="create-bar card-box">
<h3 class="create-title">创建新分类</h3>
<t-space size="small" style="flex-wrap:wrap;">
<t-select v-model="newParentId" :options="parentOpts" placeholder="顶级分类" size="small" style="width:180px;" />
<t-input v-model="newName" placeholder="分类名称" size="small" style="width:160px;" @enter="create" />
<t-input v-model="newDescription" placeholder="描述(可选)" size="small" style="width:180px;" />
<t-input-number v-model="newSortOrder" placeholder="排序" size="small" style="width:80px;" :min="0" />
<t-button theme="success" size="small" @click="create">创建</t-button>
</t-space>
</div>
<!-- 树形列表 -->
<div class="tree-header">
<span class="tree-count"> {{ categoryStore.categories.length }} 个分类</span>
<t-button variant="outline" size="small" @click="categoryStore.loadCategories()">刷新</t-button>
</div>
<t-empty v-if="flatTree.length===0" description="暂无分类" />
<div v-else>
<div v-for="node in flatTree" :key="node.id" class="tree-row" :style="{paddingLeft:(node.level*24+12)+'px'}">
<div class="tree-left">
<span v-if="node.hasChildren" @click="toggleExpand(node.id)" class="tree-arrow">{{ expandedIds.has(node.id)?'':'' }}</span>
<span v-else style="width:16px;" />
<strong class="tree-name">{{ node.name }}</strong>
<span v-if="node.description" class="tree-desc">{{ node.description }}</span>
<span class="tree-doc-count">({{ node.documentCount||0 }} )</span>
</div>
<t-space :size="4">
<t-button size="small" variant="text" @click="openEdit(node)">编辑</t-button>
<t-button size="small" variant="text" theme="danger" @click="remove(node)">删除</t-button>
</t-space>
</div>
</div>
<!-- 编辑弹窗 -->
<t-dialog v-model:visible="editModal.visible" header="编辑分类" width="440px" :footer="false">
<t-form label-align="top">
<t-form-item label="分类名称"><t-input v-model="editModal.name" placeholder="分类名称" /></t-form-item>
<t-form-item label="描述"><t-input v-model="editModal.description" placeholder="描述(可选)" /></t-form-item>
<t-form-item label="排序权重"><t-input-number v-model="editModal.sortOrder" :min="0" style="width:120px;" /></t-form-item>
</t-form>
<div class="dialog-footer">
<t-button variant="outline" @click="closeEdit">取消</t-button>
<t-button theme="primary" @click="saveEdit">保存</t-button>
</div>
</t-dialog>
</t-card>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useCategoryStore } from '@/stores/category'
import { createCategory, updateCategory, deleteCategory } from '@/api/category'
import { toast } from '@/utils/toast'
import { useConfirm } from '@/composables/useConfirm'
const { confirm } = useConfirm()
const categoryStore = useCategoryStore()
const newName=ref(''); const newDescription=ref(''); const newParentId=ref(''); const newSortOrder=ref(0)
const parentOpts = computed(() => {
const opts = [{label:'顶级分类',value:''}]
function walk(cats: any[], level: number) { for(const c of cats){ opts.push({label:'─'.repeat(level)+' '+c.name,value:String(c.id)}); if(c.children?.length && level<2) walk(c.children,level+1) } }
walk(categoryTree.value,0); return opts
})
const expandedIds = ref(new Set<string>())
const editModal = ref({visible:false,id:null as any,name:'',description:'',sortOrder:0})
const categoryTree = computed(() => {
const cats = categoryStore.categories||[]; const m=new Map<string,any>(); const roots:any[]=[]
for(const c of cats){ m.set(String(c.id),{...c,children:[],level:0}) }
for(const node of m.values()){
const pid = String(node.parentId || '')
if (pid && pid !== '0') { const p = m.get(pid); if (p) p.children.push(node); else roots.push(node) }
else roots.push(node)
}
function sort(nodes:any[]){nodes.sort((a,b)=>(a.sortOrder||0)-(b.sortOrder||0));nodes.forEach(n=>{if(n.children?.length)sort(n.children)})}
sort(roots); return roots
})
const flatTree = computed(() => {
const r:any[]=[]
function walk(nodes:any[],level:number){for(const n of nodes){r.push({...n,level,hasChildren:!!(n.children&&n.children.length)});if(n.children&&n.children.length&&expandedIds.value.has(String(n.id))) walk(n.children,level+1)}}
walk(categoryTree.value,0); return r
})
function toggleExpand(id:string){const s=new Set(expandedIds.value);if(s.has(id))s.delete(id);else s.add(id);expandedIds.value=s}
function expandAll(){const s=new Set<string>();function walk(nodes:any[]){for(const n of nodes){if(n.children?.length){s.add(String(n.id));walk(n.children)}}};walk(categoryTree.value);expandedIds.value=s}
onMounted(async () => { await categoryStore.loadCategories(); setTimeout(expandAll,100) })
async function create(){if(!newName.value.trim()){toast('请输入分类名称','error');return}
try{const r=await createCategory({name:newName.value.trim(),description:newDescription.value.trim(),parentId:newParentId.value||null,sortOrder:newSortOrder.value})
if(r.success){toast('分类创建成功','success');newName.value='';newDescription.value='';newParentId.value='';newSortOrder.value=0;await categoryStore.loadCategories();setTimeout(expandAll,100)}else toast(r.message||'创建失败','error')}catch(e:any){toast('创建失败:'+e.message,'error')}}
function openEdit(node:any){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 r=await updateCategory(editModal.value.id,{name:editModal.value.name.trim(),description:editModal.value.description.trim(),sortOrder:editModal.value.sortOrder})
if(r.success){toast('分类更新成功','success');closeEdit();await categoryStore.loadCategories();setTimeout(expandAll,100)}else toast(r.message||'更新失败','error')}catch(e:any){toast('更新失败:'+e.message,'error')}}
async function remove(node:any){const cc=countDescendants(node);const m=cc>0?`\n注意:下有 ${cc} 个子分类`:''
if(!await confirm(`确定删除「${node.name}」?关联文档将变为未分类${m}`))return
try{const r=await deleteCategory(node.id);if(r.success){toast('分类已删除','success');await categoryStore.loadCategories();setTimeout(expandAll,100)}else toast(r.message||'删除失败','error')}catch(e:any){toast('删除失败:'+e.message,'error')}}
function countDescendants(node:any):number{if(!node.children?.length)return 0;let c=node.children.length;for(const ch of node.children)c+=countDescendants(ch);return c}
</script>
<style scoped>
.create-title{font-size:13px;font-weight:600;margin-bottom:8px;}
.tree-header{display:flex;justify-content:space-between;align-items:center;margin-bottom:8px;}.tree-count{font-size:13px;color:#999;}
.tree-row{padding:10px 12px;border-bottom:1px solid #e7e7e7;display:flex;justify-content:space-between;align-items:center;}
.tree-left{display:flex;align-items:center;gap:6px;min-width:0;flex:1;}
.tree-arrow{cursor:pointer;user-select:none;font-size:12px;width:16px;text-align:center;color:#999;}
.tree-name{font-size:13px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
.tree-desc{color:#999;font-size:12px;margin-left:8px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
.tree-doc-count{color:#999;font-size:11px;margin-left:8px;white-space:nowrap;}
</style>

684
frontend/src/views/ChatPanel.vue

@ -0,0 +1,684 @@
<template>
<div class="chat-shell">
<!-- 左侧角色面板 -->
<aside class="chat-sidebar">
<div class="agent-card">
<div class="agent-avatar">SB</div>
<div>
<div class="agent-name">Support Bot</div>
<div class="agent-status"><span></span>在线客服助手</div>
</div>
</div>
<div class="side-section side-section-grow">
<div class="side-label">客服助手</div>
<div class="assistant-list">
<div
v-for="role in roles"
:key="role.key"
:class="['assistant-card', selectedRole === role.key ? 'active' : '']"
@click="selectRole(role.key)"
>
{{ role.name }}
</div>
</div>
</div>
</aside>
<!-- 右侧对话区 -->
<section class="chat-main">
<!-- 顶栏 -->
<header class="chat-header">
<div class="chat-title">
<h2>{{ currentRole.name }}</h2>
<div class="chat-subline">
<span :class="['rag-dot', isRagMode ? 'on' : '']"></span>{{ ragStatusText }}
<span class="dot-sep">&middot;</span>
{{ selectedCategoryNames.length ? selectedCategoryNames.join('、') : '全部知识库' }}
</div>
</div>
<div class="chat-actions">
<div class="model-chip" :class="{ 'model-chip-error': modelLoadError }" :title="modelTitle">
<span>调用模型</span>
<strong>{{ activeModelText }}</strong>
</div>
<label class="rag-toggle">
<t-switch v-model="isRagMode" size="small" />
<span>RAG 检索</span>
</label>
<t-select
v-if="isRagMode"
v-model="ragStrategy"
class="rag-strategy"
size="small"
:options="ragStrategyOptions"
/>
<t-select v-model="mode" class="chat-mode" size="small" :options="modeOptions" />
</div>
</header>
<!-- 快捷提问 -->
<div class="quick-row" v-if="messages.length <= 1">
<t-tag
v-for="q in QUICK_QUESTIONS"
:key="q"
theme="default"
variant="light"
class="quick-tag"
@click="useQuickQuestion(q)"
>{{ q }}</t-tag>
</div>
<!-- 消息列表 -->
<div class="msg-area chat-msg-area" ref="msgAreaRef">
<div v-for="(m, i) in messages" :key="i" :class="['msg', m.role, m.streaming ? 'streaming' : '']">
<div class="msg-content">
<!-- 编辑模式 -->
<template v-if="editingIndex === i">
<t-textarea
v-model="editingText"
:autosize="{ minRows: 2, maxRows: 4 }"
@keydown.enter.exact.prevent="submitEdit(i)"
/>
<div class="edit-actions">
<t-button theme="primary" size="small" @click="submitEdit(i)" :disabled="isSending || !editingText.trim()">重新发送</t-button>
<t-button variant="outline" size="small" @click="cancelEdit">取消</t-button>
<span class="edit-hint">将清除此条之后的全部对话并重发</span>
</div>
</template>
<!-- 正常显示模式 -->
<template v-else>
<!-- MCP 工具调用 -->
<div v-if="m.role === 'assistant' && m.toolCalls && m.toolCalls.length" class="mcp-tool-calls">
<div v-for="(tc, j) in m.toolCalls" :key="j" class="mcp-tool-item">
<div class="mcp-tool-header">
<span class="mcp-tool-icon">{{ tc.status === 'running' ? '⏳' : '✅' }}</span>
<span class="mcp-tool-name">{{ tc.tool }}</span>
<span v-if="tc.latencyMs" class="mcp-tool-latency">{{ tc.latencyMs }}ms</span>
</div>
</div>
</div>
<!-- AI 回复Markdown 渲染 -->
<div v-if="m.role === 'assistant' && m.content" class="msg-bubble markdown-body" v-html="renderMarkdown(m.content)"></div>
<!-- AI 思考中 -->
<div v-else-if="m.role === 'assistant'" class="msg-bubble"><span class="thinking">正在思考</span></div>
<!-- 用户消息 -->
<div v-else class="msg-bubble">{{ m.content }}</div>
<!-- 引用来源 -->
<MessageSources v-if="m.role === 'assistant' && m.sources && m.sources.length" :sources="m.sources" />
<!-- 消息操作栏 -->
<div class="msg-tools">
<span>{{ m.time }}</span>
<t-button v-if="m.role === 'assistant' && m.content" variant="text" size="small" @click="copyMessage(m.content)">复制</t-button>
<t-button v-if="m.role === 'assistant' && m.content && !m.streaming" variant="text" size="small" @click="regenerate(i)">重新生成</t-button>
<t-button v-if="m.role === 'user' && !m.streaming" variant="text" size="small" @click="startEditMessage(i)">编辑</t-button>
<t-button v-if="m.error" variant="text" size="small" @click="retryLast">重试</t-button>
<template v-if="m.role === 'assistant' && m.content && !m.streaming">
<t-button variant="text" size="small" :style="{ color: m.feedback === 'up' ? 'var(--td-brand-color)' : '' }" @click="submitFeedback(m.id, 'up')" title="有帮助">👍</t-button>
<t-button variant="text" size="small" :style="{ color: m.feedback === 'down' ? 'var(--td-error-color)' : '' }" @click="submitFeedback(m.id, 'down')" title="没帮助">👎</t-button>
</template>
</div>
</template>
</div>
</div>
</div>
<!-- 输入区 -->
<footer class="chat-composer">
<div class="composer-box">
<t-textarea
v-model="userInput"
placeholder="输入问题,Enter 发送,Shift + Enter 换行"
:autosize="{ minRows: 2, maxRows: 4 }"
:disabled="isSending"
@keydown.enter.exact.prevent="send"
/>
<t-button theme="primary" class="send-btn" @click="send" :disabled="isSending || !userInput.trim()">
{{ isSending ? '发送中' : '发送' }}
</t-button>
</div>
</footer>
</section>
</div>
</template>
<script setup lang="ts">
import { ref, computed, nextTick, onMounted, onBeforeUnmount } from 'vue'
import { chatSync, chatRagSync, chatSSEUrl, chatRagSSEUrl, ragSources } from '@/api/chat'
import { getRoleList } from '@/api/role'
import { getActiveModelConfig } from '@/api/model-config'
import { truncateConversation } from '@/api/conversation'
import { submitFeedback as submitFeedbackApi } from '@/api/feedback'
import { toast } from '@/utils/toast'
import { readSSEStream, readSSEStreamWithEvents } from '@/utils/sse'
import { renderMarkdown } from '@/utils/markdown'
import { useCategoryStore } from '@/stores/category'
import MessageSources from '@/components/MessageSources.vue'
const categoryStore = useCategoryStore()
// ==================== ====================
const FALLBACK_ROLE = {
id: '', key: 'general', name: '客服',
categoryIds: [] as string[],
}
const QUICK_QUESTIONS = [
'我的问题应该找哪个部门处理?',
'如何办理业务申请?',
'流程进度一直没更新怎么办?',
'费用报销需要准备哪些材料?',
'办公用品或资产怎么申请?',
]
const ragStrategyOptions = [
{ label: '不重写', value: 'NONE' },
{ label: '查询重写', value: 'REWRITE' },
{ label: '翻译扩展', value: 'TRANSLATION' },
{ label: '查询压缩', value: 'COMPRESSION' },
{ label: '多路扩展', value: 'MULTI_QUERY' },
]
const modeOptions = [
{ label: '同步调用', value: 'sync' },
{ label: 'SSE 流式', value: 'sse' },
]
// ==================== ====================
const chatId = ref('')
const mode = ref('sse') // SSE
const selectedRole = ref('general')
const roles = ref([FALLBACK_ROLE])
const isRagMode = ref(false)
const ragStrategy = ref('MULTI_QUERY')
const activeModel = ref<any>(null)
const modelLoadError = ref('')
const userInput = ref('')
const lastUserInput = ref('')
const isSending = ref(false)
const msgAreaRef = ref<HTMLElement | null>(null)
const editingIndex = ref(-1)
const editingText = ref('')
let sseAbortController: AbortController | null = null
// ==================== ====================
interface ChatMessage {
id: string
role: 'user' | 'assistant'
content: string
streaming: boolean
time: string
sources?: any[]
toolCalls?: any[]
error?: boolean
feedback?: string | null
}
const messages = ref<ChatMessage[]>([
{
id: generateMsgId(),
role: 'assistant',
content: '您好,我是智能客服助手。可以咨询客服、财务、行政相关问题;需要基于知识库回答时,可以在上方开启 RAG 检索。',
streaming: false,
time: formatTime(),
},
])
// ==================== ====================
const currentRole = computed(() => roles.value.find(r => r.key === selectedRole.value) || roles.value[0] || FALLBACK_ROLE)
const selectedCategoryIds = computed(() => (currentRole.value.categoryIds || []).map(String))
const selectedCategoryNames = computed(() => {
const selected = new Set(selectedCategoryIds.value)
return categoryStore.categories
.filter(c => selected.has(String(c.id)))
.map(c => c.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 mn = model.modelName || model.model_name || '-'
return `对话模型:${name}${provider ? provider + ' / ' : ''}${mn}`
}
return modelLoadError.value || '正在加载对话模型'
})
const ragStatusText = computed(() => {
if (!isRagMode.value) return '普通对话'
const names: Record<string, string> = {
NONE: 'RAG:不重写', REWRITE: 'RAG:查询重写',
TRANSLATION: 'RAG:翻译扩展', COMPRESSION: 'RAG:查询压缩',
MULTI_QUERY: 'RAG:多路扩展',
}
return names[ragStrategy.value] || 'RAG 已启用'
})
// ==================== ====================
function formatTime(): string {
return new Date().toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' })
}
function generateMsgId(): string {
return 'msg_' + Date.now() + '_' + Math.random().toString(36).substring(2, 8)
}
function newChatId(): void {
chatId.value = 'web_' + Date.now() + '_' + Math.random().toString(36).slice(2, 8)
}
function providerLabel(provider: string): string {
const map: Record<string, string> = {
dashscope: '通义千问', openai: 'OpenAI', volcengine: '豆包',
zhipu: '智谱', baidu: '百度千帆',
}
return map[String(provider || '').toLowerCase()] || provider || ''
}
// ==================== ====================
function selectRole(roleKey: string): void {
selectedRole.value = roleKey
newChatId()
const role = roles.value.find(r => r.key === roleKey) || FALLBACK_ROLE
messages.value = [{
id: generateMsgId(),
role: 'assistant',
content: `已切换到${role.name}。请直接输入你的问题。`,
streaming: false,
time: formatTime(),
}]
}
function currentRoleId(): string {
return currentRole.value && currentRole.value.id ? String(currentRole.value.id) : ''
}
// ==================== ====================
async function loadActiveModel(): Promise<void> {
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: any) {
activeModel.value = null
modelLoadError.value = e.message || '模型加载失败'
}
}
async function loadRoles(): Promise<void> {
try {
const json = await getRoleList()
if (json.success) {
roles.value = (json.data || []).map((role: any) => ({
id: role.id,
key: role.role_key || role.roleKey || String(role.id),
name: role.name,
categoryIds: (role.categoryIds || []).map(String),
}))
if (!roles.value.some(r => r.key === selectedRole.value)) {
selectedRole.value = roles.value[0]?.key || 'general'
}
}
} catch (e: any) {
toast('加载客服角色失败:' + e.message, 'error')
}
}
// ==================== ====================
async function scrollToBottom(): Promise<void> {
await nextTick()
if (msgAreaRef.value) {
msgAreaRef.value.scrollTop = msgAreaRef.value.scrollHeight
}
}
// ==================== ====================
function useQuickQuestion(question: string): void {
userInput.value = question
send()
}
// ==================== ====================
async function send(): Promise<void> {
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(),
})
// AI
const assistantMsg: ChatMessage = {
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
const roleId = currentRoleId()
try {
// SSE AbortController
if (sseAbortController) { sseAbortController.abort() }
sseAbortController = new AbortController()
const signal = sseAbortController.signal
if (isRagMode.value && mode.value === 'sync') {
// RAG
assistantMsg.content = await chatRagSync(text, cid, ragStrategy.value, roleId)
} else if (isRagMode.value) {
// RAG SSE
const url = chatRagSSEUrl(text, cid, ragStrategy.value, roleId)
await readSSEStreamWithEvents(url, {
onMessage: async (chunk: string) => {
assistantMsg.content += chunk
await scrollToBottom()
},
onToolCallStart: (data: any) => {
assistantMsg.toolCalls!.push({ tool: data.tool, input: data.input, status: 'running', result: null })
scrollToBottom()
},
onToolCallResult: (data: any) => {
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: any) => {
assistantMsg.content += '\n\n⚠️ ' + (data.message || '工具调用出错')
},
onDone: () => {},
}, undefined, signal)
} else if (mode.value === 'sync') {
//
assistantMsg.content = await chatSync(text, cid, roleId)
} else {
// SSE
const url = chatSSEUrl(text, cid, roleId)
await readSSEStream(url, async (chunk: string) => {
assistantMsg.content += chunk
await scrollToBottom()
}, () => {}, undefined, signal)
}
// 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: any) {
// AbortError
if (e.name === 'AbortError') {
assistantMsg.content = assistantMsg.content || '已取消'
} else {
assistantMsg.content = '请求失败:' + e.message
assistantMsg.error = true
toast('对话失败:' + e.message, 'error')
}
} finally {
assistantMsg.streaming = false
isSending.value = false
await scrollToBottom()
}
}
// ==================== ====================
function retryLast(): void {
if (!lastUserInput.value || isSending.value) return
userInput.value = lastUserInput.value
send()
}
function startEditMessage(i: number): void {
if (isSending.value) return
editingIndex.value = i
editingText.value = messages.value[i].content || ''
}
function cancelEdit(): void {
editingIndex.value = -1
editingText.value = ''
}
async function submitEdit(i: number): Promise<void> {
const text = editingText.value.trim()
if (!text || isSending.value) return
// userTurn1-based
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: any) {
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: number): Promise<void> {
if (isSending.value) return
//
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: any) { toast('重新生成失败:' + e.message, 'error'); return }
messages.value = messages.value.slice(0, userIndex)
userInput.value = text
await send()
}
async function copyMessage(content: string): Promise<void> {
try {
await navigator.clipboard.writeText(content)
toast('已复制回复', 'success')
} catch { toast('复制失败', 'error') }
}
// ==================== ====================
async function submitFeedback(msgId: string, type: 'up' | 'down'): Promise<void> {
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()
categoryStore.loadCategories()
loadRoles()
loadActiveModel()
})
// SSE
onBeforeUnmount(() => {
if (sseAbortController) {
sseAbortController.abort()
sseAbortController = null
}
})
</script>
<style scoped>
/* ==================== 布局 ==================== */
.chat-shell {
display: flex; height: calc(100vh - 48px); overflow: hidden;
}
.chat-sidebar {
width: 220px; background: var(--td-bg-color-page); border-right: 1px solid var(--td-border-level-1-color);
flex-shrink: 0; display: flex; flex-direction: column; overflow-y: auto; padding: 12px;
}
.chat-main {
flex: 1; display: flex; flex-direction: column; overflow: hidden; background: var(--td-bg-color-container);
}
/* ==================== 角色卡片 ==================== */
.agent-card {
display: flex; align-items: center; gap: 10px; padding: 0 0 12px 0;
border-bottom: 1px solid var(--td-border-level-1-color);
}
.agent-avatar {
width: 40px; height: 40px; border-radius: 10px; background: var(--td-brand-color);
color: #fff; display: flex; align-items: center; justify-content: center; font-weight: 700; font-size: 14px;
}
.agent-name { font-weight: 600; font-size: 14px; }
.agent-status { font-size: 12px; color: var(--td-success-color); display: flex; align-items: center; gap: 4px; }
.agent-status span { width: 6px; height: 6px; border-radius: 50%; background: var(--td-success-color); }
.side-label { font-size: 11px; color: var(--td-text-color-placeholder); text-transform: uppercase; margin: 12px 0 8px; letter-spacing: 1px; }
.side-section-grow { flex: 1; overflow-y: auto; }
.assistant-list { display: flex; flex-direction: column; gap: 6px; }
.assistant-card {
display: flex; align-items: center; gap: 8px; padding: 8px; border-radius: 8px;
cursor: pointer; transition: background .15s; border: 1px solid transparent;
}
.assistant-card:hover { background: var(--td-bg-color-component); }
.assistant-card.active { background: var(--td-brand-color-light); border-color: var(--td-brand-color); }
/* ==================== 顶栏 ==================== */
.chat-header {
display: flex; align-items: flex-start; justify-content: space-between;
padding: 12px 16px; border-bottom: 1px solid var(--td-border-level-1-color);
flex-wrap: wrap; gap: 8px;
}
.chat-title h2 { font-size: 16px; margin: 0 0 4px; }
.chat-subline { font-size: 12px; color: var(--td-text-color-placeholder); display: flex; align-items: center; gap: 4px; }
.rag-dot { width: 8px; height: 8px; border-radius: 50%; background: var(--td-text-color-disabled); flex-shrink: 0; }
.rag-dot.on { background: var(--td-success-color); }
.dot-sep { color: var(--td-border-level-2-color); }
.chat-actions { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
.model-chip {
display: flex; align-items: center; gap: 4px; font-size: 12px; background: var(--td-bg-color-component);
padding: 4px 10px; border-radius: 6px; white-space: nowrap;
}
.model-chip strong { color: var(--td-brand-color); max-width: 160px; overflow: hidden; text-overflow: ellipsis; }
.model-chip-error { background: var(--td-error-color-1); }
.model-chip-error strong { color: var(--td-error-color); }
.rag-toggle { display: flex; align-items: center; gap: 4px; font-size: 12px; cursor: pointer; }
.rag-strategy { width: 120px; }
.chat-mode { width: 100px; }
/* ==================== 快捷提问 ==================== */
.quick-row { display: flex; gap: 8px; padding: 10px 16px; flex-wrap: wrap; border-bottom: 1px solid var(--td-border-level-1-color); }
.quick-tag { cursor: pointer; }
/* ==================== 消息区 ==================== */
.msg-area { flex: 1; overflow-y: auto; padding: 16px; }
.msg { margin-bottom: 16px; }
.msg.user .msg-content { display: flex; flex-direction: column; align-items: flex-end; }
.msg.user .msg-bubble {
background: var(--td-brand-color); color: #fff; border-radius: 12px 12px 4px 12px;
padding: 10px 14px; max-width: 70%; font-size: 14px; line-height: 1.6; white-space: pre-wrap;
}
.msg.assistant .msg-bubble {
background: var(--td-bg-color-secondarycontainer); color: var(--td-text-color-primary); border-radius: 12px 12px 12px 4px;
padding: 12px 16px; max-width: 85%; font-size: 14px; line-height: 1.7;
}
.msg.streaming .msg-content::after { content: ''; }
.thinking { color: var(--td-text-color-placeholder); font-style: italic; animation: blink 1s infinite; }
@keyframes blink { 50% { opacity: 0.5; } }
/* Markdown 在气泡内 */
.msg-bubble :deep(h1) { font-size: 1.3em; margin: 12px 0 6px; }
.msg-bubble :deep(h2) { font-size: 1.15em; margin: 10px 0 4px; }
.msg-bubble :deep(h3) { font-size: 1.05em; margin: 8px 0 4px; }
.msg-bubble :deep(p) { margin: 4px 0; }
.msg-bubble :deep(ul), .msg-bubble :deep(ol) { padding-left: 20px; margin: 6px 0; }
.msg-bubble :deep(table) { border-collapse: collapse; margin: 8px 0; width: 100%; }
.msg-bubble :deep(th), .msg-bubble :deep(td) { border: 1px solid var(--td-border-level-2-color); padding: 6px 10px; text-align: left; font-size: 13px; }
.msg-bubble :deep(th) { background: var(--td-bg-color-secondarycontainer); font-weight: 600; }
.msg-bubble :deep(code) { background: rgba(0,0,0,.06); padding: 2px 6px; border-radius: 3px; font-size: 13px; }
.msg-bubble :deep(pre) { background: #1e1e1e; color: #d4d4d4; padding: 12px; border-radius: 8px; overflow-x: auto; }
.msg-bubble :deep(pre code) { background: none; padding: 0; color: inherit; }
/* ==================== MCP 工具调用 ==================== */
.mcp-tool-calls { margin-bottom: 8px; }
.mcp-tool-item { background: var(--td-bg-color-page); border: 1px solid var(--td-border-level-1-color); border-radius: 6px; padding: 6px 10px; margin-bottom: 4px; }
.mcp-tool-header { display: flex; align-items: center; gap: 6px; font-size: 13px; }
.mcp-tool-icon { font-size: 14px; }
.mcp-tool-name { font-weight: 600; color: var(--td-brand-color); }
.mcp-tool-latency { font-size: 11px; color: var(--td-text-color-placeholder); margin-left: auto; }
/* ==================== 消息操作 ==================== */
.msg-tools { display: flex; align-items: center; gap: 6px; margin-top: 6px; font-size: 12px; color: var(--td-text-color-placeholder); }
.msg-tools span { margin-right: auto; }
.edit-actions { display: flex; align-items: center; gap: 8px; margin-top: 8px; }
.edit-hint { font-size: 12px; color: var(--td-text-color-placeholder); }
/* ==================== 输入区 ==================== */
.chat-composer { padding: 12px 16px; border-top: 1px solid var(--td-border-level-1-color); }
.composer-box { display: flex; gap: 10px; align-items: flex-end; }
.composer-box :deep(.t-textarea__inner) { min-height: 44px; }
.send-btn { flex-shrink: 0; height: 44px; }
@media (max-width: 768px) {
.chat-sidebar { display: none; }
.chat-header { flex-direction: column; }
.chat-actions { flex-wrap: wrap; }
}
</style>

83
frontend/src/views/ConversationManager.vue

@ -0,0 +1,83 @@
<template>
<t-card title="💬 会话管理" :bordered="false">
<!-- 统计卡片 -->
<t-row :gutter="16" style="margin-bottom:16px;" v-if="stats">
<t-col :span="3"><t-card :bordered="true"><t-statistic title="总会话数" :value="stats.totalConversations||0" /></t-card></t-col>
<t-col :span="3"><t-card :bordered="true"><t-statistic title="总消息数" :value="stats.totalMessages||0" /></t-card></t-col>
<t-col :span="3"><t-card :bordered="true"><t-statistic title="今日会话" :value="stats.todayConversations||0" /></t-card></t-col>
<t-col :span="3"><t-card :bordered="true"><t-statistic title="今日消息" :value="stats.todayMessages||0" /></t-card></t-col>
</t-row>
<!-- 搜索栏 -->
<div class="toolbar">
<t-input v-model="keyword" placeholder="搜索会话ID/消息..." size="small" style="width:200px;" @enter="load(1)" />
<t-input v-model="accountFilter" placeholder="外部用户ID" size="small" style="width:140px;" @enter="load(1)" />
<t-select v-model="roleFilter" :options="roleOpts" placeholder="全部角色" size="small" style="width:140px;" clearable />
<t-button size="small" @click="load(1)">搜索</t-button>
<t-button variant="outline" size="small" @click="load()">刷新</t-button>
</div>
<t-table :data="conversations" :columns="columns" row-key="conversationId"
:pagination="{current:page,total:total,pageSize:pageSize,showJumper:true}" @page-change="onPageChange">
<template #op="{row}">
<t-space :size="4">
<t-button size="small" variant="text" @click="viewMessages(row.conversationId)">查看消息</t-button>
<t-button size="small" variant="text" theme="primary" @click="downloadExport(row.conversationId)">导出</t-button>
<t-button size="small" variant="text" theme="danger" @click="remove(row.conversationId)">删除</t-button>
</t-space>
</template>
</t-table>
<!-- 消息详情弹窗 -->
<t-dialog v-model:visible="msgModal.visible" header="会话消息详情" width="900px" :footer="false">
<p class="desc-text">会话ID: <code>{{ msgModal.conversationId }}</code><span v-if="msgModal.roleName"> · 角色{{ msgModal.roleName }}</span> · {{ msgModal.messages.length }} </p>
<div v-if="!msgModal.messages.length"><t-empty /></div>
<div v-else class="msg-list" style="max-height:500px;overflow-y:auto;">
<div v-for="msg in msgModal.messages" :key="msg.id" :class="['msg-item',msg.messageType==='USER'?'msg-user':msg.messageType==='ASSISTANT'?'msg-assistant':'msg-system']">
<div class="msg-meta">
<t-tag size="small" :theme="msg.messageType==='USER'?'primary':msg.messageType==='ASSISTANT'?'success':'default'" variant="light">{{ msg.messageType==='USER'?'用户':msg.messageType==='ASSISTANT'?'AI助手':'系统' }}</t-tag>
<span class="msg-time">{{ formatDate(msg.createTime) }}</span>
</div>
<div class="msg-body">{{ msg.content }}</div>
</div>
</div>
</t-dialog>
</t-card>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { listConversations, deleteConversation, getConversationMessages, getConversationStats, exportConversation } from '@/api/conversation'
import { getRoleList } from '@/api/role'
import { toast } from '@/utils/toast'
import { formatDate } from '@/utils/format'
import { useConfirm } from '@/composables/useConfirm'
const { confirm } = useConfirm()
const conversations=ref<any[]>([]);const page=ref(1);const pageSize=ref(10);const total=ref(0)
const keyword=ref('');const accountFilter=ref('');const roleFilter=ref('')
const roles=ref<any[]>([]);const stats=ref<any>(null)
const msgModal=ref({visible:false,conversationId:'',roleName:'',messages:[] as any[]})
const roleOpts=ref([{label:'全部角色',value:''}])
const columns=[{colKey:'conversationId',title:'会话ID',width:160,ellipsis:true},{colKey:'accountId',title:'外部用户ID',width:100},{colKey:'roleName',title:'角色',width:80},{colKey:'messageCount',title:'消息数',width:60},{colKey:'lastMessageTime',title:'最后消息时间',width:140,cell:(_:any,{row}:any)=>formatDate(row.lastMessageTime)},{colKey:'lastMessagePreview',title:'最后消息预览',width:250,ellipsis:true},{colKey:'op',title:'操作',width:200}]
onMounted(()=>{loadFilters();load();loadStats()})
async function load(p=1){page.value=p
try{const r=await listConversations(p,pageSize.value,keyword.value||undefined,accountFilter.value||undefined,roleFilter.value||undefined);if(r.success){conversations.value=r.data||[];total.value=r.total||0}else toast(r.message||'查询失败','error')}catch(e:any){toast('加载失败:'+e.message,'error')}}
function onPageChange(i:{current:number;pageSize:number}){pageSize.value=i.pageSize;load(i.current)}
async function loadFilters(){try{const r=await getRoleList();if(r.success){roles.value=r.data||[];roleOpts.value=[{label:'全部角色',value:''},...roles.value.map((ro:any)=>({label:ro.name,value:String(ro.id)}))]}}catch(e){console.error(e)}}
async function loadStats(){try{const r=await getConversationStats();if(r.success)stats.value=r.data}catch(e){console.error(e)}}
async function viewMessages(cid:string){try{const r=await getConversationMessages(cid);if(r.success){msgModal.value.conversationId=cid;msgModal.value.messages=r.data||[];const c=conversations.value.find(i=>i.conversationId===cid);msgModal.value.roleName=c?.roleName||'';msgModal.value.visible=true}else toast(r.message||'加载失败','error')}catch(e:any){toast('加载失败:'+e.message,'error')}}
async function remove(cid:string){if(!await confirm('确定删除?所有消息将被逻辑删除'))return
try{const r=await deleteConversation(cid);if(r.success){toast(`已删除 ${r.deletedMessages||0} 条消息`,'success');load(page.value);loadStats()}else toast(r.message||'删除失败','error')}catch(e:any){toast('删除失败:'+e.message,'error')}}
async function downloadExport(cid:string){try{const content=await exportConversation(cid);const b=new Blob([content],{type:'text/plain;charset=utf-8'});const u=URL.createObjectURL(b);const a=document.createElement('a');a.href=u;a.download=`conversation_${cid}.txt`;document.body.appendChild(a);a.click();document.body.removeChild(a);URL.revokeObjectURL(u);toast('导出成功','success')}catch(e:any){toast('导出失败:'+e.message,'error')}}
</script>
<style scoped>.msg-item{padding:12px;border-radius:8px;margin-bottom:12px;}.msg-user{background:#eff6ff;}.msg-assistant{background:#f9fafb;}.msg-system{background:#fef3c7;}.msg-meta{display:flex;align-items:center;gap:8px;margin-bottom:6px;}.msg-time{font-size:11px;color:#999;}.msg-body{font-size:13px;line-height:1.6;white-space:pre-wrap;word-break:break-word;}</style>

144
frontend/src/views/DashboardPanel.vue

@ -0,0 +1,144 @@
<template>
<div class="dashboard-panel">
<t-card title="" :bordered="false" header-bordered>
<template #title>
<div style="display:flex;align-items:center;justify-content:space-between;flex-wrap:wrap;gap:12px;">
<h2 style="margin:0;">📈 运营数据看板</h2>
<t-space size="small">
<t-button :variant="rangeDays === 7 ? 'base' : 'outline'" theme="primary" size="small" @click="setRange(7)">近7天</t-button>
<t-button :variant="rangeDays === 30 ? 'base' : 'outline'" theme="primary" size="small" @click="setRange(30)">近30天</t-button>
<t-divider layout="vertical" />
<t-date-picker v-model="customStart" size="small" style="width:140px;" />
<span style="color:var(--td-text-color-placeholder);"></span>
<t-date-picker v-model="customEnd" size="small" style="width:140px;" />
<t-button variant="outline" size="small" @click="loadCustomRange" :disabled="!customStart || !customEnd">查询</t-button>
</t-space>
</div>
</template>
<!-- 4 指标卡片 -->
<t-row :gutter="16" style="margin-bottom:16px;">
<t-col :span="6"><t-card :bordered="true"><t-statistic title="今日对话数" :value="overview.conversationCount ?? 0" /></t-card></t-col>
<t-col :span="6"><t-card :bordered="true"><t-statistic title="满意率" :value="formatPercent(overview.satisfactionRate)" unit="%" :color="satisfactionColor" /></t-card></t-col>
<t-col :span="6"><t-card :bordered="true"><t-statistic title="知识库命中率" :value="formatPercent(overview.ragHitRate)" unit="%" color="green" /></t-card></t-col>
<t-col :span="6"><t-card :bordered="true"><t-statistic title="平均响应时间" :value="overview.avgResponseTime ?? 0" unit="ms" /></t-card></t-col>
</t-row>
<!-- 图表 -->
<t-row :gutter="16" style="margin-bottom:16px;">
<t-col :span="6"><t-card title="📊 对话量趋势" :bordered="true"><div style="height:260px;"><canvas ref="conversationChartRef"></canvas></div></t-card></t-col>
<t-col :span="6"><t-card title="📊 满意度趋势" :bordered="true"><div style="height:260px;"><canvas ref="satisfactionChartRef"></canvas></div></t-card></t-col>
</t-row>
<!-- 知识库分析 -->
<t-row :gutter="16">
<t-col :span="6">
<t-card title="📚 文档命中排行 TOP-10" :bordered="true">
<t-table :data="topHitDocuments" :columns="hitColumns" row-key="idx" v-if="topHitDocuments.length" />
<t-empty v-else description="暂无数据" />
</t-card>
</t-col>
<t-col :span="6">
<t-card title="❓ 未命中问题收集" :bordered="true">
<div v-if="missQuestions.length" style="max-height:400px;overflow-y:auto;">
<div v-for="(item, idx) in missQuestions" :key="idx" style="padding:8px 12px;border-bottom:1px solid var(--td-border-level-1-color);font-size:13px;display:flex;align-items:center;gap:8px;">
<span style="color:var(--td-text-color-placeholder);flex:none;">{{ idx + 1 }}.</span>
<span style="flex:1;word-break:break-all;">{{ item.user_query }}</span>
<span v-if="item.last_time" style="color:var(--td-text-color-placeholder);font-size:11px;flex:none;">{{ formatDateShort(item.last_time) }}</span>
</div>
</div>
<t-empty v-else description="暂无数据" />
</t-card>
</t-col>
</t-row>
<t-loading v-if="loading" text="加载中..." style="text-align:center;padding:20px;" />
</t-card>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onBeforeUnmount, nextTick } from 'vue'
import { getDashboardOverview, getDashboardTrend, getDashboardKnowledge, getDashboardCustom } from '@/api/dashboard'
const loading = ref(false)
const rangeDays = ref(7)
const customStart = ref('')
const customEnd = ref('')
const overview = ref<any>({})
const trendData = ref<any[]>([])
const topHitDocuments = ref<any[]>([])
const missQuestions = ref<any[]>([])
let conversationChart: any = null
let satisfactionChart: any = null
const conversationChartRef = ref<HTMLCanvasElement | null>(null)
const satisfactionChartRef = ref<HTMLCanvasElement | null>(null)
const hitColumns = [
{ colKey: 'idx', title: '排名', width: 50, cell: (_h: any, { rowIndex }: any) => rowIndex + 1 },
{ colKey: 'document_title', title: '文档标题', ellipsis: true },
{ colKey: 'hit_count', title: '命中次数', width: 80, cell: (_h: any, { row }: any) => row.hit_count ?? row.cnt ?? 0 },
{ colKey: 'avg_score', title: '平均得分', width: 80, cell: (_h: any, { row }: any) => row.avg_score != null ? Number(row.avg_score).toFixed(3) : '-' },
]
const satisfactionColor = computed(() => {
const rate = overview.value.satisfactionRate || 0
if (rate >= 80) return 'green'
if (rate >= 60) return 'orange'
return 'red'
})
function formatPercent(v: any) { return v != null ? Number(v).toFixed(1) : '0.0' }
function formatDateShort(d: any) { if (!d) return ''; const dt = new Date(d); if (isNaN(dt.getTime())) return ''; return `${dt.getMonth() + 1}/${dt.getDate()}` }
async function setRange(days: number) { rangeDays.value = days; await loadTrend(days) }
async function loadOverview() { try { const r = await getDashboardOverview(); if (r.success) overview.value = r.data } catch { /* silent */ } }
async function loadTrend(days: number) {
try { const r = await getDashboardTrend(days); if (r.success) { trendData.value = r.data || []; await nextTick(); renderCharts() } } catch { /* */ }
}
async function loadCustomRange() {
if (!customStart.value || !customEnd.value) return; loading.value = true
try { const r = await getDashboardCustom(customStart.value, customEnd.value); if (r.success) { trendData.value = r.data || []; rangeDays.value = 0; await nextTick(); renderCharts() } } catch { /* */ }
finally { loading.value = false }
}
async function loadKnowledge() {
try { const r = await getDashboardKnowledge(); if (r.success) { topHitDocuments.value = r.data?.topHitDocuments || []; missQuestions.value = r.data?.missQuestions || [] } } catch { /* */ }
}
// 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: any) => { const d = new Date(s.snapshotDate || s.snapshot_date); return `${d.getMonth() + 1}/${d.getDate()}` })
if (conversationChart) conversationChart.destroy()
if (conversationChartRef.value) {
conversationChart = new Chart(conversationChartRef.value, {
type: 'line', data: { labels, datasets: [
{ label: '对话数', data: trendData.value.map((s: any) => s.conversationCount ?? s.conversation_count ?? 0), borderColor: '#3b82f6', backgroundColor: 'rgba(59,130,246,0.1)', fill: true, tension: 0.3, pointRadius: 4 },
{ label: '消息数', data: trendData.value.map((s: any) => s.messageCount ?? s.message_count ?? 0), borderColor: '#8b5cf6', backgroundColor: 'rgba(139,92,246,0.1)', fill: true, tension: 0.3, pointRadius: 4 }] },
options: { responsive: true, maintainAspectRatio: false, plugins: { legend: { position: 'bottom', labels: { boxWidth: 12, font: { size: 12 } } } }, scales: { y: { beginAtZero: true }, x: { grid: { display: false } } } },
})
}
if (satisfactionChart) satisfactionChart.destroy()
if (satisfactionChartRef.value) {
satisfactionChart = new Chart(satisfactionChartRef.value, {
type: 'line', data: { labels, datasets: [
{ label: '👍 有帮助', data: trendData.value.map((s: any) => s.thumbsUpCount ?? s.thumbs_up_count ?? 0), borderColor: '#10b981', backgroundColor: 'rgba(16,185,129,0.1)', fill: true, tension: 0.3, pointRadius: 4 },
{ label: '👎 没帮助', data: trendData.value.map((s: any) => s.thumbsDownCount ?? s.thumbs_down_count ?? 0), borderColor: '#ef4444', backgroundColor: 'rgba(239,68,68,0.1)', fill: true, tension: 0.3, pointRadius: 4 }] },
options: { responsive: true, maintainAspectRatio: false, plugins: { legend: { position: 'bottom', labels: { boxWidth: 12, font: { size: 12 } } } }, scales: { y: { beginAtZero: true }, x: { 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), loadKnowledge()]); loading.value = false })
onBeforeUnmount(() => destroyCharts())
</script>

130
frontend/src/views/DocDetail.vue

@ -0,0 +1,130 @@
<template>
<t-dialog :visible="visible" header="文档详情" width="900px" @close="$emit('close')">
<div v-if="!doc" class="empty-detail">文档数据为空无法显示详情</div>
<t-tabs v-else v-model="activeTab">
<t-tab-panel value="info" label="📋 基本信息">
<div class="info-grid">
<div class="info-item"><span class="info-label">标题</span><span class="info-val">{{ doc.title || '-' }}</span></div>
<div class="info-item"><span class="info-label">来源文件</span><span class="info-val">{{ doc.sourceName || '-' }}</span></div>
<div class="info-item"><span class="info-label">文件类型</span><span class="info-val">{{ doc.fileType || '-' }}</span></div>
<div class="info-item"><span class="info-label">文件大小</span><span class="info-val">{{ doc.fileSize != null ? formatFileSize(doc.fileSize) : '-' }}</span></div>
<div class="info-item"><span class="info-label">所属分类</span><span class="info-val">{{ categoryName || '未分类' }}</span></div>
<div class="info-item"><span class="info-label">分块数量</span><span class="info-val">{{ doc.chunkCount ?? 0 }}</span></div>
<div class="info-item"><span class="info-label">处理状态</span><span class="info-val"><t-tag size="small" :theme="statusTheme" variant="light">{{ statusLabel }}</t-tag></span></div>
<div class="info-item"><span class="info-label">启用状态</span><span class="info-val"><t-tag size="small" :theme="doc.enabled !== false ? 'success' : 'default'" variant="light">{{ doc.enabled !== false ? '已启用' : '已禁用' }}</t-tag></span></div>
<div class="info-item"><span class="info-label">创建时间</span><span class="info-val">{{ formatDate(doc.createTime) }}</span></div>
<div class="info-item"><span class="info-label">更新时间</span><span class="info-val">{{ formatDate(doc.updateTime) }}</span></div>
</div>
<div v-if="doc.tags && getTags().length" class="info-tags">
<span class="info-label">标签</span>
<t-tag v-for="t in getTags()" :key="t" size="small" variant="light" theme="primary" style="margin-right:4px;">{{ t }}</t-tag>
</div>
<div v-if="doc.errorMessage" class="info-error">
<span class="info-label">错误信息</span><span class="error-text">{{ doc.errorMessage }}</span>
</div>
</t-tab-panel>
<t-tab-panel value="content" label="📄 原文内容">
<div v-if="doc.content" class="raw-content">
<div class="raw-header">
<span class="raw-title">{{ doc.sourceName || doc.title || '原文' }}</span>
<t-tag v-if="doc.content && doc.content.length >= 2000" size="small" theme="warning" variant="light">内容已截断仅显示前2000字符</t-tag>
</div>
<pre class="raw-body">{{ doc.content }}</pre>
</div>
<t-empty v-else description="该文档无原文内容(纯文件上传,可点击下载查看)" />
</t-tab-panel>
<t-tab-panel value="chunks" label="🧩 分块列表">
<div v-if="chunksError" class="error-msg">{{ chunksError }}</div>
<div v-else-if="chunks.length" class="chunks-wrapper">
<div class="chunks-summary"> {{ chunks.length }} 个分块</div>
<div v-for="(chunk, idx) in chunks" :key="idx" class="chunk-card">
<div class="chunk-header">
<span class="chunk-badge">#{{ chunk.chunk_index ?? chunk.metadata?.chunkIndex ?? idx + 1 }}</span>
<span class="chunk-meta" v-if="chunk.metadata?.sourceName">来源{{ chunk.metadata.sourceName }}</span>
</div>
<div class="chunk-body" v-html="renderMarkdown(chunk.content || '')"></div>
<div class="chunk-footer">
<span class="chunk-len">{{ (chunk.content || '').length }} 字符</span>
</div>
</div>
</div>
<t-empty v-else description="暂无分块" />
</t-tab-panel>
</t-tabs>
</t-dialog>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { renderMarkdown } from '@/utils/markdown'
import { formatDate, formatBytes } from '@/utils/format'
import { useCategoryStore } from '@/stores/category'
const props = defineProps<{ visible: boolean; doc: any; chunks: any[]; chunksError: string }>()
defineEmits<{ close: [] }>()
const activeTab = ref('info')
const categoryStore = useCategoryStore()
const statusTheme = computed(() => {
const s = props.doc?.status
if (s === 'READY') return 'success'
if (s === 'PROCESSING') return 'warning'
if (s === 'FAILED') return 'danger'
return 'default'
})
const statusLabel = computed(() => {
const s = props.doc?.status
if (s === 'READY') return '已完成'
if (s === 'PROCESSING') return '处理中'
if (s === 'FAILED') return '失败'
return s || '-'
})
const categoryName = computed(() => {
if (!props.doc?.categoryId) return ''
return categoryStore.getCategoryName(props.doc.categoryId)
})
function formatFileSize(b: number) { return b != null ? formatBytes(b) : '-' }
function getTags(): string[] {
const t = props.doc?.tags
if (!t) return []
if (t.tags && Array.isArray(t.tags)) return t.tags
if (Array.isArray(t)) return t
return []
}
</script>
<style scoped>
/* 基本信息 */
.info-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 10px 24px; }
.info-item { display: flex; align-items: center; padding: 6px 0; border-bottom: 1px solid #f0f0f0; }
.info-label { width: 70px; flex: none; font-size: 13px; color: #999; }
.info-val { font-size: 13px; color: #333; word-break: break-all; }
.info-tags { margin-top: 12px; display: flex; align-items: center; }
.info-error { margin-top: 8px; display: flex; align-items: flex-start; gap: 8px; }
.error-text { color: var(--td-error-color, #e34d59); font-size: 12px; }
/* 原文 */
.raw-content { background: #fafafa; border: 1px solid #e5e7eb; border-radius: 8px; overflow: hidden; }
.raw-header { display: flex; align-items: center; justify-content: space-between; padding: 10px 14px; background: #f3f4f6; border-bottom: 1px solid #e5e7eb; }
.raw-title { font-weight: 600; font-size: 13px; }
.raw-body { padding: 14px; margin: 0; font-size: 13px; line-height: 1.8; white-space: pre-wrap; word-break: break-word; max-height: 500px; overflow-y: auto; color: #374151; }
/* 分块列表 */
.chunks-wrapper { max-height: 520px; overflow-y: auto; }
.chunks-summary { font-size: 12px; color: #999; margin-bottom: 10px; }
.chunk-card { background: #fafbfc; border: 1px solid #e5e7eb; border-radius: 8px; padding: 12px 14px; margin-bottom: 10px; transition: border-color .2s; }
.chunk-card:hover { border-color: var(--td-brand-color, #0052d9); }
.chunk-header { display: flex; align-items: center; gap: 8px; margin-bottom: 8px; }
.chunk-badge { display: inline-block; background: var(--td-brand-color, #0052d9); color: #fff; font-size: 12px; font-weight: 600; padding: 2px 10px; border-radius: 10px; }
.chunk-meta { font-size: 11px; color: #999; }
.chunk-body { font-size: 13px; line-height: 1.75; color: #374151; word-break: break-word; }
.chunk-footer { margin-top: 8px; padding-top: 6px; border-top: 1px dashed #e5e7eb; display: flex; justify-content: flex-end; }
.chunk-len { font-size: 11px; color: #bbb; }
.error-msg { color: var(--td-error-color, #e34d59); padding: 8px 0; }
.empty-detail { color: #999; padding: 40px; text-align: center; }
</style>

264
frontend/src/views/DocList.vue

@ -0,0 +1,264 @@
<template>
<t-card title="文档列表" :bordered="false">
<!-- 工具栏搜索 + 筛选 + 批量操作 -->
<div class="toolbar">
<t-input v-model="keyword" placeholder="搜索文档标题或文件名..." clearable @change="debouncedLoad" style="max-width:260px;" size="small" />
<t-select v-model="filterCategory" :options="categoryOptions" placeholder="全部分类" clearable size="small" style="width:140px;" @change="load()" />
<t-select v-model="filterStatus" :options="statusOptions" placeholder="全部状态" clearable size="small" style="width:110px;" @change="load()" />
<t-select v-model="filterTag" :options="tagOptions" placeholder="全部标签" clearable size="small" style="width:140px;" @change="load()" />
<t-button variant="outline" size="small" @click="load()">刷新</t-button>
<t-tag v-if="hasProcessing" theme="warning" size="small"> 有文档处理中...</t-tag>
<template v-if="selectedIds.size > 0">
<t-divider layout="vertical" />
<span class="selected-count">已选 {{ selectedIds.size }} </span>
<t-button theme="danger" size="small" @click="batchRemove">批量删除</t-button>
<t-button variant="outline" size="small" @click="batchReprocess">批量重新处理</t-button>
<t-button variant="outline" size="small" @click="batchEnable">批量启用</t-button>
<t-button variant="outline" size="small" @click="batchDisable">批量禁用</t-button>
<t-select v-model="moveCategoryId" :options="moveCategoryOptions" placeholder="移动到分类..." size="small" style="width:140px;" />
<t-button variant="outline" size="small" @click="batchMove" :disabled="moveCategoryId === ''">移动</t-button>
<t-button variant="text" size="small" @click="clearSelection">取消选择</t-button>
</template>
</div>
<!-- 表格 -->
<t-table
:data="documents" :columns="columns" row-key="id" :loading="loading"
:pagination="{ current: page, total: total, pageSize: pageSize, showJumper: true }"
:selected-row-keys="Array.from(selectedIds)"
@page-change="onPageChange"
@select-change="onSelectChange"
>
<template #title="{ row }">
<div><strong>{{ row.title }}</strong></div>
<div style="font-size:11px;color:var(--td-text-color-placeholder);">{{ row.sourceName || '' }}</div>
<div v-if="getDocTags(row).length" style="margin-top:2px;">
<t-tag v-for="tag in getDocTags(row)" :key="tag" size="small" variant="light" theme="primary" style="margin-right:2px;">{{ tag }}</t-tag>
</div>
</template>
<template #fileType="{ row }"><t-tag size="small" variant="light">{{ row.fileType }}</t-tag></template>
<template #fileSize="{ row }"><span style="white-space:nowrap;">{{ formatSize(row.fileSize) }}</span></template>
<template #categoryId="{ row }"><span>{{ categoryStore.getCategoryName(row.categoryId) }}</span></template>
<template #status="{ row }">
<t-tag size="small" :theme="row.status === 'READY' ? 'success' : row.status === 'PROCESSING' ? 'warning' : 'danger'"
variant="light">{{ statusLabel(row.status) }}</t-tag>
</template>
<template #enabled="{ row }">
<t-switch v-model="row._enabled" size="small" @change="toggle(row)" />
</template>
<template #op="{ row }">
<t-space :size="2">
<t-button size="small" variant="text" @click="viewDetail(row.id)">查看</t-button>
<t-button v-if="row.filePath" size="small" variant="text" @click="download(row.id)">下载</t-button>
<t-button size="small" variant="text" @click="reprocess(row.id)">重新处理</t-button>
<t-button size="small" variant="text" theme="danger" @click="remove(row.id)">删除</t-button>
</t-space>
</template>
</t-table>
</t-card>
</template>
<script setup lang="ts">
import { ref, computed, onUnmounted } from 'vue'
import { useDocumentStore } from '@/stores/document'
import { useCategoryStore } from '@/stores/category'
import { useDialogStore } from '@/stores/dialog'
import { listDocuments, deleteDocument, reprocessDocument, batchDeleteDocuments, batchReprocessDocuments, toggleDocument, batchToggleDocuments, batchMoveDocuments, downloadDocument } from '@/api/document'
import { toast } from '@/utils/toast'
import { formatBytes, formatDate } from '@/utils/format'
import { useConfirm } from '@/composables/useConfirm'
import { useDebounce } from '@/composables/useDebounce'
const { confirm } = useConfirm()
const { debounce } = useDebounce()
const documentStore = useDocumentStore()
const categoryStore = useCategoryStore()
const dialogStore = useDialogStore()
// ==================== ====================
const documents = ref<any[]>([])
const page = ref(1)
const pageSize = ref(10)
const total = ref(0)
const loading = ref(false)
const filterCategory = ref('')
const filterStatus = ref('')
const keyword = ref('')
const filterTag = ref('')
const selectedIds = ref(new Set<string>())
const moveCategoryId = ref('')
const hasProcessing = ref(false)
// ==================== ====================
let pollTimer: any = null
function startPolling() {
stopPolling()
hasProcessing.value = true
pollTimer = setInterval(async () => {
await loadData(page.value)
if (!documents.value.some(d => d.status === 'PROCESSING' || d.status === 'processing')) {
stopPolling()
documentStore.loadStats()
toast('所有文档处理完成', 'success')
}
}, 2000)
}
function stopPolling() {
hasProcessing.value = false
if (pollTimer) { clearInterval(pollTimer); pollTimer = null }
}
onUnmounted(() => { stopPolling() })
// ==================== ====================
const columns = [
{ colKey: 'row-select', type: 'multiple', width: 50 },
{ colKey: 'title', title: '标题', width: 220 },
{ colKey: 'fileType', title: '类型', width: 70 },
{ colKey: 'fileSize', title: '大小', width: 80 },
{ colKey: 'categoryId', title: '分类', width: 100 },
{ colKey: 'status', title: '状态', width: 80 },
{ colKey: 'enabled', title: '启用', width: 60 },
{ colKey: 'chunkCount', title: '分块', width: 60 },
{ colKey: 'createTime', title: '创建时间', width: 160, cell: (_:any,{row}:any) => formatDate(row.createTime) },
{ colKey: 'op', title: '操作', width: 200 },
]
// ==================== ====================
const categoryOptions = computed(() => [{ label: '全部分类', value: '' }, ...categoryStore.categories.map(c => ({ label: c.name, value: String(c.id) }))])
const statusOptions = [{ label: '全部状态', value: '' }, { label: '已完成', value: 'READY' }, { label: '处理中', value: 'PROCESSING' }, { label: '失败', value: 'FAILED' }]
const tagOptions = computed(() => [{ label: '全部标签', value: '' }, ...(documentStore.tags || []).map((t: any) => ({ label: `${t.tag} (${t.count})`, value: t.tag }))])
const moveCategoryOptions = computed(() => [{ label: '移动到分类...', value: '' }, ...categoryStore.categories.map(c => ({ label: c.name, value: String(c.id) })), { label: '未分类', value: '0' }])
// ==================== ====================
function getDocTags(doc: any): string[] {
if (!doc.tags) return []
if (doc.tags.tags && Array.isArray(doc.tags.tags)) return doc.tags.tags
if (Array.isArray(doc.tags)) return doc.tags
return []
}
function statusLabel(s: string) { return s === 'READY' ? '已完成' : s === 'PROCESSING' ? '处理中' : s === 'FAILED' ? '失败' : s }
function formatSize(b: number) { return b ? formatBytes(b) : '-' }
// ==================== ====================
const debouncedLoad = debounce(() => load(1))
// ==================== ====================
async function loadData(p = page.value) {
page.value = p
loading.value = true
try {
const json = await listDocuments(p, pageSize.value, filterCategory.value || undefined, filterStatus.value || undefined, keyword.value.trim() || undefined, filterTag.value || undefined)
if (json.success) {
documents.value = (json.data || []).map((d: any) => ({ ...d, _enabled: d.enabled !== false }))
total.value = json.total || 0
if (documents.value.some((d: any) => d.status === 'PROCESSING' || d.status === 'processing') && !pollTimer) startPolling()
} else toast(json.message || '查询失败', 'error')
} catch (e: any) { toast('加载文档失败:' + e.message, 'error') }
finally { loading.value = false }
}
function load(p = 1) { selectedIds.value = new Set(); loadData(p) }
function onPageChange(info: { current: number; pageSize: number }) {
pageSize.value = info.pageSize
load(info.current)
}
function onSelectChange(keys: (string | number)[]) { selectedIds.value = new Set(keys.map(String)) }
// ==================== ====================
function clearSelection() { selectedIds.value = new Set() }
function viewDetail(id: string) { dialogStore.openDetail(id) }
function download(id: string) { downloadDocument(id) }
async function toggle(doc: any) {
try {
const json = await toggleDocument(doc.id)
if (json.success) { doc.enabled = json.data.enabled; doc._enabled = json.data.enabled; toast(json.message, 'success') }
else { toast(json.message || '操作失败', 'error'); loadData() }
} catch (e: any) { toast('操作失败:' + e.message, 'error'); loadData() }
}
async function remove(id: string) {
if (!await confirm('确定删除此文档?关联的向量也将被删除')) return
try {
const json = await deleteDocument(id)
if (json.success) { toast(`已删除,连带删除 ${json.deletedVectors || 0} 个向量`, 'success'); loadData(); documentStore.loadStats() }
else toast(json.message || '删除失败', 'error')
} catch (e: any) { toast('删除失败:' + e.message, 'error') }
}
async function reprocess(id: string) {
if (!await confirm('确定重新处理此文档?')) return
try {
const json = await reprocessDocument(id)
if (json.success) { toast(json.message || '已提交重新处理', 'success'); loadData(); if (!pollTimer) startPolling() }
else toast(json.message || '重新处理失败', 'error')
} catch (e: any) { toast('重新处理失败:' + e.message, 'error') }
}
async function batchRemove() {
const ids = Array.from(selectedIds.value)
if (!await confirm(`确定删除选中的 ${ids.length} 个文档?`)) return
try {
const json = await batchDeleteDocuments(ids)
if (json.success) { toast(json.message, 'success'); selectedIds.value = new Set(); loadData(); documentStore.loadStats() }
else toast(json.message || '批量删除失败', 'error')
} catch (e: any) { toast('批量删除失败:' + e.message, 'error') }
}
async function batchReprocess() {
const ids = Array.from(selectedIds.value)
if (!await confirm(`确定重新处理选中的 ${ids.length} 个文档?`)) return
try {
const json = await batchReprocessDocuments(ids)
if (json.success) { toast(json.message, 'success'); selectedIds.value = new Set(); loadData(); if (!pollTimer) startPolling() }
else toast(json.message || '批量重新处理失败', 'error')
} catch (e: any) { toast('批量重新处理失败:' + e.message, 'error') }
}
async function batchEnable() {
const ids = Array.from(selectedIds.value)
if (!await confirm(`确定启用选中的 ${ids.length} 个文档?`)) return
try {
const json = await batchToggleDocuments(ids, true)
if (json.success) { toast(json.message, 'success'); selectedIds.value = new Set(); loadData() }
else toast(json.message || '批量启用失败', 'error')
} catch (e: any) { toast('批量启用失败:' + e.message, 'error') }
}
async function batchDisable() {
const ids = Array.from(selectedIds.value)
if (!await confirm(`确定禁用选中的 ${ids.length} 个文档?禁用后不参与 RAG 检索`)) return
try {
const json = await batchToggleDocuments(ids, false)
if (json.success) { toast(json.message, 'success'); selectedIds.value = new Set(); loadData() }
else toast(json.message || '批量禁用失败', 'error')
} catch (e: any) { toast('批量禁用失败:' + e.message, 'error') }
}
async function batchMove() {
const ids = Array.from(selectedIds.value)
const catId = moveCategoryId.value
if (!catId && catId !== '0') { toast('请选择目标分类', 'error'); return }
const catName = catId === '0' ? '未分类' : categoryStore.getCategoryName(catId)
if (!await confirm(`确定将选中的 ${ids.length} 个文档移动到「${catName}」?`)) return
try {
// ID 0 0
const json = await batchMoveDocuments(ids, catId)
if (json.success) { toast(json.message, 'success'); selectedIds.value = new Set(); moveCategoryId.value = ''; loadData(); documentStore.loadStats() }
else toast(json.message || '批量移动失败', 'error')
} catch (e: any) { toast('批量移动失败:' + e.message, 'error') }
}
//
loadData()
</script>
<style scoped>
.selected-count { font-size: 12px; font-weight: 600; color: var(--td-text-color-primary); }
</style>

33
frontend/src/views/DocSearch.vue

@ -0,0 +1,33 @@
<template>
<t-card title="搜索测试" :bordered="false">
<div class="toolbar">
<t-input v-model="query" placeholder="输入搜索关键词" size="small" style="width:300px;" @enter="doSearch" />
<t-select v-model="searchMode" :options="modeOpts" size="small" style="width:120px;" />
<t-input-number v-model="topK" :min="1" :max="50" size="small" style="width:80px;" placeholder="Top K" />
<t-button theme="primary" size="small" @click="doSearch">搜索</t-button>
</div>
<t-card title="搜索结果" :bordered="true" v-for="(chunk, idx) in results" :key="idx" style="margin-bottom:8px;">
<t-tag size="small" theme="primary" variant="light">#{{ idx+1 }} · {{ chunk.metadata?.sourceName||chunk.documentId }}</t-tag>
<span style="font-size:11px;color:#999;margin-left:8px;">相似度: {{ chunk.similarity?.toFixed(4)||'-' }}</span>
<div class="chunk-content" v-html="renderMarkdown(chunk.content?.substring(0,500)||'')" />
</t-card>
<t-empty v-if="results.length===0&&searched" description="无结果" />
</t-card>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { searchDocuments } from '@/api/document'
import { toast } from '@/utils/toast'
import { renderMarkdown } from '@/utils/markdown'
const query=ref(''); const searchMode=ref('VECTOR'); const topK=ref(5)
const results=ref<any[]>([]); const searched=ref(false)
const modeOpts=[{label:'向量',value:'VECTOR'},{label:'关键词',value:'KEYWORD'},{label:'混合',value:'HYBRID'}]
async function doSearch(){if(!query.value.trim())return
try{const r=await searchDocuments(query.value,topK.value,undefined,undefined,undefined,searchMode.value);if(r.success){results.value=r.data||[];searched.value=true}else toast(r.message||'搜索失败','error')}catch(e:any){toast('搜索失败:'+e.message,'error')}}
</script>
<style scoped>.toolbar{display:flex;gap:8px;align-items:center;margin-bottom:16px;flex-wrap:wrap;}.chunk-content{font-size:13px;line-height:1.6;margin-top:8px;}</style>

19
frontend/src/views/DocStats.vue

@ -0,0 +1,19 @@
<template>
<t-card title="统计概览" :bordered="false">
<t-row :gutter="16">
<t-col :span="4"><t-card :bordered="true"><t-statistic title="文档总数" :value="stats?.totalDocuments||0" /></t-card></t-col>
<t-col :span="4"><t-card :bordered="true"><t-statistic title="分类数" :value="stats?.totalCategories||0" /></t-card></t-col>
<t-col :span="4"><t-card :bordered="true"><t-statistic title="分块总数" :value="stats?.totalChunks||0" /></t-card></t-col>
<t-col :span="4"><t-card :bordered="true"><t-statistic title="处理中" :value="stats?.processingCount||0" :color="stats?.processingCount?'orange':'default'" /></t-card></t-col>
<t-col :span="4"><t-card :bordered="true"><t-statistic title="失败" :value="stats?.failedCount||0" :color="stats?.failedCount?'red':'default'" /></t-card></t-col>
<t-col :span="4"><t-card :bordered="true"><t-statistic title="FAQ 总数" :value="stats?.faqCount||0" /></t-card></t-col>
</t-row>
</t-card>
</template>
<script setup lang="ts">
import { onMounted } from 'vue'
import { useDocumentStore } from '@/stores/document'
const { stats, loadStats } = useDocumentStore()
onMounted(()=>{loadStats()})
</script>

147
frontend/src/views/DocUpload.vue

@ -0,0 +1,147 @@
<template>
<t-card title="文档上传" :bordered="false">
<p style="color:#999;font-size:13px;margin-bottom:16px;">上传文档到 RAG 知识库自动分词 向量化 存入 PGVector</p>
<!-- 上传元信息 -->
<div class="upload-meta">
<t-select v-model="uploadCategory" :options="categoryOptions" placeholder="选择分类(可选)" clearable size="small" style="width:200px;" />
<div class="tag-input-wrapper">
<t-tag v-for="(tag, idx) in tagList" :key="idx" closable size="small" @close="removeTag(idx)">{{ tag }}</t-tag>
<t-input v-model="tagInputValue" placeholder="输入标签,回车添加" size="small" borderless
@keydown.enter.prevent="addTag()" @keydown.backspace="handleTagBackspace" />
</div>
</div>
<!-- 上传模式 Tab -->
<t-tabs v-model="uploadMode">
<t-tab-panel value="file" label="📄 文件上传">
<t-upload :auto-upload="false" theme="file-flow" :before-upload="beforeFileUpload"
:request-method="handleFileUpload" accept=".pdf,.doc,.docx,.xls,.xlsx,.ppt,.pptx,.txt,.md,.json,.csv,.html,.xml,.rtf"
:size-limit="{ size: 50, unit: 'MB', message: '文件大小不能超过 50MB' }" />
</t-tab-panel>
<t-tab-panel value="text" label="📝 文本">
<t-textarea v-model="textContent" placeholder="粘贴文本内容..." :autosize="{ minRows: 6, maxRows: 12 }" />
<t-input v-model="textTitle" placeholder="文本标题" style="margin-top:8px;" />
<t-button theme="primary" @click="uploadText" :loading="uploading" style="margin-top:8px;">上传文本</t-button>
</t-tab-panel>
<t-tab-panel value="markdown" label="📋 Markdown">
<t-upload :auto-upload="false" theme="file" accept=".md" :request-method="handleMdUpload" />
</t-tab-panel>
<t-tab-panel value="json" label="📊 JSON">
<t-upload :auto-upload="false" theme="file" accept=".json" :request-method="handleJsonUpload" />
</t-tab-panel>
</t-tabs>
</t-card>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { useCategoryStore } from '@/stores/category'
import { useDocumentStore } from '@/stores/document'
import { uploadFile, uploadMarkdown } from '@/api/upload'
import { toast } from '@/utils/toast'
const categoryStore = useCategoryStore()
const documentStore = useDocumentStore()
const uploadMode = ref('file')
const uploadCategory = ref('')
const uploading = ref(false)
//
const tagList = ref<string[]>([])
const tagInputValue = ref('')
//
const textContent = ref('')
const textTitle = ref('')
const categoryOptions = computed(() => [{ label: '选择分类(可选)', value: '' }, ...categoryStore.categories.map(c => ({ label: c.name, value: String(c.id) }))])
const ALLOWED_EXT = new Set(['pdf', 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx', 'txt', 'md', 'json', 'csv', 'html', 'xml', 'rtf'])
function addTag() {
const v = tagInputValue.value.trim()
if (v && !tagList.value.includes(v)) { tagList.value.push(v) }
tagInputValue.value = ''
}
function removeTag(idx: number) { tagList.value.splice(idx, 1) }
function handleTagBackspace() {
if (!tagInputValue.value && tagList.value.length > 0) removeTag(tagList.value.length - 1)
}
function buildFormData(file: File): FormData {
const fd = new FormData()
fd.append('file', file)
if (uploadCategory.value) fd.append('categoryId', uploadCategory.value)
if (tagList.value.length) fd.append('tags', tagList.value.join(','))
return fd
}
function beforeFileUpload(file: File): boolean {
const ext = file.name.split('.').pop()?.toLowerCase() || ''
if (!ALLOWED_EXT.has(ext)) { toast(`不支持的文件类型 .${ext}`, 'error'); return false }
if (file.size > 50 * 1024 * 1024) { toast('文件大小不能超过 50MB', 'error'); return false }
return true
}
async function handleFileUpload(req: { file: File; onProgress?: (e: { percent: number }) => void; onSuccess: () => void; onError: () => void }) {
try {
const fd = buildFormData(req.file)
await uploadFile(fd, (pct) => { if (req.onProgress) req.onProgress({ percent: pct }) })
req.onSuccess()
toast('上传成功,文档正在处理中', 'success')
documentStore.loadStats()
} catch (e: any) { req.onError(); toast('上传失败:' + e.message, 'error') }
}
async function handleMdUpload(req: { file: File; onProgress?: (e: { percent: number }) => void; onSuccess: () => void; onError: () => void }) {
try {
const fd = buildFormData(req.file)
await uploadMarkdown(fd, (pct) => { if (req.onProgress) req.onProgress({ percent: pct }) })
req.onSuccess()
toast('Markdown 上传成功', 'success')
documentStore.loadStats()
} catch (e: any) { req.onError(); toast('上传失败:' + e.message, 'error') }
}
async function handleJsonUpload(req: { file: File; onProgress?: (e: { percent: number }) => void; onSuccess: () => void; onError: () => void }) {
// JSON 使 fetch
try {
const fd = buildFormData(req.file)
const xhr = new XMLHttpRequest()
xhr.open('POST', '/upload/json/basic')
const token = localStorage.getItem('sb_token') || ''
if (token) xhr.setRequestHeader('Authorization', 'Bearer ' + token)
xhr.upload.addEventListener('progress', (e) => {
if (e.lengthComputable && req.onProgress) req.onProgress({ percent: Math.round((e.loaded / e.total) * 100) })
})
xhr.addEventListener('load', () => { req.onSuccess(); toast('JSON 上传成功', 'success'); documentStore.loadStats() })
xhr.addEventListener('error', () => { req.onError(); toast('上传失败', 'error') })
xhr.send(fd)
} catch (e: any) { req.onError(); toast('上传失败:' + e.message, 'error') }
}
async function uploadText() {
if (!textContent.value.trim()) { toast('请输入文本内容', 'error'); return }
const token = localStorage.getItem('sb_token') || ''
let url = `/upload/string?title=${encodeURIComponent(textTitle.value || '未命名文本')}`
if (uploadCategory.value) url += `&categoryId=${uploadCategory.value}`
if (tagList.value.length) url += `&tags=${encodeURIComponent(tagList.value.join(','))}`
uploading.value = true
try {
const res = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'text/plain', ...(token ? { Authorization: 'Bearer ' + token } : {}) }, body: textContent.value })
const json = await res.json()
if (json.success) { toast('文本上传成功', 'success'); textContent.value = ''; textTitle.value = ''; documentStore.loadStats() }
else toast(json.message || '上传失败', 'error')
} catch (e: any) { toast('上传失败:' + e.message, 'error') }
finally { uploading.value = false }
}
</script>
<style scoped>
.upload-meta { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; padding: 12px; background: #fafafa; border-radius: 8px; border: 1px solid #e7e7e7; margin-bottom: 16px; }
.tag-input-wrapper { display: flex; flex-wrap: wrap; gap: 4px; align-items: center; flex: 1; padding: 4px 8px; border: 1px solid var(--td-border-level-1-color, #dcdcdc); border-radius: 4px; background: #fff; }
</style>

157
frontend/src/views/FaqManager.vue

@ -0,0 +1,157 @@
<template>
<t-card title="❓ FAQ 精准匹配管理" :bordered="false">
<!-- 操作栏 -->
<div class="toolbar">
<t-button theme="primary" size="small" @click="openAddDialog"> 添加 FAQ</t-button>
<t-button variant="outline" size="small" @click="showImportDialog = true">📥 批量导入</t-button>
<t-button variant="outline" size="small" @click="doExport">📤 导出</t-button>
<div style="flex:1;" />
<t-input v-model="searchKeyword" placeholder="搜索问题..." clearable size="small" style="width:200px;" @change="debouncedSearch" />
<t-select v-model="filterCategoryId" :options="categoryOptions" placeholder="全部分类" clearable size="small" style="width:180px;" @change="loadList" />
<t-select v-model="filterStatus" :options="statusOptions" placeholder="全部状态" clearable size="small" style="width:100px;" @change="loadList" />
</div>
<!-- 统计 -->
<div v-if="stats" class="stats-bar">
<t-tag variant="light">总计: {{ stats.totalCount || 0 }}</t-tag>
<t-tag variant="light" theme="success">启用: {{ stats.enabledCount || 0 }}</t-tag>
<t-tag variant="light" theme="primary">总命中: {{ stats.totalHitCount || stats.totalHits || 0 }}</t-tag>
</div>
<!-- FAQ 表格 -->
<t-table :data="faqs" :columns="columns" row-key="id" :loading="loading"
:pagination="{ current: page, total: total, pageSize: pageSize, showJumper: true }" @page-change="onPageChange">
<template #status="{ row }">
<span @click="toggleStatus(row)" style="cursor:pointer;" :style="{color: row.status === 'ENABLED' ? 'var(--td-success-color)' : 'var(--td-error-color)'}">
{{ row.status === 'ENABLED' ? '✅ 启用' : '❌ 禁用' }}
</span>
</template>
<template #op="{ row }">
<t-space :size="4">
<t-button size="small" variant="text" @click="openEditDialog(row)">编辑</t-button>
<t-button size="small" variant="text" theme="danger" @click="removeFaq(row.id)">删除</t-button>
</t-space>
</template>
</t-table>
<!-- 新增/编辑弹窗 -->
<t-dialog v-model:visible="showFormDialog" :header="editingFaq ? '编辑 FAQ' : '添加 FAQ'" width="720px" :footer="false">
<t-form label-align="top">
<t-form-item label="问题 *"><t-input v-model="form.question" placeholder="输入标准问题" /></t-form-item>
<t-form-item label="标准答案 *"><t-textarea v-model="form.answer" :autosize="{ minRows: 3, maxRows: 8 }" placeholder="输入标准答案" /></t-form-item>
<t-form-item label="相似问题(每行一个)"><t-textarea v-model="form.similarQuestionsText" :autosize="{ minRows: 3, maxRows: 5 }" placeholder="每行一个相似问法" /></t-form-item>
<t-row :gutter="16">
<t-col :span="6">
<t-form-item label="分类"><t-select v-model="form.categoryId" :options="categoryOptions2" placeholder="不分类" clearable /></t-form-item>
</t-col>
<t-col :span="6">
<t-form-item label="优先级"><t-input-number v-model="form.priority" :min="0" style="width:100%;" /></t-form-item>
</t-col>
</t-row>
</t-form>
<div class="dialog-footer">
<t-button variant="outline" @click="showFormDialog = false">取消</t-button>
<t-button theme="primary" @click="saveFaq" :disabled="!form.question||!form.answer">保存</t-button>
</div>
</t-dialog>
<!-- 批量导入弹窗 -->
<t-dialog v-model:visible="showImportDialog" header="批量导入 FAQ" width="660px" :footer="false">
<p class="desc-text">使用 JSON 格式批量导入每条包含 questionanswersimilarQuestions可选数组</p>
<t-textarea v-model="importJson" :autosize="{ minRows: 6, maxRows: 12 }" placeholder='[{"question":"退货流程","answer":"请先在订单页面..."}]' />
<div class="dialog-footer">
<t-button variant="outline" @click="showImportDialog = false">取消</t-button>
<t-button theme="primary" @click="doImport" :disabled="!importJson.trim()">导入</t-button>
</div>
</t-dialog>
</t-card>
</template>
<script setup lang="ts">
import { ref, onMounted, computed } from 'vue'
import { listFaqs, createFaq, updateFaq, deleteFaq, toggleFaqStatus, batchImportFaqs, exportFaqs, getFaqStats } from '@/api/faq'
import { getCategoryTree } from '@/api/category'
import { toast } from '@/utils/toast'
import { useConfirm } from '@/composables/useConfirm'
import { useDebounce } from '@/composables/useDebounce'
const { confirm } = useConfirm()
const { debounce } = useDebounce()
const faqs = ref<any[]>([])
const loading = ref(false)
const page = ref(1); const pageSize = ref(20); const total = ref(0)
const searchKeyword = ref(''); const filterCategoryId = ref(''); const filterStatus = ref('')
const stats = ref<any>(null)
const showFormDialog = ref(false); const showImportDialog = ref(false); const editingFaq = ref<any>(null)
const form = ref({ question: '', answer: '', similarQuestionsText: '', categoryId: null as any, priority: 0 })
const importJson = ref('')
const flatCategories = ref<any[]>([]); const categoryMap = ref<Record<string, string>>({})
const columns = [
{ colKey: 'question', title: '问题', width: 200, ellipsis: true },
{ colKey: 'answer', title: '答案(摘要)', width: 200, ellipsis: true },
{ colKey: 'categoryName', title: '分类', width: 100, cell: (_h:any,{row}:any)=>categoryMap.value[row.categoryId]||'-' },
{ colKey: 'priority', title: '优先级', width: 70 },
{ colKey: 'hitCount', title: '命中', width: 60 },
{ colKey: 'status', title: '状态', width: 90 },
{ colKey: 'op', title: '操作', width: 120 },
]
const statusOptions = [{label:'全部状态',value:''},{label:'启用',value:'ENABLED'},{label:'禁用',value:'DISABLED'}]
const categoryOptions = computed(()=>[{label:'全部分类',value:''},...flatCategories.value.map(c=>({label:c.path,value:String(c.id)}))])
const categoryOptions2 = computed(()=>[{label:'不分类',value:null as any},...flatCategories.value.map(c=>({label:c.path,value:c.id}))])
onMounted(() => { loadCategories(); loadList(); loadStats() })
async function loadCategories() {
try { const r = await getCategoryTree(); if (r.success) { flatCategories.value = flattenTree(r.data); const m:Record<string,string>={}; flatCategories.value.forEach(c=>m[c.id]=c.path); categoryMap.value = m } } catch {/* */}
}
function flattenTree(tree: any[], prefix = ''): any[] {
const r: any[] = []
for (const n of (tree||[])) { const p = prefix?`${prefix}/${n.name}`:n.name; r.push({id:n.id,name:n.name,path:p}); if(n.children?.length) r.push(...flattenTree(n.children,p)) }
return r
}
async function loadList() { loading.value = true
try { const r = await listFaqs(page.value,pageSize.value,searchKeyword.value||undefined,filterCategoryId.value||undefined,filterStatus.value||undefined)
if (r.success) { faqs.value = r.data?.records||r.data||[]; total.value = r.data?.total||r.total||0 } } catch(e:any){ toast('加载失败:'+e.message,'error') }
finally { loading.value = false } }
async function loadStats() { try { const r = await getFaqStats(); if(r.success) stats.value=r.data } catch {/* */} }
function onPageChange(info:{current:number;pageSize:number}) { page.value=info.current; pageSize.value=info.pageSize; loadList() }
const debouncedSearch = debounce(() => { page.value = 1; loadList() })
function openAddDialog() { editingFaq.value=null; form.value={question:'',answer:'',similarQuestionsText:'',categoryId:null,priority:0}; showFormDialog.value=true }
function openEditDialog(f:any) {
editingFaq.value = f
let st = ''; try { const a = typeof f.similarQuestions==='string'?JSON.parse(f.similarQuestions):f.similarQuestions; st=Array.isArray(a)?a.join('\n'):'' } catch { st=f.similarQuestions||'' }
form.value = { question:f.question, answer:f.answer, similarQuestionsText:st, categoryId:f.categoryId||null, priority:f.priority||0 }; showFormDialog.value=true
}
async function saveFaq() {
const sq = form.value.similarQuestionsText.split('\n').map(s=>s.trim()).filter(Boolean)
const d:any = { question:form.value.question, answer:form.value.answer, similarQuestions:JSON.stringify(sq), categoryId:form.value.categoryId||null, priority:form.value.priority||0 }
try { let r; if(editingFaq.value) r=await updateFaq(editingFaq.value.id,d); else r=await createFaq(d)
if(r.success){ toast(editingFaq.value?'修改成功':'添加成功','success'); showFormDialog.value=false; loadList(); loadStats() } else toast(r.message||'操作失败','error')
} catch(e:any){ toast('操作失败:'+e.message,'error') }
}
async function toggleStatus(f:any) { const ns = f.status==='ENABLED'?'DISABLED':'ENABLED'
try { const r = await toggleFaqStatus(f.id,ns); if(r.success){ f.status=ns; toast(`${ns==='ENABLED'?'启用':'禁用'}`,'success') } else toast(r.message||'操作失败','error') } catch(e:any){ toast('操作失败:'+e.message,'error') } }
async function removeFaq(id:string) { if(!await confirm('确认删除?'))return
try { const r = await deleteFaq(id); if(r.success){ toast('删除成功','success'); loadList(); loadStats() } else toast(r.message||'删除失败','error') } catch(e:any){ toast('删除失败:'+e.message,'error') } }
async function doImport() {
try { let faqs = JSON.parse(importJson.value); if(!Array.isArray(faqs)||!faqs.length) return toast('请输入有效数组','error')
for(const f of faqs){ if(Array.isArray(f.similarQuestions)) f.similarQuestions=JSON.stringify(f.similarQuestions); else if(!f.similarQuestions) f.similarQuestions='[]' }
const r = await batchImportFaqs({faqs}); if(r.success){ toast(`成功导入 ${r.data||faqs.length}`,'success'); showImportDialog.value=false; importJson.value=''; loadList(); loadStats() } else toast(r.message||'导入失败','error')
}catch(e:any){ if(e instanceof SyntaxError) toast('JSON 格式错误','error'); else toast('导入失败:'+e.message,'error') } }
async function doExport() {
try { const r = await exportFaqs(); if(r.success){ const b=new Blob([JSON.stringify(r.data,null,2)],{type:'application/json'}); const u=URL.createObjectURL(b); const a=document.createElement('a'); a.href=u; a.download='faq_export.json'; a.click(); URL.revokeObjectURL(u); toast('导出成功','success') } else toast(r.message||'导出失败','error')
}catch(e:any){ toast('导出失败:'+e.message,'error') } }
</script>
<style scoped>.stats-bar{display:flex;gap:8px;margin-bottom:12px;}</style>

78
frontend/src/views/LoginPage.vue

@ -0,0 +1,78 @@
<template>
<div class="login-container">
<div class="login-card">
<h2>🤖 AI 智能客服系统</h2>
<p class="login-subtitle">Support Bot 管理后台</p>
<t-form ref="formRef" :data="form" :rules="rules" @submit="handleLogin" label-width="0">
<t-form-item name="username">
<t-input v-model="form.username" placeholder="用户名" clearable size="large" />
</t-form-item>
<t-form-item name="password">
<t-input v-model="form.password" type="password" placeholder="密码" clearable size="large" @enter="handleLogin" />
</t-form-item>
<t-form-item>
<t-button type="submit" theme="primary" block size="large" :loading="loading"> </t-button>
</t-form-item>
</t-form>
<p class="login-error" v-if="errorMsg">{{ errorMsg }}</p>
</div>
<p class="login-footer">Powered by Spring AI + TDesign</p>
</div>
</template>
<script setup lang="ts">
import { ref, reactive } from 'vue'
import { login } from '@/api/auth'
import { setToken, setRefreshToken, setUserInfo } from '@/utils/token'
import { getCurrentUser } from '@/api/auth'
const emit = defineEmits<{ loginSuccess: [user: any] }>()
const form = reactive({ username: '', password: '' })
const rules = {
username: [{ required: true, message: '请输入用户名' }],
password: [{ required: true, message: '请输入密码' }],
}
const loading = ref(false)
const errorMsg = ref('')
async function handleLogin() {
errorMsg.value = ''
loading.value = true
try {
const res = await login(form.username, form.password)
if (res.success && res.data) {
setToken(res.data.accessToken)
setRefreshToken(res.data.refreshToken)
//
const userRes = await getCurrentUser()
if (userRes.success && userRes.data) {
setUserInfo(userRes.data)
emit('loginSuccess', userRes.data)
} else {
emit('loginSuccess', { username: form.username })
}
} else {
errorMsg.value = res.message || '登录失败'
}
} catch (e: any) {
errorMsg.value = e.message || '登录失败'
}
loading.value = false
}
</script>
<style scoped>
.login-container {
display: flex; flex-direction: column; align-items: center; justify-content: center;
min-height: 100vh; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
}
.login-card {
background: #fff; border-radius: 12px; padding: 40px; width: 380px; max-width: 90vw;
box-shadow: 0 20px 60px rgba(0,0,0,.15);
}
.login-card h2 { text-align: center; margin-bottom: 6px; color: #333; }
.login-subtitle { text-align: center; color: #999; font-size: 13px; margin-bottom: 32px; }
.login-error { color: var(--td-error-color, #e34d59); font-size: 13px; text-align: center; margin-top: 8px; }
.login-footer { color: rgba(255,255,255,.6); font-size: 12px; margin-top: 24px; }
</style>

117
frontend/src/views/McpServerManager.vue

@ -0,0 +1,117 @@
<template>
<t-card title="🔌 MCP 服务管理" :bordered="false">
<p class="desc-text">管理 MCP (Model Context Protocol) 服务端配置支持 SSE Stdio 两种传输方式</p>
<div class="toolbar">
<t-button theme="primary" size="small" @click="openAddModal">新建配置</t-button>
<t-button variant="outline" size="small" @click="refreshConnections" :disabled="refreshLoading">{{ refreshLoading?'刷新中...':'刷新连接' }}</t-button>
<t-button variant="outline" size="small" @click="load(currentPage)">刷新列表</t-button>
<div style="flex:1;" />
<span class="total-hint"> {{ total }} 条配置</span>
</div>
<t-table :data="servers" :columns="columns" row-key="id"
:pagination="{ current: currentPage, total: total, pageSize: pageSize, showJumper: true }" @page-change="onPageChange">
<template #transport_type="{ row }"><t-tag size="small" :theme="row.transport_type==='sse'?'primary':'default'" variant="light">{{ row.transport_type==='sse'?'SSE':'Stdio' }}</t-tag></template>
<template #displayUrl="{ row }"><span class="ellipsis" :title="getDisplayUrl(row)">{{ getDisplayUrl(row)||'-' }}</span></template>
<template #is_active="{ row }"><t-switch v-model="row._active" size="small" @change="toggleActive(row)" /></template>
<template #op="{ row }">
<t-space :size="4">
<t-button size="small" variant="text" @click="testConnection(row)" :disabled="testLoading[row.id]">{{ testLoading[row.id]?'测试中...':'测试' }}</t-button>
<t-button size="small" variant="text" @click="openEditModal(row)">编辑</t-button>
<t-button size="small" variant="text" theme="danger" @click="remove(row.id,row.name)">删除</t-button>
</t-space>
</template>
</t-table>
<!-- 测试结果 -->
<div v-if="testResult.visible" class="test-toast" :style="{background:testResult.success?'#16a34a':'#dc2626'}">
<strong>{{ testResult.success?'连接成功':'连接失败' }}</strong>
<span v-if="testResult.message" class="test-msg">{{ testResult.message }}</span>
</div>
<!-- 编辑弹窗 -->
<t-dialog v-model:visible="showModal" :header="modalMode==='add'?'新建 MCP 服务配置':'编辑 MCP 服务配置'" width="600px" :footer="false">
<t-form label-align="top">
<t-form-item label="服务名称 *"><t-input v-model="form.name" placeholder="如:天气查询服务" /></t-form-item>
<t-form-item label="传输类型 *">
<t-radio-group v-model="form.transportType">
<t-radio-button value="sse">SSEHTTP 服务</t-radio-button>
<t-radio-button value="stdio">Stdio本地进程</t-radio-button>
</t-radio-group>
</t-form-item>
<t-form-item v-if="form.transportType==='sse'" label="Server URL *"><t-input v-model="form.url" placeholder="如:http://localhost:3001/sse" /></t-form-item>
<template v-if="form.transportType==='stdio'">
<t-form-item label="Command *"><t-input v-model="form.command" placeholder="如:npx, python, node" /></t-form-item>
<t-form-item label="Args(每行一个)"><t-textarea v-model="form.argsText" :autosize="{minRows:2,maxRows:4}" placeholder="-m\nmcp-server-fetch" /></t-form-item>
</template>
<t-form-item label="环境变量(可选)"><t-textarea v-model="form.envText" :autosize="{minRows:2,maxRows:3}" placeholder="KEY1=value1\nKEY2=value2" /></t-form-item>
<t-form-item label="描述说明"><t-textarea v-model="form.description" :autosize="{minRows:2,maxRows:3}" placeholder="可选" /></t-form-item>
</t-form>
<div class="dialog-footer">
<t-button variant="outline" @click="showModal=false">取消</t-button>
<t-button theme="primary" @click="save" :loading="saving">保存</t-button>
</div>
</t-dialog>
</t-card>
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue'
import * as api from '@/api/mcp-server'
import { toast } from '@/utils/toast'
import { formatDate } from '@/utils/format'
import { useConfirm } from '@/composables/useConfirm'
const { confirm } = useConfirm()
const servers = ref<any[]>([]); const currentPage=ref(1); const pageSize=ref(10); const total=ref(0)
const showModal=ref(false); const modalMode=ref('add'); const editId=ref<any>(null); const saving=ref(false)
const form=ref({name:'',transportType:'sse',url:'',command:'',argsText:'',envText:'',description:''})
const testLoading=ref<Record<string,boolean>>({}); const testResult=ref({visible:false,success:false,message:'' as any}); let testTimer:any=null
const refreshLoading=ref(false)
const columns=[{colKey:'name',title:'名称',width:130},{colKey:'transport_type',title:'传输类型',width:90},{colKey:'displayUrl',title:'URL/命令',width:200,ellipsis:true},{colKey:'is_active',title:'状态',width:70},{colKey:'description',title:'描述',width:140,ellipsis:true},{colKey:'create_time',title:'创建时间',width:160,cell:(_:any,{row}:any)=>formatDate(row.create_time)},{colKey:'op',title:'操作',width:170,}]
onMounted(()=>load())
onUnmounted(()=>{if(testTimer)clearTimeout(testTimer)})
async function load(p=1){currentPage.value=p
try{const r=await api.listMcpServers(p,pageSize.value);if(r.success){servers.value=(r.data||[]).map((s:any)=>({...s,_active:s.is_active}));total.value=r.total||0}else toast(r.message||'查询失败','error')}catch(e:any){toast('加载失败:'+e.message,'error')}}
function onPageChange(i:{current:number;pageSize:number}){pageSize.value=i.pageSize;load(i.current)}
function createEmpty(){return{name:'',transportType:'sse',url:'',command:'',argsText:'',envText:'',description:''}}
function openAddModal(){modalMode.value='add';editId.value=null;form.value=createEmpty();showModal.value=true}
function openEditModal(s:any){modalMode.value='edit';editId.value=s.id
let at='',et=''
if(s.args){if(Array.isArray(s.args))at=s.args.join('\n');else{try{const p=JSON.parse(s.args);if(Array.isArray(p))at=p.join('\n');else at=s.args}catch{at=s.args}}}
if(s.env_vars){if(typeof s.env_vars==='object')et=Object.entries(s.env_vars).map(([k,v])=>k+'='+v).join('\n');else{try{const p=JSON.parse(s.env_vars);if(typeof p==='object')et=Object.entries(p).map(([k,v]:any)=>k+'='+v).join('\n');else et=s.env_vars}catch{et=s.env_vars}}}
form.value={name:s.name||'',transportType:s.transport_type||'sse',url:s.server_url||'',command:s.command||'',argsText:at,envText:et,description:s.description||''};showModal.value=true}
async function save(){
const f=form.value;if(!f.name.trim()){toast('请填写服务名称','error');return}
if(f.transportType==='sse'){if(!f.url.trim()){toast('请填写 URL','error');return}}else{if(!f.command.trim()){toast('请填写 Command','error');return}}
const d:any={name:f.name,transportType:f.transportType,description:f.description}
if(f.transportType==='sse')d.serverUrl=f.url;else{d.command=f.command;d.args=f.argsText.trim()?f.argsText.split('\n').map(s=>s.trim()).filter(Boolean):[]}
if(f.envText.trim()){const e:any={};f.envText.split('\n').forEach(l=>{const t=l.trim();if(!t)return;const i=t.indexOf('=');if(i>0)e[t.substring(0,i)]=t.substring(i+1)});d.envVars=e}
saving.value=true
try{let r;if(modalMode.value==='add')r=await api.createMcpServer(d);else r=await api.updateMcpServer(editId.value,d)
if(r.success){toast(modalMode.value==='add'?'创建成功':'更新成功','success');showModal.value=false;load(currentPage.value)}else toast(r.message||'操作失败','error')}catch(e:any){toast('保存失败:'+e.message,'error')}finally{saving.value=false}}
async function remove(id:string,name:string){if(!await confirm('确定删除「'+(name||id)+'」?'))return
try{const r=await api.deleteMcpServer(id);if(r.success){toast('删除成功','success');load(currentPage.value)}else toast(r.message||'删除失败','error')}catch(e:any){toast('删除失败:'+e.message,'error')}}
async function toggleActive(s:any){const na=!s.is_active
try{const r=await api.toggleMcpServer(s.id,na);if(r.success){toast(na?'已启用':'已禁用','success');s.is_active=na;s._active=na}else toast(r.message||'操作失败','error')}catch(e:any){toast('操作失败:'+e.message,'error')}}
async function testConnection(s:any){testLoading.value={...testLoading.value,[s.id]:true}
try{const r=await api.testMcpServer(s.id);showResult(r)}catch(e:any){showResult({success:false,message:e.message})}finally{testLoading.value={...testLoading.value,[s.id]:false}}}
function showResult(r:any){if(testTimer)clearTimeout(testTimer);testResult.value={visible:true,success:r.success,message:r.message||''};testTimer=setTimeout(()=>{testResult.value.visible=false},5000)}
async function refreshConnections(){refreshLoading.value=true
try{const r=await api.refreshMcpServers();if(r.success){toast('连接已刷新','success');load(currentPage.value)}else toast(r.message||'刷新失败','error')}catch(e:any){toast('刷新失败:'+e.message,'error')}finally{refreshLoading.value=false}}
function getDisplayUrl(s:any){if(s.transport_type==='sse')return s.server_url||'-';let d=s.command||'';if(s.args)d+=' '+(Array.isArray(s.args)?s.args.join(' '):s.args);return d||'-'}
</script>
<style scoped>
.ellipsis{max-width:220px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;display:block;}.test-toast{position:fixed;bottom:20px;right:20px;padding:12px 20px;border-radius:8px;z-index:9999;color:#fff;max-width:400px;box-shadow:0 4px 12px rgba(0,0,0,.15);}.test-msg{font-size:12px;margin-top:4px;opacity:.9;display:block;}</style>

719
frontend/src/views/ModelConfigManager.vue

@ -0,0 +1,719 @@
<template>
<t-card title="" :bordered="false" header-bordered>
<template #title><h2 style="margin:0;font-size:16px;"> AI 大模型配置管理</h2></template>
<!-- F4: 健康状态汇总卡片 -->
<div v-if="healthSummary" class="health-summary" style="display:flex;gap:12px;margin:12px 0;flex-wrap:wrap;">
<div style="padding:10px 16px;background:#f0fdf4;border:1px solid #bbf7d0;border-radius:8px;min-width:100px;text-align:center;">
<div style="font-size:22px;font-weight:700;color:#16a34a;">{{ healthSummary.online }}</div>
<div style="font-size:11px;color:#166534;">🟢 在线</div>
</div>
<div style="padding:10px 16px;background:#fef2f2;border:1px solid #fecaca;border-radius:8px;min-width:100px;text-align:center;">
<div style="font-size:22px;font-weight:700;color:#dc2626;">{{ healthSummary.offline }}</div>
<div style="font-size:11px;color:#991b1b;">🔴 离线</div>
</div>
<div v-if="healthSummary.unknown" style="padding:10px 16px;background:#fffbeb;border:1px solid #fde68a;border-radius:8px;min-width:100px;text-align:center;">
<div style="font-size:22px;font-weight:700;color:#d97706;">{{ healthSummary.unknown }}</div>
<div style="font-size:11px;color:#92400e;">🟡 未检测</div>
</div>
<div style="padding:10px 16px;background:#f9fafb;border:1px solid #e5e7eb;border-radius:8px;min-width:100px;text-align:center;">
<div style="font-size:22px;font-weight:700;color:#374151;">{{ healthSummary.total }}</div>
<div style="font-size:11px;color:#6b7280;">📊 总计</div>
</div>
</div>
<!-- 筛选栏 + 操作按钮 -->
<div class="toolbar">
<t-select v-model="filterAppType" :options="appTypeOptions" style="max-width:200px;" size="small" @change="load(1)" />
<t-button theme="primary" size="small" @click="openAddModal"> 新建配置</t-button>
<t-button variant="outline" size="small" @click="exportConfigs">📤 导出</t-button>
<t-button variant="outline" size="small" @click="showImportDialog = true">📥 导入</t-button>
<t-button variant="outline" size="small" @click="load()">🔄 刷新</t-button>
</div>
<!-- F6: Fallback 链可视化 -->
<div v-if="fallbackChains.length > 0" class="fallback-section">
<div style="display:flex;align-items:center;gap:8px;margin-bottom:8px;">
<span style="font-size:13px;font-weight:600;color:#1e40af;">🔗 Fallback 按优先级排列</span>
<t-tag size="small" variant="light" theme="primary" style="cursor:pointer;" @click="showFallbackHelp = !showFallbackHelp"> 帮助</t-tag>
</div>
<div v-if="showFallbackHelp" class="fallback-help">
<div style="font-weight:600;color:#1e40af;margin-bottom:8px;">📖 Fallback 链使用说明</div>
<p>Fallback 链是<span style="color:#059669;font-weight:600;">高可用性机制</span>主模型故障时自动切换到备用模型</p>
<p> 勾选设为活跃配置启用 | 📊 调整优先级数值越大越优先| 🔀 拖拽列表项快速调整</p>
</div>
<div v-for="chain in fallbackChains" :key="chain.appType" style="margin-bottom:8px;">
<div style="font-size:12px;color:#1e40af;font-weight:600;">{{ getAppTypeLabel(chain.appType) }}</div>
<div v-for="(item, idx) in chain.configs" :key="item.id"
style="display:flex;align-items:center;gap:6px;padding:4px 8px;margin:2px 0;background:white;border-radius:4px;font-size:12px;cursor:grab;border:1px solid #e5e7eb;"
draggable="true"
@dragstart="onDragStart($event, item, chain)"
@dragover.prevent
@drop="onDrop($event, item, chain)">
<span style="color:#6b7280;font-weight:600;">#{{ idx + 1 }}</span>
<span v-if="idx < chain.configs.length - 1" style="color:#93c5fd;"></span>
<span v-else style="color:#d1d5db;">·</span>
<strong>{{ item.name }}</strong>
<code style="font-size:11px;background:#f3f4f6;padding:1px 4px;border-radius:3px;">{{ item.model_name }}</code>
<span style="color:#6b7280;">P={{ item.priority }}</span>
<span v-if="healthMap[item.id]" :title="getHealthTooltip(item.id)"
:style="{ color: healthMap[item.id].status === 'ONLINE' ? '#16a34a' : '#dc2626' }">
{{ healthMap[item.id].status === 'ONLINE' ? '🟢' : healthMap[item.id].status === 'UNKNOWN' ? '🟡' : '🔴' }}
<span v-if="healthMap[item.id].latencyMs" style="font-size:10px;">{{ healthMap[item.id].latencyMs }}ms</span>
</span>
<span v-else style="color:#d1d5db;" title="未检测">🟡</span>
</div>
</div>
</div>
<!-- 配置列表表格 -->
<div style="overflow-x:auto;">
<t-table :data="configs" :columns="tableColumns" row-key="id" :loading="tableLoading" hover stripe
:pagination="{ current: currentPage, total: total, pageSize: pageSize, showJumper: true }"
@page-change="onPageChange">
<template #app_type="{ row }">
<t-tag size="small" variant="light" theme="primary">{{ getAppTypeLabel(row.app_type) }}</t-tag>
</template>
<template #model_name="{ row }">
<code style="font-size:12px;background:#f3f4f6;padding:2px 6px;border-radius:4px;">{{ row.model_name }}</code>
</template>
<template #base_url="{ row }">
<span style="font-size:12px;color:#6b7280;max-width:180px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;display:block;" :title="row.base_url || getProviderBaseUrl(row.provider)">
{{ row.base_url || getProviderBaseUrl(row.provider) || '-' }}
</span>
</template>
<template #api_key="{ row }">
<code style="font-size:12px;background:#f3f4f6;padding:2px 6px;border-radius:4px;">{{ row.api_key || '-' }}</code>
</template>
<template #status="{ row }">
<t-tag size="small" :theme="row.is_active ? 'success' : 'default'" variant="light">
{{ row.is_active ? '🟢 活跃' : '⚫ 未激活' }}
</t-tag>
<span v-if="row.is_active && healthMap[row.id]" :title="getHealthTooltip(row.id)"
:style="{ cursor: 'help', color: healthMap[row.id].status === 'ONLINE' ? '#16a34a' : healthMap[row.id].status === 'UNKNOWN' ? '#d97706' : '#dc2626', marginLeft: '4px' }">
{{ healthMap[row.id].status === 'ONLINE' ? '●' : '●' }}
</span>
</template>
<template #op="{ row }">
<t-space :size="4">
<t-button size="small" variant="text" @click="openEditModal(row)" title="编辑"></t-button>
<t-button size="small" variant="text" @click="testConnection(row)" :disabled="testLoading[row.id]" title="测试连接">{{ testLoading[row.id] ? '⏳' : '🔌' }}</t-button>
<t-button size="small" variant="text" @click="duplicateConfig(row)" title="复制">📋</t-button>
<t-button v-if="!row.is_active" size="small" variant="text" theme="primary" @click="activate(row.id)" title="激活"></t-button>
<t-button v-if="row.is_active" size="small" variant="text" @click="deactivateConfig(row.id)" title="停用"></t-button>
<t-button v-if="!row.is_active" size="small" variant="text" theme="danger" @click="remove(row.id, row.name)" title="删除">🗑</t-button>
</t-space>
</template>
</t-table>
</div>
<!-- F1: 测试结果提示 -->
<div v-if="testResult.visible" class="test-result-toast"
:style="{ position: 'fixed', bottom: '20px', right: '20px', padding: '12px 20px', borderRadius: '8px', zIndex: 9999, color: 'white', maxWidth: '400px', boxShadow: '0 4px 12px rgba(0,0,0,0.15)',
background: testResult.success ? '#16a34a' : '#dc2626' }">
<div style="font-weight:600;">{{ testResult.success ? '✅ 连接成功' : '❌ 连接失败' }}</div>
<div style="font-size:12px;margin-top:4px;">延迟{{ testResult.latencyMs }}ms<span v-if="testResult.dimensions"> · 维度{{ testResult.dimensions }}</span></div>
<div v-if="testResult.errorMessage" style="font-size:11px;margin-top:4px;opacity:0.9;">{{ testResult.errorMessage }}</div>
</div>
<!-- ==================== 编辑/新建弹窗 ==================== -->
<t-dialog
v-model:visible="editModal.visible"
:header="editModal.mode === 'add' ? '➕ 新建模型配置' : '✏️ 编辑模型配置'"
width="720px"
:footer="false"
:close-on-overlay-click="false"
>
<div style="font-size:12px;color:#6b7280;margin-bottom:16px;">💡 核心三要素API Key + API 地址 + 模型名称填入即可使用</div>
<t-form label-align="top">
<!-- 应用类型 -->
<t-form-item label="应用类型">
<t-radio-group v-model="editModal.form.app_type" :disabled="editModal.mode === 'edit'">
<t-radio-button v-for="tab in appTypeTabs" :key="tab.value" :value="tab.value">{{ tab.shortLabel }}</t-radio-button>
</t-radio-group>
</t-form-item>
<!-- API Key -->
<t-form-item label="🔑 API Key(必填)">
<t-space style="width:100%;">
<t-input v-model="editModal.form.api_key" :type="showApiKey ? 'text' : 'password'"
:placeholder="editModal.mode === 'edit' ? '留空则保留原值不变' : '请输入 API Key'" style="flex:1;"
@blur="onApiKeyFilled" />
<t-button variant="outline" size="small" @click="showApiKey = !showApiKey">{{ showApiKey ? '🙈 隐藏' : '👁️ 显示' }}</t-button>
</t-space>
<template #help>
<span v-if="editModal.mode === 'edit'" style="font-size:11px;color:#6b7280;">💡 API Key 已脱敏留空保留原值</span>
</template>
</t-form-item>
<!-- API 地址 -->
<t-form-item label="🌐 API 地址">
<t-input v-model="editModal.form.base_url" :placeholder="providerBaseUrlPlaceholder || '输入 API 地址'" @blur="onBaseUrlBlur" />
<template #help>
<t-space size="small" style="font-size:12px;margin-top:4px;align-items:center;">
<span style="color:#6b7280;">快捷选择</span>
<t-select v-model="editModal.form.provider" :options="providerOptions" style="max-width:200px;" size="small" @change="onProviderChange" />
<span v-if="providerTip" style="color:#3b82f6;">💡 {{ providerTip }}</span>
</t-space>
</template>
</t-form-item>
<!-- 模型名称 -->
<t-form-item label="🤖 模型名称(必填)">
<t-space style="width:100%;">
<t-input v-model="editModal.form.model_name" placeholder="输入模型名称,或点击「获取模型」"
style="flex:1;" @focus="onModelInputFocus" @input="onModelInputChange"
@keydown.down.prevent="onDropdownKeyDown('down')" @keydown.up.prevent="onDropdownKeyDown('up')"
@keydown.enter.prevent="onDropdownKeyDown('enter')" @keydown.escape="modelDropdownOpen = false">
<template #suffix>
<t-button v-if="editModal.form.model_name" variant="text" size="small"
@click="editModal.form.model_name = ''; modelDropdownOpen = false; updateNameSuggestion()" style="color:#9ca3af;"></t-button>
</template>
</t-input>
<t-button variant="outline" size="small" @click="fetchModelsFromApi" :disabled="fetchLoading"
style="border-color:var(--td-brand-color);color:var(--td-brand-color);white-space:nowrap;">
{{ fetchLoading ? '⏳ 获取中...' : '🔍 获取模型' }}
</t-button>
</t-space>
<!-- 自定义下拉列表 -->
<div v-if="modelDropdownOpen && filteredModels.length > 0" class="model-dropdown"
style="position:relative;top:100%;left:0;right:0;z-index:100;background:white;border:1px solid #d1d5db;border-radius:8px;max-height:240px;overflow-y:auto;margin-top:4px;box-shadow:0 4px 12px rgba(0,0,0,0.12);">
<div v-for="(m, idx) in filteredModels" :key="m.id"
@click="selectModel(m)" @mouseenter="modelDropdownHighlight = idx"
:style="{ padding: '8px 12px', cursor: 'pointer', fontSize: '13px',
borderBottom: idx < filteredModels.length - 1 ? '1px solid #f3f4f6' : 'none',
background: idx === modelDropdownHighlight ? '#eff6ff' : 'white' }">
<div style="font-weight:500;">{{ m.id }}</div>
<div v-if="m.owned_by" style="font-size:11px;color:#6b7280;margin-top:1px;">{{ m.owned_by }}</div>
</div>
</div>
<template #help>
<div style="font-size:11px;color:#6b7280;margin-top:4px;">💡 填入 API Key 和地址后点击获取模型自动填充</div>
<div v-if="fetchedModels.length > 0" style="font-size:11px;color:#16a34a;margin-top:2px;"> 已获取 {{ fetchedModels.length }} 个模型</div>
</template>
</t-form-item>
<!-- 高级参数折叠 -->
<t-form-item>
<t-collapse v-model="advancedCollapse">
<t-collapse-panel header="⚡ 高级参数" value="advanced">
<div style="display:grid;grid-template-columns:1fr 1fr;gap:12px;">
<t-form-item label="温度 (Temperature)">
<t-input-number v-model="editModal.form.temperature" :min="0" :max="2" :step="0.1" placeholder="0.7" style="width:100%;" />
</t-form-item>
<t-form-item label="最大 Token 数">
<t-input-number v-model="editModal.form.max_tokens" :min="1" :max="128000" placeholder="2000" style="width:100%;" />
</t-form-item>
<t-form-item label="Top P">
<t-input-number v-model="editModal.form.topP" :min="0" :max="1" :step="0.01" placeholder="不设置" style="width:100%;" />
</t-form-item>
<t-form-item label="Top K">
<t-input-number v-model="editModal.form.topK" :min="1" :max="100" placeholder="不限制" style="width:100%;" />
</t-form-item>
<t-form-item label="频率惩罚">
<t-input-number v-model="editModal.form.frequencyPenalty" :min="-2" :max="2" :step="0.1" placeholder="0" style="width:100%;" />
</t-form-item>
<t-form-item label="存在惩罚">
<t-input-number v-model="editModal.form.presencePenalty" :min="-2" :max="2" :step="0.1" placeholder="0" style="width:100%;" />
</t-form-item>
<t-form-item label="停止词(逗号分隔)" style="grid-column:1/-1;">
<t-input v-model="editModal.form.stopSequences" placeholder="如:END,STOP" />
</t-form-item>
</div>
</t-collapse-panel>
</t-collapse>
</t-form-item>
<!-- 向量维度 EMBEDDING -->
<t-form-item v-if="editModal.form.app_type === 'EMBEDDING'" label="📐 向量维度(必填)">
<t-input-number v-model="editModal.form.embeddingDimensions" :min="1" :max="8192" placeholder="1024" style="max-width:240px;" />
<template #help>
<span style="font-size:11px;color:#d97706;"> 修改维度后需重建向量表</span>
</template>
</t-form-item>
<!-- 配置名称 -->
<t-form-item label="📝 配置名称(必填)">
<t-input v-model="editModal.form.name" :placeholder="nameSuggestion || '如:生产环境-DeepSeek对话'" />
<template #help>
<span v-if="nameSuggestion && !editModal.form.name" style="font-size:11px;color:#3b82f6;">
💡 建议<a @click.prevent="editModal.form.name = nameSuggestion" style="color:var(--td-brand-color);cursor:pointer;text-decoration:underline;">{{ nameSuggestion }}</a>
</span>
</template>
</t-form-item>
<!-- 优先级 + 激活 -->
<t-row :gutter="16">
<t-col :span="6">
<t-form-item label="优先级">
<t-input-number v-model="editModal.form.priority" :min="0" placeholder="0(越大越优先)" style="width:100%;" />
</t-form-item>
</t-col>
<t-col :span="6">
<t-form-item label=" ">
<t-checkbox v-model="editModal.form.is_active">设为活跃配置同类型支持多个按优先级 Fallback</t-checkbox>
</t-form-item>
</t-col>
</t-row>
<!-- 描述 -->
<t-form-item label="描述说明">
<t-textarea v-model="editModal.form.description" :autosize="{ minRows: 2, maxRows: 4 }" placeholder="可选,填写配置用途说明" />
</t-form-item>
</t-form>
<!-- 按钮 -->
<div class="dialog-footer" style="gap:10px;margin-top:20px;">
<t-button variant="outline" @click="editModal.visible = false">取消</t-button>
<t-button v-if="editModal.mode === 'edit'" variant="outline" @click="testConnectionById(editModal.editId!)"
:disabled="testLoading[editModal.editId!]" style="border-color:var(--td-brand-color);color:var(--td-brand-color);">
{{ testLoading[editModal.editId!] ? '⏳ 测试中...' : '🔌 测试连接' }}
</t-button>
<t-button theme="primary" @click="saveConfig">💾 保存</t-button>
</div>
</t-dialog>
<!-- F7: 导入弹窗 -->
<t-dialog v-model:visible="showImportDialog" header="📥 导入模型配置" width="600px" :footer="false">
<t-upload :auto-upload="false" theme="file" accept=".json" @change="onImportFileSelect" />
<div v-if="importPreview" style="margin:12px 0;padding:12px;background:#f9fafb;border-radius:8px;">
<div style="font-size:13px;font-weight:600;margin-bottom:8px;">导入预览</div>
<div style="font-size:12px;"> {{ importPreview.configs.length }} 条配置</div>
<div v-for="c in importPreview.configs.slice(0, 5)" :key="c.name" style="font-size:11px;color:#6b7280;margin:2px 0;">
· {{ c.name }} ({{ c.appType || c.app_type }}) - {{ c.modelName || c.model_name }}
</div>
</div>
<t-form-item label="冲突策略">
<t-select v-model="importConflict" :options="importConflictOptions" style="max-width:300px;" />
</t-form-item>
<div v-if="importResult" style="margin:12px 0;padding:12px;border-radius:8px;"
:style="{ background: importResult.errors?.length ? '#fef2f2' : '#f0fdf4' }">
<div style="font-size:13px;font-weight:600;">导入完成</div>
<div style="font-size:12px;margin-top:4px;">新增 {{ importResult.added }} · 覆盖 {{ importResult.overwritten }} · 跳过 {{ importResult.skipped }}</div>
</div>
<div class="dialog-footer" style="gap:10px;margin-top:20px;">
<t-button variant="outline" @click="closeImportDialog">关闭</t-button>
<t-button theme="primary" @click="doImport" :disabled="!importPreview">📥 确认导入</t-button>
</div>
</t-dialog>
</t-card>
</template>
<script setup lang="ts">
import { ref, reactive, computed, watch, onMounted, onUnmounted } from 'vue'
import { MessagePlugin } from 'tdesign-vue-next'
import * as api from '@/api/model-config'
import { toast } from '@/utils/toast'
import { useConfirm } from '@/composables/useConfirm'
const { confirm } = useConfirm()
// ==================== ====================
const appTypeOptions = [
{ label: '全部类型', value: '' },
{ label: '💬 智能客服对话', value: 'CHAT' },
{ label: '📐 文本向量化', value: 'EMBEDDING' },
{ label: '🔄 RAG查询重写', value: 'RAG_REWRITE' },
{ label: '📊 重排序', value: 'RERANK' },
]
const appTypeTabs = [
{ value: 'CHAT', label: '💬 对话', shortLabel: '对话' },
{ value: 'EMBEDDING', label: '📐 向量化', shortLabel: '向量化' },
{ value: 'RAG_REWRITE', label: '🔄 RAG重写', shortLabel: 'RAG重写' },
{ value: 'RERANK', label: '📊 重排序', shortLabel: '重排序' },
]
const providerOptions = [
{ label: '手动填写地址', value: '' },
{ label: '通义千问 (DashScope)', value: 'dashscope' },
{ label: 'DeepSeek (深度求索)', value: 'deepseek' },
{ label: '豆包 (字节跳动)', value: 'volcengine' },
{ label: 'Kimi (月之暗面)', value: 'moonshot' },
{ label: '智谱 AI (GLM)', value: 'zhipu' },
{ label: 'OpenAI', value: 'openai' },
]
const PROVIDER_BASE_URLS: Record<string, string> = {
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: Record<string, string> = {
dashscope: '通义千问团队版/私有化部署需填写专属 Base URL,公共版可留空',
volcengine: '模型名称可在火山引擎 ARK 控制台获取',
}
const importConflictOptions = [
{ label: '跳过同名配置', value: 'skip' },
{ label: '覆盖同名配置', value: 'overwrite' },
]
// ==================== ====================
const tableColumns = [
{ colKey: 'name', title: '配置名称', width: 160 },
{ colKey: 'app_type', title: '应用类型', width: 100 },
{ colKey: 'model_name', title: '模型名称', width: 160 },
{ colKey: 'base_url', title: 'API 地址', width: 180 },
{ colKey: 'api_key', title: 'API Key', width: 180 },
{ colKey: 'status', title: '状态', width: 120 },
{ colKey: 'op', title: '操作', width: 240 },
]
// ==================== ====================
const configs = ref<any[]>([])
const currentPage = ref(1)
const pageSize = ref(10)
const total = ref(0)
const tableLoading = ref(false)
const filterAppType = ref('')
const showApiKey = ref(false)
//
const fetchedModels = ref<any[]>([])
const fetchLoading = ref(false)
const modelDropdownOpen = ref(false)
const modelDropdownHighlight = ref(-1)
const providerTip = ref('')
const providerBaseUrlPlaceholder = ref('')
const nameSuggestion = ref('')
const advancedCollapse = ref<string[]>([])
const filteredModels = computed(() => {
const query = (editModal.value.form.model_name || '').trim().toLowerCase()
if (!query) return fetchedModels.value
return fetchedModels.value.filter((m: any) =>
m.id.toLowerCase().includes(query) || (m.owned_by && m.owned_by.toLowerCase().includes(query)))
})
//
const healthMap = ref<Record<string, any>>({})
const healthSummary = ref<any>(null)
let healthTimer: any = null
//
const testLoading = ref<Record<string, boolean>>({})
const testResult = reactive({ visible: false, success: false, latencyMs: 0, dimensions: null as any, errorMessage: null as any })
let testResultTimer: any = null
// Fallback
const fallbackChains = ref<any[]>([])
const showFallbackHelp = ref(false)
//
const showImportDialog = ref(false)
const importPreview = ref<any>(null)
const importConflict = ref('skip')
const importResult = ref<any>(null)
//
const editModal = ref<any>({
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: '',
}
}
// ==================== ====================
function onProviderChange() {
const p = editModal.value.form.provider
if (!p) { editModal.value.form.base_url = ''; providerTip.value = ''; providerBaseUrlPlaceholder.value = '输入自定义 API 地址'; fetchedModels.value = []; updateNameSuggestion(); return }
editModal.value.form.base_url = PROVIDER_BASE_URLS[p] || ''
providerBaseUrlPlaceholder.value = PROVIDER_BASE_URLS[p] || ''
providerTip.value = PROVIDER_TIPS[p] || ''
fetchedModels.value = []
updateNameSuggestion()
}
function onBaseUrlBlur() {
const url = editModal.value.form.base_url
if (!url || editModal.value.form.provider) return
for (const [p, du] of Object.entries(PROVIDER_BASE_URLS)) {
if (url.startsWith(du) || du.startsWith(url)) {
editModal.value.form.provider = p
providerTip.value = PROVIDER_TIPS[p] || ''
break
}
}
}
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(dir: string) {
if (!modelDropdownOpen.value || filteredModels.value.length === 0) {
if (fetchedModels.value.length > 0) { modelDropdownOpen.value = true; modelDropdownHighlight.value = 0 }
return
}
if (dir === 'down') modelDropdownHighlight.value = Math.min(modelDropdownHighlight.value + 1, filteredModels.value.length - 1)
else if (dir === 'up') modelDropdownHighlight.value = Math.max(modelDropdownHighlight.value - 1, 0)
else if (dir === 'enter') {
if (modelDropdownHighlight.value >= 0 && modelDropdownHighlight.value < filteredModels.value.length)
selectModel(filteredModels.value[modelDropdownHighlight.value])
else modelDropdownOpen.value = false
}
}
function selectModel(m: any) { editModal.value.form.model_name = m.id; modelDropdownOpen.value = false; modelDropdownHighlight.value = -1; updateNameSuggestion() }
function onDocumentClick(e: Event) {
const el = e.target as HTMLElement
if (el.closest?.('.model-dropdown') || el.closest?.('.t-input')) return
modelDropdownOpen.value = false
}
// ==================== ====================
async function fetchModelsFromApi() {
const form = editModal.value.form
if (!form.api_key?.trim()) { toast('请先填写 API Key', 'error'); return }
if (!form.base_url?.trim()) {
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: any) { toast('获取失败:' + e.message, 'error'); fetchedModels.value = [] }
finally { fetchLoading.value = false }
}
function updateNameSuggestion() {
const f = editModal.value.form
if (f.name) { nameSuggestion.value = ''; return }
const pl = getProviderLabel(f.provider)
if (f.model_name?.trim()) nameSuggestion.value = (pl || '自定义') + '-' + f.model_name.trim()
else nameSuggestion.value = ''
}
watch(() => editModal.value.form.model_name, updateNameSuggestion)
watch(() => editModal.value.form.provider, updateNameSuggestion)
// ==================== ====================
async function load(p = currentPage.value) {
tableLoading.value = true
currentPage.value = p
try {
const json = await api.listModelConfigs(p, pageSize.value, filterAppType.value || undefined)
if (json.success) {
configs.value = json.data || []
total.value = json.total || 0
loadHealthStatus(); buildFallbackChains()
} else toast(json.message || '查询失败', 'error')
} catch (e: any) { toast('加载失败:' + e.message, 'error') }
finally { tableLoading.value = false }
}
function onPageChange(pageInfo: { current: number; pageSize: number }) { pageSize.value = pageInfo.pageSize; load(pageInfo.current) }
// ==================== ====================
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 { /* 静默失败 */ }
}
function getHealthTooltip(cid: string) {
const h = healthMap.value[cid]
if (!h) return '未检测'
let t = h.status === 'ONLINE' ? '在线' : h.status === 'UNKNOWN' ? '未支持自动检测' : '离线'
if (h.latencyMs) t += ' · ' + h.latencyMs + 'ms'
return t
}
// ==================== Fallback ====================
function buildFallbackChains() {
const chains = []
for (const at of ['CHAT', 'EMBEDDING', 'RAG_REWRITE', 'RERANK']) {
const ac = configs.value.filter((c: any) => c.app_type === at && c.is_active).sort((a: any, b: any) => (b.priority || 0) - (a.priority || 0))
if (ac.length > 0) chains.push({ appType: at, configs: ac })
}
fallbackChains.value = chains
}
let dragItem: any = null; let dragChain: any = null
function onDragStart(e: DragEvent, item: any, chain: any) { dragItem = item; dragChain = chain; e.dataTransfer!.effectAllowed = 'move' }
async function onDrop(e: DragEvent, target: any) {
if (!dragItem || dragItem.id === target.id) return
try {
await api.updateModelConfigPriority(dragItem.id, target.priority || 0)
await api.updateModelConfigPriority(target.id, dragItem.priority || 0)
toast('优先级已调整', 'success')
load()
} catch (e: any) { toast('调整失败:' + e.message, 'error') }
dragItem = null; dragChain = null
}
// ==================== ====================
function openAddModal() {
editModal.value = { visible: true, mode: 'add', editId: null, form: createEmptyForm() }
showApiKey.value = false; advancedCollapse.value = []
fetchedModels.value = []; providerTip.value = ''; providerBaseUrlPlaceholder.value = ''; nameSuggestion.value = ''
modelDropdownOpen.value = false; modelDropdownHighlight.value = -1
}
function openEditModal(config: any) {
let extra: any = {}
const raw = config.extra_config || config.extraConfig
if (raw) { try { extra = typeof raw === 'string' ? JSON.parse(raw) : raw } catch { } }
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: extra.dimensions || 1024,
topP: extra.topP ?? null, topK: extra.topK ?? null,
frequencyPenalty: extra.frequencyPenalty ?? null, presencePenalty: extra.presencePenalty ?? null,
stopSequences: extra.stopSequences || '', priority: config.priority || 0,
is_active: config.is_active || false, description: config.description || '',
},
}
showApiKey.value = false; advancedCollapse.value = []
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 toCamelCase(form: any) {
const data: any = { 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: any = {}
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?.trim()) extraConfig.stopSequences = form.stopSequences.trim()
data.extraConfig = extraConfig
return data
}
async function saveConfig() {
const form = editModal.value.form
if (!form.name?.trim()) { toast('请填写配置名称', 'error'); return }
if (!form.api_key?.trim() && editModal.value.mode === 'add') { toast('请填写 API Key', 'error'); return }
if (!form.model_name?.trim()) { toast('请填写模型名称', 'error'); return }
if (!form.provider) form.provider = 'other'
try {
const data = toCamelCase(form)
let json
if (editModal.value.mode === 'add') json = await api.createModelConfig(data)
else { if (!data.apiKey?.trim()) delete data.apiKey; json = await api.updateModelConfig(editModal.value.editId, data) }
if (json.success) { toast(editModal.value.mode === 'add' ? '配置创建成功' : '配置更新成功', 'success'); editModal.value.visible = false; load() }
else toast(json.message || '操作失败', 'error')
} catch (e: any) { toast('保存失败:' + e.message, 'error') }
}
// ==================== ====================
async function testConnection(config: any) {
testLoading.value = { ...testLoading.value, [config.id]: true }
try { showTestResult(await api.testModelConfig(config.id)) } catch (e: any) { showTestResult({ success: false, errorMessage: e.message, latencyMs: 0 }) }
finally { testLoading.value = { ...testLoading.value, [config.id]: false } }
}
async function testConnectionById(id: string) {
testLoading.value = { ...testLoading.value, [id]: true }
try { showTestResult(await api.testModelConfig(id)) } catch (e: any) { showTestResult({ success: false, errorMessage: e.message, latencyMs: 0 }) }
finally { testLoading.value = { ...testLoading.value, [id]: false } }
}
function showTestResult(r: any) {
if (testResultTimer) clearTimeout(testResultTimer)
testResult.visible = true; testResult.success = r.success; testResult.latencyMs = r.latencyMs || 0; testResult.dimensions = r.dimensions || null; testResult.errorMessage = r.errorMessage || r.message || null
testResultTimer = setTimeout(() => { testResult.visible = false }, 5000)
}
// ==================== /// ====================
async function duplicateConfig(c: any) {
if (!await confirm(`确定复制配置「${c.name || c.id}」?`)) return
try { const j = await api.duplicateModelConfig(c.id); if (j.success) { toast('配置复制成功', 'success'); load() } else toast(j.message || '复制失败', 'error') }
catch (e: any) { toast('复制失败:' + e.message, 'error') }
}
async function activate(id: string) {
if (!await confirm('确定激活此配置?')) return
try { const j = await api.activateModelConfig(id); if (j.success) { toast('配置已激活', 'success'); load() } else toast(j.message || '激活失败', 'error') }
catch (e: any) { toast('激活失败:' + e.message, 'error') }
}
async function deactivateConfig(id: string) {
if (!await confirm('确定停用此配置?')) return
try { const j = await api.deactivateModelConfig(id); if (j.success) { toast('配置已停用', 'success'); load() } else toast(j.message || '停用失败', 'error') }
catch (e: any) { toast('停用失败:' + e.message, 'error') }
}
async function remove(id: string, name: string) {
if (!await confirm(`确定删除「${name || id}」?`)) return
try { const j = await api.deleteModelConfig(id); if (j.success) { toast('已删除', 'success'); load() } else toast(j.message || '删除失败', 'error') }
catch (e: any) { toast('删除失败:' + e.message, 'error') }
}
// ==================== / ====================
async function exportConfigs() {
try {
const j = await api.exportModelConfigs()
if (j.success) { const blob = new Blob([JSON.stringify(j.data, null, 2)], { type: 'application/json' }); const u = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = u; a.download = 'model-configs-' + new Date().toISOString().slice(0, 10) + '.json'; a.click(); URL.revokeObjectURL(u); toast('导出成功', 'success') }
else toast(j.message || '导出失败', 'error')
} catch (e: any) { toast('导出失败:' + e.message, 'error') }
}
function onImportFileSelect(file: any) {
if (!file?.raw) return
const reader = new FileReader()
reader.onload = (ev: any) => {
try { let d = JSON.parse(ev.target.result); if (!Array.isArray(d) && d.data) d = d.data; if (!Array.isArray(d)) { toast('JSON 格式不正确', 'error'); return }; importPreview.value = { configs: d }; importResult.value = null }
catch (e: any) { toast('文件解析失败:' + e.message, 'error') }
}
reader.readAsText(file.raw)
}
async function doImport() {
if (!importPreview.value) return
try { const j = await api.importModelConfigs(importPreview.value.configs, importConflict.value); if (j.success) { importResult.value = j.data; toast('导入完成', 'success'); load() } else toast(j.message || '导入失败', 'error') }
catch (e: any) { toast('导入失败:' + e.message, 'error') }
}
function closeImportDialog() { showImportDialog.value = false; importPreview.value = null; importResult.value = null }
// ==================== ====================
function getAppTypeLabel(t: string) { const m: Record<string, string> = { CHAT: '💬 对话', EMBEDDING: '📐 向量化', RAG_REWRITE: '🔄 RAG重写', RERANK: '📊 重排序' }; return m[t] || t }
function getProviderLabel(p: string) { const f = providerOptions.find(o => o.value === p); return f ? f.label : p }
function getProviderBaseUrl(p: string) { return PROVIDER_BASE_URLS[p] || '' }
// ==================== ====================
onMounted(() => { load(); healthTimer = setInterval(loadHealthStatus, 30000); document.addEventListener('click', onDocumentClick, true) })
onUnmounted(() => { clearInterval(healthTimer); clearTimeout(testResultTimer); document.removeEventListener('click', onDocumentClick, true) })
</script>
<style scoped>
.fallback-section { margin:12px 0; padding:12px; background:#eff6ff; border:1px solid #bfdbfe; border-radius:8px; }
.fallback-help { margin-bottom:12px; padding:12px; background:white; border:1px solid #93c5fd; border-radius:6px; font-size:12px; line-height:1.6; color:#374151; }
.fallback-help p { margin:4px 0; }
</style>

197
frontend/src/views/PipelineFlow.vue

@ -0,0 +1,197 @@
<template>
<t-card :bordered="false">
<template #header>
<span style="font-size:16px;font-weight:600;">🔀 AI 执行链</span>
<p style="font-size:12px;color:var(--td-text-color-placeholder);margin:4px 0 0;">
下图展示从用户请求到 AI 回复的完整处理流程包含意图路由RAG 检索和 Advisor
菱形节点 = 决策分支 · 虚线框 = 独立子系统 · /绿/黄色高亮 = 入口/出口/LLM
</p>
</template>
<!-- 图例 -->
<div class="pipeline-legend">
<div class="legend-item"><span class="legend-dot legend-input" /> 用户请求 / 入口</div>
<div class="legend-item"><span class="legend-dot legend-decision" /> 决策分支</div>
<div class="legend-item"><span class="legend-dot legend-process" /> 处理步骤</div>
<div class="legend-item"><span class="legend-dot legend-rag" /> RAG 检索</div>
<div class="legend-item"><span class="legend-dot legend-advisor" /> Advisor </div>
<div class="legend-item"><span class="legend-dot legend-output" /> 最终输出</div>
<div class="legend-item"><span class="legend-dot legend-llm" /> LLM 调用</div>
</div>
<!-- 流程图 -->
<div style="margin-top:16px;">
<div v-if="error" class="pipeline-error">
<p> 图表渲染失败: {{ error }}</p>
<t-button variant="outline" size="small" @click="retry">重试</t-button>
</div>
<div v-else-if="!svg" class="pipeline-loading">
<p> 正在生成流程图...</p>
</div>
<div v-else class="pipeline-diagram" v-html="svg" />
</div>
</t-card>
</template>
<script setup lang="ts">
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["<b>用户请求</b><br/>message + roleId + accountId + chatId"]
A --> B{"<b>Controller</b><br/>鉴权 / 角色解析<br/>构建 ChatContext"}
B --> C["<b>ChatPipeline.buildRequest</b><br/>编排决策入口"]
C --> D{"enableRag ?"}
D -- "❌ false" --> E["<b>模式: 纯对话</b><br/>systemPrompt(角色人设)<br/>不检索知识库"]
D -- "✅ true" --> F["<b>IntentRouter</b><br/>LLM 意图识别<br/>FAQ / RAG / CHITCHAT"]
F --> G{"意图分类结果"}
G -- "FAQ<br/>confidence ≧ 0.8" --> H["<b>FaqMatchEngine</b><br/>三级匹配策略<br/>精确 → 关键词 → 向量语义"]
G -- "CHITCHAT<br/>confidence ≧ 0.6" --> I["<b>模式: 纯对话</b><br/>跳过知识库检索<br/>不注入资料块"]
G -- "RAG / 降级<br/>其余情况" --> J["<b>RagPipeline.retrieve</b><br/>RAG 检索流水线入口"]
subgraph RAG["📚 RAG 检索流水线"]
J --> K["<b>1. FAQ 优先匹配</b><br/>FaqMatchEngine 三级匹配<br/>命中则短路返回"]
K --> L["<b>2. 查询重写</b><br/>REWRITE / TRANSLATION<br/>COMPRESSION / MULTI_QUERY"]
L --> M{"<b>3. 检索模式</b>"}
M --> N["<b>VECTOR</b><br/>向量语义检索"]
M --> O["<b>KEYWORD</b><br/>全文关键词检索<br/>PostgreSQL tsvector"]
M --> P["<b>HYBRID</b><br/>向量 + 关键词<br/>双路检索"]
N --> Q["<b>4. RRF 融合排序</b><br/>Reciprocal Rank Fusion<br/>k=60"]
O --> Q
P --> Q
Q --> R["<b>5. Reranker 重排序</b><br/>DashScope / OpenAI 兼容<br/>超时 3s 自动 fallback"]
R --> S["<b>6. 构建资料块</b><br/>拼接检索文档<br/>注入 system prompt 末尾"]
end
H --> T["<b>组装 ChatRequest</b><br/>finalMessage + finalSystemPrompt<br/>+ faqAnswer (可选)"]
I --> T
S --> T
E --> T
T --> U["<b>AssistantApp</b><br/>chat / chatStream<br/>构建 ChatClient + MCP 工具"]
subgraph ADVISOR["🛡️ Advisor 链(环绕 LLM 调用)"]
U --> V["<b>ContentSafetyAdvisor</b><br/>🔽 before: DFA 敏感词检测<br/>用户输入 BLOCK/MASK"]
V --> W["<b>MessageChatMemoryAdvisor</b><br/>注入历史对话记忆<br/>DatabaseChatMemory 持久化"]
W --> X["<b>MyLoggerAdvisor</b><br/>🔽 before: 请求日志<br/>INFO: AI Request: ..."]
X --> Y["<b>🤖 ChatClient.call / stream</b><br/>LLM 大模型调用<br/>通义千问 / 其他提供商"]
Y --> Z["<b>MyLoggerAdvisor</b><br/>🔼 after: 响应日志<br/>INFO: AI Response: ..."]
Z --> AA["<b>ContentSafetyAdvisor</b><br/>🔼 after: AI 输出检测<br/>BLOCK/MASK 违规内容"]
end
AA --> AB["<b>返回 AI 回复</b><br/>SSE 流式输出<br/>+ 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
`
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: any) {
console.error('Mermaid 渲染失败:', e)
error.value = e.message || '未知渲染错误'
}
}
function retry() {
render()
}
onMounted(() => {
render()
})
</script>
<style scoped>
.pipeline-legend {
display: flex;
flex-wrap: wrap;
gap: 10px 20px;
margin-bottom: 8px;
}
.legend-item {
display: flex;
align-items: center;
gap: 8px;
font-size: 12px;
color: var(--td-text-color-placeholder, #999);
}
.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; }
.pipeline-diagram {
width: 100%;
overflow-x: auto;
padding: 8px 0;
}
.pipeline-diagram :deep(svg) {
max-width: 100%;
height: auto;
display: block;
margin: 0 auto;
}
.pipeline-loading {
text-align: center;
padding: 40px;
color: var(--td-text-color-placeholder, #999);
font-size: 14px;
}
.pipeline-error {
text-align: center;
padding: 24px;
color: var(--td-error-color, #e34d59);
font-size: 14px;
}
</style>

89
frontend/src/views/RoleManager.vue

@ -0,0 +1,89 @@
<template>
<t-card title="🎭 客服角色管理" :bordered="false">
<p class="desc-text">为角色设定人设并授权可检索的知识库分类对话页只能选择这里配置好的角色检索范围由服务端按角色强制限定</p>
<!-- 新增/编辑表单 -->
<div class="card-box form-bar">
<t-row :gutter="12">
<t-col :span="4"><t-input v-model="form.name" placeholder="角色名称 *" size="small" /></t-col>
<t-col :span="4"><t-input v-model="form.description" placeholder="职责描述(可选)" size="small" /></t-col>
<t-col :span="2"><t-checkbox v-model="form.enabled">启用</t-checkbox></t-col>
</t-row>
<t-textarea v-model="form.prompt" :autosize="{minRows:2,maxRows:3}" placeholder="角色人设/风格提示词" style="margin-top:8px;" />
<t-space style="margin-top:8px;">
<t-button theme="success" size="small" @click="submit">{{ editingId?'保存修改':'新增角色' }}</t-button>
<t-button v-if="editingId" variant="outline" size="small" @click="resetForm">取消编辑</t-button>
<t-button variant="outline" size="small" @click="reload">刷新</t-button>
</t-space>
</div>
<!-- 角色列表 -->
<t-empty v-if="!roles.length" description="暂无角色" />
<div v-else>
<div v-for="role in roles" :key="role.id" class="role-card" :style="{opacity:role.enabled?'1':'.6'}">
<div class="role-header">
<div>
<strong class="role-name">{{ role.name }}</strong>
<t-tag v-if="!role.enabled" size="small" theme="danger" variant="light" style="margin-left:6px;">已停用</t-tag>
<div class="role-desc">{{ role.description || '—' }}</div>
</div>
<t-space :size="4">
<t-button size="small" variant="text" @click="startEdit(role)">编辑</t-button>
<t-button size="small" variant="text" theme="danger" @click="remove(role)">删除</t-button>
</t-space>
</div>
<div v-if="role.prompt" class="role-prompt">人设{{ role.prompt }}</div>
<div class="role-categories">
<span class="cat-label">可检索知识库</span>
<t-checkbox v-for="c in categoryStore.categories" :key="c.id"
:checked="role.categoryIds.includes(String(c.id))"
@change="toggleCategory(role, String(c.id), ($event as any))">
{{ c.name }}
</t-checkbox>
<span v-if="!categoryStore.categories.length" class="cat-hint">暂无知识库分类</span>
</div>
</div>
</div>
</t-card>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue'
import { useCategoryStore } from '@/stores/category'
import { getAllRoles, createRole, updateRole, deleteRole, updateRoleCategories } from '@/api/role'
import { toast } from '@/utils/toast'
import { useConfirm } from '@/composables/useConfirm'
const { confirm } = useConfirm()
const categoryStore = useCategoryStore()
const roles = ref<any[]>([])
const editingId = ref('')
const form = reactive({name:'',description:'',prompt:'',enabled:true})
onMounted(()=>{reload();categoryStore.loadCategories()})
async function reload(){try{const r=await getAllRoles();if(r.success)roles.value=(r.data||[]).map((ro:any)=>({...ro,categoryIds:(ro.categoryIds||[]).map(String)}))}catch(e:any){toast('加载失败:'+e.message,'error')}}
function startEdit(role:any){editingId.value=role.id;form.name=role.name;form.description=role.description||'';form.prompt=role.prompt||'';form.enabled=role.enabled}
function resetForm(){editingId.value='';form.name='';form.description='';form.prompt='';form.enabled=true}
async function submit(){
if(!form.name.trim()){toast('请输入角色名称','error');return}
const d:any={name:form.name.trim(),description:form.description.trim(),prompt:form.prompt,enabled:form.enabled}
try{let r;if(editingId.value)r=await updateRole(editingId.value,d);else r=await createRole(d)
if(r.success){toast(editingId.value?'修改成功':'新增成功','success');resetForm();reload()}else toast(r.message||'操作失败','error')}catch(e:any){toast('操作失败:'+e.message,'error')}}
async function remove(role:any){if(!await confirm(`确定删除「${role.name}」?`))return
try{const r=await deleteRole(role.id);if(r.success){toast('删除成功','success');reload()}else toast(r.message||'删除失败','error')}catch(e:any){toast('删除失败:'+e.message,'error')}}
async function toggleCategory(role:any, catId:string, checked:boolean){
const ids=new Set(role.categoryIds);if(checked)ids.add(catId);else ids.delete(catId)
try{const r=await updateRoleCategories(role.id,Array.from(ids));if(r.success){role.categoryIds=Array.from(ids)}else toast(r.message||'授权失败','error')}catch(e:any){toast('授权失败:'+e.message,'error')}}
</script>
<style scoped>
.role-card{padding:12px;border:1px solid #e7e7e7;border-radius:8px;margin-bottom:10px;}
.role-header{display:flex;justify-content:space-between;align-items:flex-start;gap:8px;}.role-name{font-size:14px;}.role-desc{font-size:12px;color:#999;margin-top:2px;}
.role-prompt{font-size:12px;color:#333;margin-top:6px;background:#f9fafb;border-radius:6px;padding:6px 8px;}
.role-categories{margin-top:8px;font-size:12px;display:flex;flex-wrap:wrap;gap:4px 12px;align-items:center;}.cat-label{color:#999;}.cat-hint{color:#999;}
</style>

104
frontend/src/views/SensitiveWordManager.vue

@ -0,0 +1,104 @@
<template>
<t-card title="🛡️ 敏感词管理" :bordered="false">
<div class="toolbar">
<t-button theme="primary" size="small" @click="showAddDialog = true"> 添加敏感词</t-button>
<t-button variant="outline" size="small" @click="showImportDialog = true">📥 批量导入</t-button>
<div style="flex:1;" />
<t-input v-model="searchKeyword" placeholder="搜索敏感词..." size="small" style="width:180px;" clearable @change="debouncedSearch" />
<t-select v-model="filterCategory" :options="catOpts" placeholder="全部分类" size="small" style="width:100px;" @change="loadList" />
</div>
<t-table :data="words" :columns="columns" row-key="id" :loading="loading"
:pagination="{ current: page, total: total, pageSize: pageSize, showJumper: true }" @page-change="onPageChange">
<template #category="{ row }">
<t-tag size="small" :style="{background:catColor(row.category)}" variant="light">{{ catLabel(row.category) }}</t-tag>
</template>
<template #level="{ row }"><span :style="{color:row.level>=2?'var(--td-error-color)':'var(--td-warning-color)'}">{{ row.level>=2?'拦截':'警告' }}</span></template>
<template #isActive="{ row }"><span>{{ row.isActive?'✅':'❌' }}</span></template>
<template #op="{ row }">
<t-space :size="4">
<t-button size="small" variant="text" @click="editWord(row)">编辑</t-button>
<t-button size="small" variant="text" theme="danger" @click="removeWord(row.id)">删除</t-button>
</t-space>
</template>
</t-table>
<!-- 新增/编辑弹窗 -->
<t-dialog v-model:visible="dialogVisible" :header="editingWord ? '编辑敏感词' : '添加敏感词'" width="420px" :footer="false" @close="closeDialog">
<t-form label-align="top">
<t-form-item label="敏感词"><t-input v-model="form.word" placeholder="输入敏感词" /></t-form-item>
<t-form-item label="分类"><t-select v-model="form.category" :options="catOpts2" /></t-form-item>
<t-form-item label="级别"><t-select v-model="form.level" :options="levelOpts" /></t-form-item>
<t-form-item label="备注"><t-input v-model="form.remark" placeholder="可选备注" /></t-form-item>
</t-form>
<div class="dialog-footer">
<t-button variant="outline" @click="closeDialog">取消</t-button>
<t-button theme="primary" @click="saveWord" :disabled="!form.word">保存</t-button>
</div>
</t-dialog>
<!-- 批量导入弹窗 -->
<t-dialog v-model:visible="showImportDialog" header="批量导入敏感词" width="500px" :footer="false">
<t-textarea v-model="importText" :autosize="{ minRows: 6, maxRows: 10 }" placeholder="每行一个敏感词" />
<t-row :gutter="16" style="margin-top:12px;">
<t-col :span="6"><t-select v-model="importCategory" :options="catOpts2" placeholder="分类" /></t-col>
<t-col :span="6"><t-select v-model="importLevel" :options="levelOpts" placeholder="级别" /></t-col>
</t-row>
<div class="dialog-footer">
<t-button variant="outline" @click="showImportDialog = false">取消</t-button>
<t-button theme="primary" @click="doImport" :disabled="!importText.trim()">导入</t-button>
</div>
</t-dialog>
</t-card>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { listSensitiveWords, createSensitiveWord, updateSensitiveWord, deleteSensitiveWord, batchImportSensitiveWords } from '@/api/sensitive-word'
import { toast } from '@/utils/toast'
import { useConfirm } from '@/composables/useConfirm'
import { useDebounce } from '@/composables/useDebounce'
const { confirm } = useConfirm()
const { debounce } = useDebounce()
const words = ref<any[]>([]); const loading=ref(false); const page=ref(1); const pageSize=ref(20); const total=ref(0)
const searchKeyword=ref(''); const filterCategory=ref('')
const showAddDialog=ref(false); const showImportDialog=ref(false); const editingWord=ref<any>(null)
const form=ref({word:'',category:'custom',level:1,remark:''})
const importText=ref(''); const importCategory=ref('custom'); const importLevel=ref(1)
const columns = [
{colKey:'word',title:'敏感词',width:160},{colKey:'category',title:'分类',width:80},
{colKey:'level',title:'级别',width:60},{colKey:'isActive',title:'状态',width:60},{colKey:'op',title:'操作',width:120},
]
const dialogVisible = computed({get:()=>showAddDialog.value||!!editingWord.value,set:(v)=>{if(!v)closeDialog()}})
const catOpts=[{label:'全部分类',value:''},{label:'政治',value:'politics'},{label:'色情',value:'porn'},{label:'辱骂',value:'abuse'},{label:'自定义',value:'custom'}]
const catOpts2=catOpts.filter(c=>c.value!=='')
const levelOpts=[{label:'警告(脱敏处理)',value:1},{label:'拦截(阻断回复)',value:2}]
onMounted(()=>loadList())
async function loadList(){loading.value=true
try{const r=await listSensitiveWords(page.value,pageSize.value,searchKeyword.value||undefined,filterCategory.value||undefined)
if(r.success){words.value=r.data?.records||r.data||[];total.value=r.data?.total||r.total||0}}catch(e:any){toast('加载失败:'+e.message,'error')}finally{loading.value=false}}
function onPageChange(i:{current:number;pageSize:number}){page.value=i.current;pageSize.value=i.pageSize;loadList()}
const debouncedSearch = debounce(() => { page.value = 1; loadList() })
function editWord(w:any){editingWord.value=w;form.value={word:w.word,category:w.category,level:w.level,remark:w.remark||''}}
function closeDialog(){showAddDialog.value=false;editingWord.value=null;form.value={word:'',category:'custom',level:1,remark:''}}
async function saveWord(){try{if(editingWord.value){const r=await updateSensitiveWord(editingWord.value.id,form.value);if(r.success)toast('修改成功','success')}else{const r=await createSensitiveWord(form.value);if(r.success)toast('添加成功','success');else toast(r.message||'添加失败','error')}closeDialog();loadList()}catch(e:any){toast('操作失败:'+e.message,'error')}}
async function removeWord(id:string){if(!await confirm('确认删除?'))return
try{const r=await deleteSensitiveWord(id);if(r.success){toast('删除成功','success');loadList()}else toast(r.message||'删除失败','error')}catch(e:any){toast('删除失败:'+e.message,'error')}}
async function doImport(){const ws=importText.value.split('\n').map(s=>s.trim()).filter(Boolean);if(!ws.length)return toast('请输入敏感词','error')
try{const r=await batchImportSensitiveWords({words:ws,category:importCategory.value,level:importLevel.value});if(r.success){toast(`成功导入 ${r.data||ws.length}`,'success');showImportDialog.value=false;importText.value='';loadList()}else toast(r.message||'导入失败','error')}catch(e:any){toast('导入失败:'+e.message,'error')}}
function catLabel(c:string){return{politics:'政治',porn:'色情',abuse:'辱骂',custom:'自定义'}[c]||c}
function catColor(c:string){return{politics:'#dc3545',porn:'#e83e8c',abuse:'#fd7e14',custom:'#6c757d'}[c]||'#6c757d'}
</script>
<style scoped></style>

94
frontend/src/views/SystemConfigManager.vue

@ -0,0 +1,94 @@
<template>
<t-card title="🔧 系统配置" :bordered="false">
<p class="desc-text">管理系统级配置项修改后实时生效SDK 窗口刷新即拉取最新内容</p>
<div v-if="!editing">
<div class="toolbar">
<t-button theme="primary" size="small" @click="startAdd"> 新建配置</t-button>
<t-button variant="outline" size="small" @click="load">🔄 刷新</t-button>
</div>
<t-loading v-if="loading" text="加载中..." />
<t-empty v-else-if="!configs.length" description="暂无配置项" />
<div v-else>
<div v-for="item in configs" :key="item.configKey" class="config-item" @click="edit(item)">
<div class="config-left">
<div class="config-key">{{ item.configKey }}</div>
<div class="config-desc">{{ item.description || '无描述' }}</div>
</div>
<span class="config-time">{{ item.updateTime ? formatDate(item.updateTime) : '' }}</span>
<t-button size="small" variant="text" theme="danger" @click.stop="deleteItem(item)">🗑 删除</t-button>
</div>
</div>
</div>
<div v-else>
<div class="edit-header">
<t-button variant="outline" size="small" @click="cancelEdit"> 返回列表</t-button>
<span class="edit-title">{{ isAdd ? '新建' : '编辑' }} {{ editingKey }}</span>
</div>
<t-row :gutter="16" style="margin-bottom:12px;">
<t-col :span="6">
<t-form-item label="配置键 *"><t-input v-model="form.configKey" :disabled="!isAdd" :placeholder="isAdd?'如 disclaimer、welcome_text':''" /></t-form-item>
</t-col>
<t-col :span="6"><t-form-item label="描述说明"><t-input v-model="form.description" placeholder="配置用途说明" /></t-form-item></t-col>
</t-row>
<t-row :gutter="16">
<t-col :span="6">
<t-form-item label="HTML 内容">
<t-textarea v-model="form.configValue" :autosize="{minRows:10,maxRows:20}" placeholder="输入 HTML 内容..." />
</t-form-item>
</t-col>
<t-col :span="6">
<t-form-item label="👁 实时预览">
<div class="preview-box">
<div v-html="form.configValue" />
<div v-if="!form.configValue" class="preview-empty">输入内容后实时显示效果</div>
</div>
</t-form-item>
</t-col>
</t-row>
<div style="display:flex;gap:8px;margin-top:16px;">
<t-button theme="primary" @click="save" :loading="saving">💾 保存</t-button>
<t-button variant="outline" @click="cancelEdit">取消</t-button>
</div>
</div>
</t-card>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { listSystemConfigs, updateSystemConfig, deleteSystemConfig } from '@/api/system-config'
import { toast } from '@/utils/toast'
import { formatDate } from '@/utils/format'
import { useConfirm } from '@/composables/useConfirm'
const { confirm } = useConfirm()
const configs = ref<any[]>([]); const loading = ref(false); const saving = ref(false)
const editing = ref(false); const isAdd = ref(false); const editingKey = ref('')
const editingItem = ref<any>({}); const form = ref({configKey:'',configValue:'',description:''})
load()
async function load(){loading.value=true
try{const r=await listSystemConfigs();if(r.success)configs.value=r.data||[]}catch(e:any){toast(e.message||'加载失败','error')}finally{loading.value=false}}
function edit(item:any){isAdd.value=false;editingKey.value=item.configKey;editingItem.value=item;form.value.configKey=item.configKey;form.value.configValue=item.configValue||'';form.value.description=item.description||'';editing.value=true}
function startAdd(){isAdd.value=true;editingKey.value='';editingItem.value={};form.value.configKey='';form.value.configValue='';form.value.description='';editing.value=true}
function cancelEdit(){editing.value=false;isAdd.value=false;editingKey.value='';editingItem.value={};form.value.configKey='';form.value.configValue='';form.value.description=''}
async function save(){if(saving.value)return
if(isAdd.value){const k=(form.value.configKey||'').trim();if(!k){toast('请输入配置键','error');return};if(!/^[a-zA-Z][a-zA-Z0-9_-]*$/.test(k)){toast('配置键格式不合法','error');return};editingKey.value=k}
saving.value=true
try{const r=await updateSystemConfig(editingKey.value,form.value.configValue,form.value.description||editingItem.value.description||'')
if(r.success){toast('保存成功','success');cancelEdit();load()}else toast(r.message||'保存失败','error')}catch(e:any){toast(e.message||'保存失败','error')}finally{saving.value=false}}
async function deleteItem(item:any){if(!await confirm(`确定删除「${item.configKey}」?`))return
try{const r=await deleteSystemConfig(item.configKey);if(r.success){toast('删除成功','success');load()}else toast(r.message||'删除失败','error')}catch(e:any){toast(e.message||'删除失败','error')}}
</script>
<style scoped>
.config-item{display:flex;align-items:center;justify-content:space-between;padding:14px 16px;border:1px solid #e7e7e7;border-radius:8px;cursor:pointer;margin-bottom:8px;transition:border-color .2s;}.config-item:hover{border-color:var(--td-brand-color);}
.config-left{flex:1;min-width:0;}.config-key{font-weight:600;font-size:14px;margin-bottom:2px;}.config-desc{font-size:12px;color:#999;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}.config-time{font-size:12px;color:#999;margin-left:16px;white-space:nowrap;}
.edit-header{display:flex;align-items:center;gap:8px;margin-bottom:12px;}.edit-title{font-weight:600;font-size:15px;}
.preview-box{border:1px solid #e7e7e7;border-radius:8px;padding:12px;min-height:200px;background:#f9fafb;overflow:auto;}.preview-empty{color:#ccc;font-style:italic;}
</style>

117
frontend/src/views/UserManager.vue

@ -0,0 +1,117 @@
<template>
<t-card title="👥 用户管理" :bordered="false">
<!-- 搜索栏 -->
<div class="toolbar">
<t-input v-model="keyword" placeholder="搜索用户名/昵称/邮箱" size="small" style="width:220px;" @enter="loadData" />
<t-select v-model="filterEnabled" :options="statusOpts" placeholder="全部状态" size="small" style="width:120px;" @change="loadData" />
<t-button size="small" @click="loadData">搜索</t-button>
<div style="flex:1;" />
<t-button theme="primary" size="small" @click="openCreate">+ 新建用户</t-button>
</div>
<t-table :data="users" :columns="columns" row-key="id"
:pagination="{ current: page, total: total, pageSize: pageSize, showJumper: true }" @page-change="onPageChange">
<template #role_names="{ row }">
<t-tag v-for="r in (row.role_names||'').split(',').filter(Boolean)" :key="r" size="small" variant="light" theme="primary" style="margin-right:2px;">{{ r }}</t-tag>
<t-tag v-if="!row.role_names" size="small" variant="light" theme="default">未分配</t-tag>
</template>
<template #enabled="{ row }">
<t-tag size="small" :theme="row.enabled?'success':'danger'" variant="light">{{ row.enabled?'启用':'禁用' }}</t-tag>
</template>
<template #op="{ row }">
<t-space :size="2">
<t-button size="small" variant="text" @click="openEdit(row)">编辑</t-button>
<t-button size="small" variant="text" @click="openRoleAssign(row)">角色</t-button>
<t-button size="small" variant="text" @click="openPasswordChange(row)">改密</t-button>
<t-button size="small" variant="text" :theme="row.enabled?'warning':'success'" @click="handleToggle(row)">{{ row.enabled?'禁用':'启用' }}</t-button>
</t-space>
</template>
</t-table>
<!-- 新建/编辑弹窗 -->
<t-dialog v-model:visible="modal.visible" :header="modal.isEdit?'编辑用户':'新建用户'" width="480px" :footer="false">
<t-form label-align="top">
<t-form-item label="用户名"><t-input v-model="modal.username" :disabled="modal.isEdit" placeholder="登录账号" /></t-form-item>
<t-form-item v-if="!modal.isEdit" label="密码"><t-input v-model="modal.password" type="password" placeholder="初始密码" /></t-form-item>
<t-form-item label="昵称"><t-input v-model="modal.nickname" placeholder="显示昵称" /></t-form-item>
<t-form-item label="邮箱"><t-input v-model="modal.email" placeholder="邮箱地址" /></t-form-item>
<t-form-item label="手机"><t-input v-model="modal.phone" placeholder="手机号" /></t-form-item>
</t-form>
<div class="dialog-footer">
<t-button variant="outline" @click="modal.visible=false">取消</t-button>
<t-button theme="primary" @click="handleSave" :loading="saving">保存</t-button>
</div>
</t-dialog>
<!-- 角色分配弹窗 -->
<t-dialog v-model:visible="roleModal.visible" :header="'分配角色 — '+roleModal.username" width="480px" :footer="false">
<t-checkbox-group v-model="roleModal.selectedRoleIds">
<t-checkbox v-for="r in allRoles" :key="r.id" :value="String(r.id)">{{ r.name }} ({{ r.roleKey }})</t-checkbox>
</t-checkbox-group>
<div class="dialog-footer">
<t-button variant="outline" @click="roleModal.visible=false">取消</t-button>
<t-button theme="primary" @click="handleRoleAssign">保存</t-button>
</div>
</t-dialog>
<!-- 改密弹窗 -->
<t-dialog v-model:visible="pwdModal.visible" :header="'修改密码 — '+pwdModal.username" width="400px" :footer="false">
<t-form label-align="top"><t-form-item label="新密码"><t-input v-model="pwdModal.newPassword" type="password" placeholder="请输入新密码" /></t-form-item></t-form>
<div class="dialog-footer">
<t-button variant="outline" @click="pwdModal.visible=false">取消</t-button>
<t-button theme="primary" @click="handlePasswordChange">确认修改</t-button>
</div>
</t-dialog>
</t-card>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue'
import { listSysUsers, createSysUser, updateSysUser, toggleSysUser, assignSysUserRoles, changeSysUserPassword, getAllSysRoles } from '@/api/user'
import { toast } from '@/utils/toast'
import { formatDate } from '@/utils/format'
import { useConfirm } from '@/composables/useConfirm'
const { confirm } = useConfirm()
const users = ref<any[]>([]); const total=ref(0); const page=ref(1); const pageSize=ref(20)
const keyword=ref(''); const filterEnabled=ref<boolean|null>(null); const allRoles=ref<any[]>([]); const saving=ref(false)
const statusOpts=[{label:'全部状态',value:null},{label:'已启用',value:true},{label:'已禁用',value:false}]
const columns = [
{colKey:'username',title:'用户名',width:100},{colKey:'nickname',title:'昵称',width:80},{colKey:'email',title:'邮箱',width:140},
{colKey:'role_names',title:'角色',width:160},{colKey:'enabled',title:'状态',width:60},{colKey:'last_login_time',title:'最后登录',width:160,cell:(_:any,{row}:any)=>formatDate(row.last_login_time)},
{colKey:'create_time',title:'创建时间',width:160,cell:(_:any,{row}:any)=>formatDate(row.create_time)},{colKey:'op',title:'操作',width:220},
]
const modal = reactive({visible:false,isEdit:false,userId:null as any,username:'',password:'',nickname:'',email:'',phone:''})
const roleModal = reactive({visible:false,userId:null as any,username:'',selectedRoleIds:[] as string[]})
const pwdModal = reactive({visible:false,userId:null as any,username:'',newPassword:''})
onMounted(()=>{loadData();loadRoles()})
async function loadData(){try{const r=await listSysUsers(page.value,pageSize.value,keyword.value,filterEnabled.value as any);if(r.success){users.value=r.data.records||[];total.value=r.data.total||0}}catch(e){console.error(e)}}
async function loadRoles(){try{const r=await getAllSysRoles();if(r.success)allRoles.value=r.data||[]}catch(e){console.error(e)}}
function onPageChange(i:{current:number;pageSize:number}){page.value=i.current;pageSize.value=i.pageSize;loadData()}
function openCreate(){Object.assign(modal,{visible:true,isEdit:false,userId:null,username:'',password:'',nickname:'',email:'',phone:''})}
function openEdit(u:any){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 r;if(modal.isEdit)r=await updateSysUser(modal.userId,{nickname:modal.nickname,email:modal.email,phone:modal.phone})
else{if(!modal.username||!modal.password){toast('用户名和密码不能为空','error');return}r=await createSysUser({username:modal.username,password:modal.password,nickname:modal.nickname,email:modal.email,phone:modal.phone})}
if(r.success){toast(r.message||'操作成功','success');modal.visible=false;loadData()}else toast(r.message||'操作失败','error')}catch(e:any){toast(e.message||'操作失败','error')}finally{saving.value=false}}
async function handleToggle(u:any){if(!await confirm(`确定${u.enabled?'禁用':'启用'}${u.username}」?`))return
try{const r=await toggleSysUser(u.id,!u.enabled);if(r.success){toast(r.message||'操作成功','success');loadData()}else toast(r.message||'操作失败','error')}catch(e:any){toast(e.message||'操作失败','error')}}
function openRoleAssign(u:any){roleModal.userId=u.id;roleModal.username=u.username
const rk=(u.role_keys||'').split(',').filter(Boolean);roleModal.selectedRoleIds=allRoles.value.filter(r=>rk.includes(r.roleKey)).map(r=>String(r.id));roleModal.visible=true}
async function handleRoleAssign(){try{const r=await assignSysUserRoles(roleModal.userId,roleModal.selectedRoleIds);if(r.success){toast('角色分配成功','success');roleModal.visible=false;loadData()}else toast(r.message||'分配失败','error')}catch(e:any){toast(e.message||'分配失败','error')}}
function openPasswordChange(u:any){pwdModal.userId=u.id;pwdModal.username=u.username;pwdModal.newPassword='';pwdModal.visible=true}
async function handlePasswordChange(){if(!pwdModal.newPassword){toast('请输入新密码','error');return}
try{const r=await changeSysUserPassword(pwdModal.userId,pwdModal.newPassword);if(r.success){toast('密码修改成功','success');pwdModal.visible=false}else toast(r.message||'修改失败','error')}catch(e:any){toast(e.message||'修改失败','error')}}
</script>
<style scoped></style>

80
frontend/src/views/WebhookManager.vue

@ -0,0 +1,80 @@
<template>
<t-card title="🔔 Webhook 管理" :bordered="false">
<div class="toolbar">
<t-button theme="primary" size="small" @click="openCreateDialog"> 新建 Webhook</t-button>
<div style="flex:1;" /><span class="total-hint"> {{ total }} </span>
</div>
<t-table :data="webhooks" :columns="columns" row-key="id" :loading="loading"
:pagination="{current:page,total:total,pageSize:pageSize,showJumper:true}" @page-change="onPageChange">
<template #events="{row}">
<t-tag v-for="ev in getEvents(row)" :key="ev" size="small" variant="light" style="margin-right:4px;">{{ ev }}</t-tag>
</template>
<template #enabled="{row}"><span :style="{color:row.enabled?'var(--td-success-color)':'var(--td-error-color)'}">{{ row.enabled?'✅ 启用':'❌ 禁用' }}</span></template>
<template #op="{row}">
<t-space :size="4">
<t-button size="small" variant="text" @click="openEditDialog(row)">编辑</t-button>
<t-button size="small" variant="text" theme="primary" @click="doTest(row.id)">测试</t-button>
<t-button size="small" variant="text" theme="danger" @click="doDelete(row.id)">删除</t-button>
</t-space>
</template>
</t-table>
<t-dialog v-model:visible="showFormDialog" :header="editingId?'编辑 Webhook':'新建 Webhook'" width="520px" :footer="false">
<t-form label-align="top">
<t-form-item label="名称 *"><t-input v-model="form.name" placeholder="如:文档处理通知" /></t-form-item>
<t-form-item label="回调 URL *"><t-input v-model="form.url" placeholder="https://example.com/webhook" /></t-form-item>
<t-form-item label="订阅事件">
<t-checkbox-group v-model="form.events">
<t-checkbox v-for="ev in availableEvents" :key="ev.value" :value="ev.value">
<strong>{{ ev.value }}</strong> {{ ev.label }}
</t-checkbox>
</t-checkbox-group>
</t-form-item>
<t-form-item><t-checkbox v-model="form.enabled">启用</t-checkbox></t-form-item>
</t-form>
<div class="dialog-footer">
<t-button variant="outline" @click="showFormDialog=false">取消</t-button>
<t-button theme="primary" @click="doSave" :disabled="!form.name||!form.url">保存</t-button>
</div>
</t-dialog>
</t-card>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { listWebhooks, createWebhook, updateWebhook, deleteWebhook, testWebhook } from '@/api/webhook'
import { toast } from '@/utils/toast'
import { useConfirm } from '@/composables/useConfirm'
const { confirm } = useConfirm()
const webhooks=ref<any[]>([]);const loading=ref(false);const page=ref(1);const pageSize=ref(20);const total=ref(0)
const showFormDialog=ref(false);const editingId=ref<any>(null)
const form=ref({name:'',url:'',events:[] as string[],enabled:true})
const availableEvents=[{value:'document.ready',label:'文档处理完成'},{value:'document.failed',label:'文档处理失败'},{value:'feedback.new',label:'收到用户反馈'}]
const columns=[{colKey:'name',title:'名称',width:140},{colKey:'url',title:'URL',width:200,ellipsis:true},{colKey:'events',title:'订阅事件',width:200},{colKey:'enabled',title:'状态',width:80},{colKey:'op',title:'操作',width:160}]
onMounted(()=>loadList())
function getEvents(w:any){try{return Array.isArray(w.events)?w.events:typeof w.events==='string'?JSON.parse(w.events):[]}catch{return[]}}
async function loadList(){loading.value=true
try{const r=await listWebhooks(page.value,pageSize.value);if(r.success){webhooks.value=r.data?.records||r.data||[];total.value=r.data?.total||r.total||0}}catch(e:any){toast('加载失败:'+e.message,'error')}finally{loading.value=false}}
function onPageChange(i:{current:number;pageSize:number}){page.value=i.current;pageSize.value=i.pageSize;loadList()}
function openCreateDialog(){editingId.value=null;form.value={name:'',url:'',events:[],enabled:true};showFormDialog.value=true}
function openEditDialog(w:any){editingId.value=w.id;form.value={name:w.name||'',url:w.url||'',events:getEvents(w),enabled:w.enabled};showFormDialog.value=true}
async function doSave(){try{let r;const d={name:form.value.name,url:form.value.url,events:form.value.events,enabled:form.value.enabled}
if(editingId.value)r=await updateWebhook(editingId.value,d);else r=await createWebhook(d)
if(r.success){toast('保存成功','success');showFormDialog.value=false;loadList()}else toast(r.message||'保存失败','error')}catch(e:any){toast('保存失败:'+e.message,'error')}}
async function doTest(id:string){try{const r=await testWebhook(id);if(r.success)toast('测试推送成功','success');else toast(r.message||'测试失败','error')}catch(e:any){toast('测试失败:'+e.message,'error')}}
async function doDelete(id:string){if(!await confirm('确定删除?'))return
try{const r=await deleteWebhook(id);if(r.success){toast('删除成功','success');loadList()}else toast(r.message||'删除失败','error')}catch(e:any){toast('删除失败:'+e.message,'error')}}
</script>
<style scoped></style>

259
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()

192
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()

61
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()

26
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"]
}

78
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',
},
},
})

52
pom.xml

@ -326,6 +326,58 @@
</resources> </resources>
</configuration> </configuration>
</execution> </execution>
<!-- 复制 Vite 构建产物到 Spring Boot 静态资源目录 -->
<execution>
<id>copy-vite-to-static</id>
<phase>prepare-package</phase>
<goals>
<goal>copy-resources</goal>
</goals>
<configuration>
<outputDirectory>${project.basedir}/src/main/resources/static</outputDirectory>
<overwrite>true</overwrite>
<resources>
<resource>
<directory>${project.basedir}/frontend/dist</directory>
</resource>
</resources>
</configuration>
</execution>
</executions>
</plugin>
<!-- ==================== 前端管理后台构建(frontend/ → static/) ==================== -->
<plugin>
<groupId>com.github.eirslett</groupId>
<artifactId>frontend-maven-plugin</artifactId>
<version>1.15.1</version>
<configuration>
<workingDirectory>frontend</workingDirectory>
<skip>${skip.frontend}</skip>
</configuration>
<executions>
<!-- npm install -->
<execution>
<id>install-frontend-npm</id>
<phase>prepare-package</phase>
<goals>
<goal>npm</goal>
</goals>
<configuration>
<arguments>install</arguments>
</configuration>
</execution>
<!-- npm run build(Vite 构建) -->
<execution>
<id>build-frontend-vite</id>
<phase>prepare-package</phase>
<goals>
<goal>npm</goal>
</goals>
<configuration>
<arguments>run build</arguments>
</configuration>
</execution>
</executions> </executions>
</plugin> </plugin>

62
src/main/java/com/wok/supportbot/app/AssistantApp.java

@ -78,16 +78,12 @@ public class AssistantApp {
/** AI 熔断时的降级提示语 */ /** AI 熔断时的降级提示语 */
private static final String CIRCUIT_OPEN_MESSAGE = "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 * 初始化 ChatClient
* *
* @param dashscopeChatModel
* @param chatModelFactory
*/ */
public AssistantApp(ChatModelFactory chatModelFactory, DatabaseChatMemory chatMemory) { public AssistantApp(ChatModelFactory chatModelFactory, DatabaseChatMemory chatMemory) {
this.chatModelFactory = chatModelFactory; this.chatModelFactory = chatModelFactory;
@ -95,7 +91,7 @@ public class AssistantApp {
} }
private ChatClient getChatClient(String appType) { 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<String> effectiveTools; final List<String> effectiveTools;
if (enableMcpTools && (allowedMcpTools == null || allowedMcpTools.isEmpty())) { if (enableMcpTools && (allowedMcpTools == null || allowedMcpTools.isEmpty())) {
effectiveTools = java.util.List.of("*");
effectiveTools = List.of("*");
} else { } else {
effectiveTools = allowedMcpTools; effectiveTools = allowedMcpTools;
} }
@ -205,10 +201,12 @@ public class AssistantApp {
/** /**
* 流式对话新入口委托 {@link ChatPipeline} 编排 * 流式对话新入口委托 {@link ChatPipeline} 编排
* 自动重置 MCP 事件并在流末尾追加工具调用事件行
* <p>
* 注意返回类型为 Flux&lt;String&gt;Spring WebFlux 会对每个元素做 data: 帧包装
* 因此 Flux 元素必须只是纯文本内容不应包含 event: / data: SSE 协议行
* *
* @param ctx 对话上下文 * @param ctx 对话上下文
* @return 流式回答
* @return 纯文本流式回答每个元素为一段自然语言文本
*/ */
public Flux<String> chatStream(ChatContext ctx) { public Flux<String> chatStream(ChatContext ctx) {
// 熔断全局 AI 调用处于熔断状态 // 熔断全局 AI 调用处于熔断状态
@ -218,8 +216,8 @@ public class AssistantApp {
} }
ChatRequest req = chatPipeline.buildRequest(ctx); ChatRequest req = chatPipeline.buildRequest(ctx);
if (req.faqHit()) { 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.resetEvents();
McpToolCallback.resetCallRounds(); McpToolCallback.resetCallRounds();
@ -230,9 +228,8 @@ public class AssistantApp {
if (StringUtils.hasText(req.finalSystemPrompt())) { if (StringUtils.hasText(req.finalSystemPrompt())) {
spec = spec.system(req.finalSystemPrompt()); spec = spec.system(req.finalSystemPrompt());
} }
// 首字节前先发状态事件让前端感知系统正在生成回复
Flux<String> statusEvent = Flux.just(STATUS_EVENT_GENERATING);
return statusEvent.concatWith(appendMcpToolEvents(spec.stream().content()))
// 直接返回纯文本流不附加任何 SSE 协议行
return spec.stream().content()
.doOnComplete(() -> aiCircuitBreaker.recordSuccess(AI_CIRCUIT_KEY)) .doOnComplete(() -> aiCircuitBreaker.recordSuccess(AI_CIRCUIT_KEY))
.doOnError(e -> { .doOnError(e -> {
aiCircuitBreaker.recordFailure(AI_CIRCUIT_KEY); aiCircuitBreaker.recordFailure(AI_CIRCUIT_KEY);
@ -256,38 +253,5 @@ public class AssistantApp {
return chatPipeline.retrieveSources(ctx); return chatPipeline.retrieveSources(ctx);
} }
// ==================== 内部工具方法 ====================
/**
* SSE 文本流末尾追加 MCP 工具调用事件行
* 事件格式遵循 SSE 标准前端由 readSSEStreamWithEvents 解析
*/
private Flux<String> appendMcpToolEvents(Flux<String> contentFlux) {
return contentFlux.concatMap(chunk -> Flux.just(chunk))
.concatWith(Flux.defer(() -> {
java.util.List<McpToolCallback.ToolCallEvent> events = McpToolCallback.drainEvents();
if (events.isEmpty()) {
return Flux.empty();
}
log.info("本次对话触发了 {} 个 MCP 工具调用", events.size());
List<String> 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");
}
// ==================== 内部工具方法已移除不再需要 ====================
} }

64
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.beans.factory.annotation.Autowired;
import org.springframework.core.Ordered; import org.springframework.core.Ordered;
import org.springframework.core.annotation.Order; 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.stereotype.Component;
import org.springframework.util.StringUtils;
import org.springframework.web.filter.OncePerRequestFilter; import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException; import java.io.IOException;
import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Set; import java.util.Set;
@ -24,11 +29,17 @@ import java.util.UUID;
/** /**
* SDK 鉴权过滤器 * SDK 鉴权过滤器
* 拦截 /ai/** 请求校验 SDK JWT Token
* 拦截 /ai/** 请求校验 SDK JWT Token也兼容管理后台 JWT Token
* <p> * <p>
* 优先级高于 ApiKeyAuthFilter SecurityFilterChain 之前执行 * 优先级高于 ApiKeyAuthFilter SecurityFilterChain 之前执行
* 校验通过后提取 allowedRoleIds 用于角色鉴权 * 校验通过后提取 allowedRoleIds 用于角色鉴权
* 校验失败直接返回 401/403请求不会到达 AiController * 校验失败直接返回 401/403请求不会到达 AiController
* <p>
* 兼容两种 Token
* <ol>
* <li>SDK JWT Token /open-api/auth/token 签发使用 jwt.sdk-secret 签名</li>
* <li>管理后台 JWT Token /auth/login 签发使用 jwt.secret 签名管理后台 ChatPanel 使用</li>
* </ol>
*/ */
@Slf4j @Slf4j
@Component @Component
@ -38,6 +49,9 @@ public class SdkAuthFilter extends OncePerRequestFilter {
@Autowired @Autowired
private SdkJwtTokenProvider sdkJwtTokenProvider; private SdkJwtTokenProvider sdkJwtTokenProvider;
@Autowired
private JwtTokenProvider jwtTokenProvider;
private static final ObjectMapper objectMapper = new ObjectMapper(); private static final ObjectMapper objectMapper = new ObjectMapper();
@Override @Override
@ -73,23 +87,57 @@ public class SdkAuthFilter extends OncePerRequestFilter {
return; return;
} }
// JWT 签名验证 + 过期检查
Claims claims;
// 双通道 Token 验证 SDK JWT再管理后台 JWT
Claims claims = null;
boolean isSdkToken = false;
// 通道 ASDK JWT 验证
try { try {
claims = sdkJwtTokenProvider.parseToken(token); claims = sdkJwtTokenProvider.parseToken(token);
isSdkToken = true;
} catch (ExpiredJwtException e) { } catch (ExpiredJwtException e) {
writeError(response, 401, "令牌已过期"); writeError(response, 401, "令牌已过期");
return; return;
} catch (JwtException e) { } 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<String> roles = jwtTokenProvider.getRolesFromToken(token);
List<SimpleGrantedAuthority> 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<Long> allowedRoleIds = sdkJwtTokenProvider.getAllowedRoleIds(claims); Set<Long> allowedRoleIds = sdkJwtTokenProvider.getAllowedRoleIds(claims);
// roleId allowedRoleIds 校验仅当请求携带 roleId 参数时
// roleId allowedRoleIds 校验仅当请求携带 roleId 参数时
String roleIdParam = request.getParameter("roleId"); String roleIdParam = request.getParameter("roleId");
if (roleIdParam != null && !roleIdParam.isEmpty()) { if (roleIdParam != null && !roleIdParam.isEmpty()) {
try { try {

4
src/main/java/com/wok/supportbot/security/SdkJwtTokenProvider.java

@ -37,8 +37,8 @@ public class SdkJwtTokenProvider {
/** 5 分钟下限 */ /** 5 分钟下限 */
private static final long MIN_EXPIRATION = 300000L; 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( public SdkJwtTokenProvider(
@Value("${jwt.sdk-secret:support-bot-sdk-jwt-secret-2026-please-change}") String sdkSecret, @Value("${jwt.sdk-secret:support-bot-sdk-jwt-secret-2026-please-change}") String sdkSecret,

18
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.method.configuration.EnableMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity; 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.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityCustomizer;
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
import org.springframework.security.config.http.SessionCreationPolicy; import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
@ -60,9 +61,6 @@ public class SecurityConfig {
// SDK 专属会话接口 /ai/** 路径下含账户归属校验 // SDK 专属会话接口 /ai/** 路径下含账户归属校验
// Knife4j / Swagger // Knife4j / Swagger
.requestMatchers("/doc.html", "/swagger-ui/**", "/v3/api-docs/**", "/webjars/**").permitAll() .requestMatchers("/doc.html", "/swagger-ui/**", "/v3/api-docs/**", "/webjars/**").permitAll()
// 静态资源
.requestMatchers("/sdk/**", "/css/**", "/js/**", "/components/**").permitAll()
.requestMatchers("/index.html", "/", "/favicon.ico").permitAll()
// ========== 管理接口需要认证 ========== // ========== 管理接口需要认证 ==========
.anyRequest().authenticated() .anyRequest().authenticated()
@ -83,6 +81,20 @@ public class SecurityConfig {
return http.build(); 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 @Bean
public PasswordEncoder passwordEncoder() { public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder(); return new BCryptPasswordEncoder();

8
src/main/java/com/wok/supportbot/service/DocumentService.java

@ -414,7 +414,9 @@ public class DocumentService {
*/ */
public List<Map<String, Object>> getDocumentChunks(Long id) { public List<Map<String, Object>> getDocumentChunks(Long id) {
String documentId = String.valueOf(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' = ? " + "WHERE metadata->>'documentId' = ? " +
" OR metadata->>'document_id' = ? " + " OR metadata->>'document_id' = ? " +
" OR metadata->>'docId' = ? " + " OR metadata->>'docId' = ? " +
@ -446,7 +448,9 @@ public class DocumentService {
return List.of(); 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) + " " + "WHERE " + String.join(" OR ", filters) + " " +
"ORDER BY " + chunkOrderSql(); "ORDER BY " + chunkOrderSql();
if (doc.getChunkCount() != null && doc.getChunkCount() > 0) { if (doc.getChunkCount() != null && doc.getChunkCount() > 0) {

155
src/main/resources/static/components/AccountManager.js

@ -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: `
<div class="card">
<h2>账号管理</h2>
<p style="font-size:12px;color:var(--sub);margin:4px 0 12px;">
为每个账号绑定一个默认角色对话页选择账号后系统会按账号绑定的角色回答并把会话历史归属到该账号
</p>
<div style="border:1px solid var(--border);border-radius:8px;padding:12px;margin-bottom:16px;">
<div class="input-row">
<input class="input" v-model="form.name" placeholder="账号名称 *">
<input class="input" v-model="form.description" placeholder="账号说明(可选)">
<select class="select" v-model="form.roleId">
<option value="">未绑定角色</option>
<option v-for="role in roles" :key="role.id" :value="role.id">{{ role.name }}</option>
</select>
<label class="toggle-line"><input type="checkbox" v-model="form.enabled"><span>启用</span></label>
</div>
<div class="input-row" style="margin-top:8px;">
<button class="btn btn-success" @click="submit">{{ editingId ? '保存修改' : '新增账号' }}</button>
<button v-if="editingId" class="btn btn-outline btn-sm" @click="resetForm">取消编辑</button>
<button class="btn btn-outline btn-sm" @click="reload">刷新</button>
</div>
</div>
<div v-if="!accounts.length" style="font-size:13px;color:var(--sub);">暂无账号</div>
<div v-else style="overflow-x:auto;">
<table class="data-table">
<thead>
<tr>
<th>账号</th>
<th>说明</th>
<th>绑定角色</th>
<th>状态</th>
<th>操作</th>
</tr>
</thead>
<tbody>
<tr v-for="account in accounts" :key="account.id">
<td>
<strong>{{ account.name }}</strong>
<div style="font-size:11px;color:var(--sub);">{{ account.account_key }}</div>
</td>
<td>{{ account.description || '-' }}</td>
<td>{{ account.role_name || '未绑定' }}</td>
<td>{{ account.enabled === false ? '停用' : '启用' }}</td>
<td>
<button class="btn btn-outline btn-sm" @click="startEdit(account)">编辑</button>
<button class="btn btn-danger btn-sm" @click="remove(account)">删除</button>
</td>
</tr>
</tbody>
</table>
</div>
</div>
`,
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 }
}
}

365
src/main/resources/static/components/ApiKeyManager.js

@ -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: `
<div class="card" style="margin-top:16px;">
<h3 style="margin-bottom:12px;">🔑 API Key 管理</h3>
<!-- 操作栏 -->
<div style="display:flex;gap:8px;margin-bottom:12px;flex-wrap:wrap;align-items:center;">
<button @click="openCreateDialog" style="padding:6px 14px;background:var(--primary);color:#fff;border:none;border-radius:6px;cursor:pointer;"> 新建 API Key</button>
<span style="margin-left:auto;font-size:13px;color:#666;"> {{ total }} Key</span>
</div>
<!-- API Key 表格 -->
<table style="width:100%;border-collapse:collapse;">
<thead>
<tr style="border-bottom:2px solid var(--border);">
<th style="text-align:left;padding:8px;">名称</th>
<th style="text-align:left;padding:8px;">Key</th>
<th style="text-align:center;padding:8px;">频率限制</th>
<th style="text-align:center;padding:8px;">调用次数</th>
<th style="text-align:center;padding:8px;">绑定角色</th>
<th style="text-align:center;padding:8px;">过期时间</th>
<th style="text-align:center;padding:8px;">状态</th>
<th style="text-align:right;padding:8px;">操作</th>
</tr>
</thead>
<tbody>
<tr v-if="loading"><td colspan="8" style="text-align:center;padding:20px;">加载中...</td></tr>
<tr v-else-if="keys.length === 0"><td colspan="8" style="text-align:center;padding:20px;color:#999;">暂无 API Key</td></tr>
<tr v-for="k in keys" :key="k.id" style="border-bottom:1px solid var(--border);">
<td style="padding:8px;">
<div style="font-weight:500;">{{ k.name || '未命名' }}</div>
<div v-if="k.description" style="font-size:12px;color:#999;margin-top:2px;">{{ k.description }}</div>
</td>
<td style="padding:8px;">
<div style="display:flex;align-items:center;gap:6px;">
<code style="font-size:12px;background:var(--bg);padding:2px 6px;border-radius:4px;">{{ maskKey(k.keyValue) }}</code>
<button @click="copyKey(k.keyValue)" title="复制完整 Key" style="padding:2px 6px;background:none;border:1px solid var(--border);border-radius:4px;cursor:pointer;font-size:12px;">📋</button>
</div>
</td>
<td style="text-align:center;padding:8px;">{{ k.rateLimit || 60 }} /分钟</td>
<td style="text-align:center;padding:8px;">
{{ k.currentCalls || 0 }}
<span v-if="k.maxCalls" style="color:#999;">/ {{ k.maxCalls }}</span>
<span v-else style="color:#999;">不限</span>
</td>
<td style="text-align:center;padding:8px;font-size:12px;">
<span v-if="getBoundRoleCount(k.roleIds) > 0" style="color:var(--primary);">{{ getBoundRoleCount(k.roleIds) }} 个角色</span>
<span v-else style="color:#999;">不限全部</span>
</td>
<td style="text-align:center;padding:8px;font-size:12px;">
{{ k.expireTime ? formatDate(k.expireTime) : '永不过期' }}
</td>
<td style="text-align:center;padding:8px;">
<span :style="{color: k.enabled ? '#28a745' : '#dc3545', cursor:'pointer'}">
{{ k.enabled ? '✅ 有效' : '❌ 已吊销' }}
</span>
</td>
<td style="text-align:right;padding:8px;">
<button @click="openRoleDialog(k)" style="padding:3px 8px;background:none;border:1px solid var(--primary);color:var(--primary);border-radius:4px;cursor:pointer;margin-right:4px;font-size:12px;">绑定角色</button>
<button v-if="k.enabled" @click="doRevoke(k.id)" style="padding:3px 8px;background:none;border:1px solid #dc3545;color:#dc3545;border-radius:4px;cursor:pointer;margin-right:4px;font-size:12px;">吊销</button>
<button v-else @click="doEnable(k.id)" style="padding:3px 8px;background:none;border:1px solid #28a745;color:#28a745;border-radius:4px;cursor:pointer;margin-right:4px;font-size:12px;">启用</button>
<button @click="doDelete(k.id)" style="padding:3px 8px;background:none;border:1px solid #dc3545;color:#dc3545;border-radius:4px;cursor:pointer;font-size:12px;">删除</button>
</td>
</tr>
</tbody>
</table>
<!-- 分页 -->
<div v-if="total > pageSize" style="display:flex;justify-content:center;gap:8px;margin-top:12px;">
<button @click="page > 1 && (page--, loadList())" :disabled="page <= 1" style="padding:4px 10px;">上一页</button>
<span style="line-height:32px;"> {{ page }} / {{ Math.ceil(total / pageSize) }} {{ total }} </span>
<button @click="page < Math.ceil(total / pageSize) && (page++, loadList())" :disabled="page >= Math.ceil(total / pageSize)" style="padding:4px 10px;">下一页</button>
</div>
<!-- 新建弹窗 -->
<div v-if="showCreateDialog" style="position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,.5);display:flex;align-items:center;justify-content:center;z-index:1000;">
<div style="background:var(--card);border-radius:12px;padding:24px;width:520px;max-width:90vw;max-height:90vh;overflow-y:auto;">
<h4 style="margin-bottom:16px;">新建 API Key</h4>
<div style="margin-bottom:12px;">
<label style="display:block;margin-bottom:4px;font-size:13px;">名称 *</label>
<input v-model="form.name" placeholder="如:生产环境 Key" style="width:100%;padding:8px;border:1px solid var(--border);border-radius:6px;box-sizing:border-box;" />
</div>
<div style="margin-bottom:12px;">
<label style="display:block;margin-bottom:4px;font-size:13px;">描述</label>
<input v-model="form.description" placeholder="用途说明(可选)" style="width:100%;padding:8px;border:1px solid var(--border);border-radius:6px;box-sizing:border-box;" />
</div>
<div style="display:flex;gap:8px;margin-bottom:12px;">
<div style="flex:1;">
<label style="display:block;margin-bottom:4px;font-size:13px;">频率限制/分钟</label>
<input v-model.number="form.rateLimit" type="number" min="1" placeholder="60" style="width:100%;padding:8px;border:1px solid var(--border);border-radius:6px;box-sizing:border-box;" />
</div>
<div style="flex:1;">
<label style="display:block;margin-bottom:4px;font-size:13px;">最大调用次数</label>
<input v-model.number="form.maxCalls" type="number" min="1" placeholder="不限" style="width:100%;padding:8px;border:1px solid var(--border);border-radius:6px;box-sizing:border-box;" />
</div>
</div>
<div style="margin-bottom:12px;">
<label style="display:block;margin-bottom:4px;font-size:13px;">过期时间可选</label>
<input v-model="form.expireTime" type="datetime-local" style="width:100%;padding:8px;border:1px solid var(--border);border-radius:6px;box-sizing:border-box;" />
</div>
<div style="margin-bottom:12px;">
<label style="display:block;margin-bottom:4px;font-size:13px;">绑定客服角色可选不选则不限制</label>
<div v-if="allRoles.length === 0" style="font-size:12px;color:#999;padding:4px 0;">暂无可用角色</div>
<div v-for="role in allRoles" :key="role.id" style="display:inline-flex;align-items:center;gap:4px;margin:2px 8px 2px 0;font-size:13px;">
<label style="display:inline-flex;align-items:center;gap:4px;cursor:pointer;">
<input type="checkbox" :value="Number(role.id)" v-model="form.roleIds" />
{{ role.name }} <span style="color:#999;font-size:11px;">({{ role.role_key }})</span>
</label>
</div>
</div>
<div style="display:flex;justify-content:flex-end;gap:8px;">
<button @click="showCreateDialog = false" style="padding:8px 16px;background:none;border:1px solid var(--border);border-radius:6px;cursor:pointer;">取消</button>
<button @click="doCreate" :disabled="!form.name" style="padding:8px 16px;background:var(--primary);color:#fff;border:none;border-radius:6px;cursor:pointer;">创建</button>
</div>
</div>
</div>
<!-- 创建成功提示弹窗显示完整 Key -->
<div v-if="showKeyReveal" style="position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,.5);display:flex;align-items:center;justify-content:center;z-index:1001;">
<div style="background:var(--card);border-radius:12px;padding:24px;width:520px;max-width:90vw;">
<h4 style="margin-bottom:8px;"> API Key 已创建</h4>
<p style="color:#dc3545;font-size:13px;margin-bottom:12px;"> 请立即复制保存关闭后将无法再次查看完整 Key</p>
<div style="background:var(--bg);padding:12px;border-radius:6px;font-family:monospace;word-break:break-all;font-size:13px;margin-bottom:12px;">
{{ revealedKey }}
</div>
<div style="display:flex;justify-content:flex-end;gap:8px;">
<button @click="copyKey(revealedKey)" style="padding:8px 16px;background:var(--primary);color:#fff;border:none;border-radius:6px;cursor:pointer;">📋 复制 Key</button>
<button @click="showKeyReveal = false" style="padding:8px 16px;background:none;border:1px solid var(--border);border-radius:6px;cursor:pointer;">关闭</button>
</div>
</div>
</div>
<!-- 角色绑定弹窗 -->
<div v-if="showRoleDialog" style="position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,.5);display:flex;align-items:center;justify-content:center;z-index:1000;">
<div style="background:var(--card);border-radius:12px;padding:24px;width:520px;max-width:90vw;max-height:90vh;overflow-y:auto;">
<h4 style="margin-bottom:8px;">绑定客服角色 {{ editingKeyName }}</h4>
<p style="color:#666;font-size:13px;margin-bottom:16px;">勾选允许此 API Key 使用的客服角色不勾选任何角色则不限制可使用所有角色</p>
<div v-if="roleLoading" style="text-align:center;padding:20px;color:#999;">加载角色列表...</div>
<div v-else-if="allRoles.length === 0" style="text-align:center;padding:20px;color:#999;">暂无可用角色请先创建客服角色</div>
<div v-else>
<div v-for="role in allRoles" :key="role.id"
style="display:flex;align-items:center;gap:8px;padding:10px 12px;margin-bottom:4px;border:1px solid var(--border);border-radius:6px;">
<label style="display:inline-flex;align-items:center;gap:4px;cursor:pointer;flex:1;">
<input type="checkbox" v-model="selectedRoleIds" :value="Number(role.id)" />
<div>
<div style="font-weight:500;font-size:14px;">{{ role.name }}</div>
<div style="font-size:12px;color:#999;">{{ role.role_key }}<span v-if="role.description"> · {{ role.description }}</span></div>
</div>
</label>
</div>
</div>
<div style="display:flex;justify-content:space-between;align-items:center;margin-top:16px;">
<span style="font-size:12px;color:#999;">已选 {{ selectedRoleIds.length }} 个角色{{ selectedRoleIds.length === 0 ? '(不限制)' : '' }}</span>
<div style="display:flex;gap:8px;">
<button @click="showRoleDialog = false" style="padding:8px 16px;background:none;border:1px solid var(--border);border-radius:6px;cursor:pointer;">取消</button>
<button @click="doSaveRoles" style="padding:8px 16px;background:var(--primary);color:#fff;border:none;border-radius:6px;cursor:pointer;">保存</button>
</div>
</div>
</div>
</div>
</div>
`,
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
}
}
}

103
src/main/resources/static/components/AuditLogManager.js

@ -1,103 +0,0 @@
/**
* 内容审计日志组件
* 展示敏感词命中记录支持分页浏览
*/
import { listAuditLogs } from '../js/api.js'
import { toast } from '../js/utils.js'
export default {
template: `
<div class="card">
<h2>📋 内容审计日志</h2>
<p style="font-size:13px;color:var(--sub);margin-bottom:16px;">记录所有敏感词命中事件包含输入/输出方向违规内容命中词及处理方式</p>
<!-- 表格 -->
<div style="overflow-x:auto;">
<table class="data-table">
<thead>
<tr>
<th>时间</th>
<th>方向</th>
<th>违规内容</th>
<th>命中词</th>
<th>处理方式</th>
</tr>
</thead>
<tbody>
<tr v-if="loading"><td colspan="5" style="text-align:center;padding:20px;">加载中...</td></tr>
<tr v-else-if="logs.length === 0"><td colspan="5" style="text-align:center;padding:20px;color:var(--sub);">暂无审计日志</td></tr>
<tr v-for="log in logs" :key="log.id">
<td style="white-space:nowrap;">{{ formatTime(log.createTime) }}</td>
<td style="text-align:center;">
<span :style="{color: log.direction === 'INPUT' ? '#007bff' : '#28a745'}">
{{ log.direction === 'INPUT' ? '输入' : '输出' }}
</span>
</td>
<td style="max-width:260px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;" :title="log.originalText">{{ log.originalText }}</td>
<td style="font-size:12px;">{{ formatHitWords(log.hitWords) }}</td>
<td style="text-align:center;">
<span :style="{color: log.actionTaken === 'BLOCK' ? '#dc3545' : log.actionTaken === 'MASK' ? '#ffc107' : '#28a745'}">
{{ log.actionTaken === 'BLOCK' ? '拦截' : log.actionTaken === 'MASK' ? '脱敏' : '通过' }}
</span>
</td>
</tr>
</tbody>
</table>
</div>
<!-- 分页 -->
<div v-if="total > pageSize" style="display:flex;justify-content:center;gap:8px;margin-top:12px;">
<button @click="page > 1 && (page--, loadLogs())" :disabled="page <= 1" style="padding:4px 10px;">上一页</button>
<span style="line-height:32px;"> {{ page }} / {{ Math.ceil(total / pageSize) }} {{ total }} </span>
<button @click="page < Math.ceil(total / pageSize) && (page++, loadLogs())" :disabled="page >= Math.ceil(total / pageSize)" style="padding:4px 10px;">下一页</button>
</div>
</div>
`,
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)
}
}
}

322
src/main/resources/static/components/CategoryManager.js

@ -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: `
<div class="card">
<h2>分类管理</h2>
<!-- 创建新分类 -->
<div style="padding:12px;background:#fafafa;border-radius:8px;border:1px solid var(--border);margin-bottom:16px;">
<div style="font-weight:600;font-size:13px;margin-bottom:8px;">创建新分类</div>
<div class="input-row" style="margin-bottom:0;">
<select class="select" v-model="newParentId" style="max-width:180px;">
<option value="">顶级分类</option>
<option v-for="c in parentOptions" :key="c.id" :value="c.id">
{{ parentLabel(c) }}
</option>
</select>
<input class="input" v-model="newName" placeholder="分类名称" @keyup.enter="create">
<input class="input" v-model="newDescription" placeholder="描述(可选)">
<input class="input input-sm" v-model.number="newSortOrder" placeholder="排序" type="number" style="max-width:80px;">
<button class="btn btn-success" @click="create">创建</button>
</div>
</div>
<!-- 分类树列表 -->
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:8px;">
<span style="font-size:13px;color:var(--sub);"> {{ store.categories.length }} 个分类</span>
<button class="btn btn-outline btn-sm" @click="store.loadCategories()">刷新</button>
</div>
<div v-if="flatTree.length === 0" style="padding:20px;text-align:center;color:var(--sub);font-size:13px;">暂无分类请在上方创建</div>
<div v-else>
<div v-for="node in flatTree" :key="node.id"
style="padding:10px 12px;border-bottom:1px solid var(--border);display:flex;justify-content:space-between;align-items:center;"
:style="{ paddingLeft: (node.level * 24 + 12) + 'px' }">
<!-- 左侧展开/折叠 + 名称 + 描述 -->
<div style="display:flex;align-items:center;gap:6px;min-width:0;flex:1;">
<!-- 展开/折叠按钮 -->
<span v-if="node.hasChildren" @click="toggleExpand(node.id)"
style="cursor:pointer;user-select:none;font-size:12px;width:16px;text-align:center;color:var(--sub);">
{{ expandedIds.has(node.id) ? '▼' : '▶' }}
</span>
<span v-else style="width:16px;"></span>
<!-- 分类名称和描述 -->
<div style="min-width:0;">
<strong style="font-size:13px;">{{ node.name }}</strong>
<span v-if="node.description" style="color:var(--sub);font-size:12px;margin-left:8px;">{{ node.description }}</span>
<span style="color:var(--sub);font-size:11px;margin-left:8px;">
({{ node.documentCount || 0 }} 篇文档)
</span>
</div>
</div>
<!-- 右侧操作按钮 -->
<div style="display:flex;gap:4px;flex-shrink:0;">
<button class="btn btn-sm btn-outline" @click="openEdit(node)">编辑</button>
<button class="btn btn-sm btn-danger" @click="remove(node)">删除</button>
</div>
</div>
</div>
<!-- 编辑弹窗 -->
<div v-if="editModal.visible" style="position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,0.4);display:flex;align-items:center;justify-content:center;z-index:1000;" @click.self="closeEdit">
<div style="background:white;border-radius:12px;padding:24px;min-width:400px;max-width:500px;box-shadow:0 20px 60px rgba(0,0,0,0.15);">
<h3 style="margin:0 0 16px 0;font-size:16px;">编辑分类</h3>
<div style="display:flex;flex-direction:column;gap:12px;">
<div>
<label style="font-size:12px;color:var(--sub);display:block;margin-bottom:4px;">分类名称</label>
<input class="input" v-model="editModal.name" placeholder="分类名称" @keyup.enter="saveEdit">
</div>
<div>
<label style="font-size:12px;color:var(--sub);display:block;margin-bottom:4px;">描述</label>
<input class="input" v-model="editModal.description" placeholder="描述(可选)">
</div>
<div>
<label style="font-size:12px;color:var(--sub);display:block;margin-bottom:4px;">排序权重</label>
<input class="input input-sm" v-model.number="editModal.sortOrder" type="number" style="max-width:120px;">
</div>
</div>
<div style="display:flex;justify-content:flex-end;gap:8px;margin-top:20px;">
<button class="btn btn-outline" @click="closeEdit">取消</button>
<button class="btn btn-primary" @click="saveEdit">保存</button>
</div>
</div>
</div>
</div>
`,
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
}
}
}

529
src/main/resources/static/components/ChatPanel.js

@ -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: `
<div class="chat-shell">
<aside class="chat-sidebar">
<div class="agent-card">
<div class="agent-avatar">SB</div>
<div>
<div class="agent-name">Support Bot</div>
<div class="agent-status"><span></span>线</div>
</div>
</div>
<div class="side-section side-section-grow">
<div class="side-label">客服助手</div>
<div class="assistant-list">
<button
v-for="role in roles"
:key="role.key"
:class="['assistant-card', selectedRole === role.key ? 'active' : '']"
@click="selectRole(role.key)"
>
<span class="assistant-avatar-sm">{{ role.badge }}</span>
<span class="assistant-meta">
<strong>{{ role.name }}</strong>
<small>{{ role.desc }}</small>
</span>
</button>
</div>
</div>
</aside>
<section class="chat-main">
<header class="chat-header">
<div class="chat-title">
<h2>{{ currentRole.name }}</h2>
<div class="chat-subline">
<span :class="['rag-dot', isRagMode ? 'on' : '']"></span>{{ ragStatusText }}
<span class="dot-sep">&middot;</span>{{ selectedCategoryNames.length ? selectedCategoryNames.join(String.fromCharCode(12289)) : '\u5168\u90e8\u77e5\u8bc6\u5e93' }}
</div>
</div>
<div class="chat-actions">
<div class="model-chip" :class="{'model-chip-error': modelLoadError}" :title="modelTitle">
<span>调用模型</span>
<strong>{{ activeModelText }}</strong>
</div>
<label class="rag-toggle">
<input type="checkbox" v-model="isRagMode">
<span>RAG &#x68C0;&#x7D22;</span>
</label>
<select v-if="isRagMode" class="select rag-strategy" v-model="ragStrategy">
<option value="NONE">&#x4E0D;&#x91CD;&#x5199;</option>
<option value="REWRITE">&#x67E5;&#x8BE2;&#x91CD;&#x5199;</option>
<option value="TRANSLATION">&#x7FFB;&#x8BD1;&#x6269;&#x5C55;</option>
<option value="COMPRESSION">&#x67E5;&#x8BE2;&#x538B;&#x7F29;</option>
<option value="MULTI_QUERY">&#x591A;&#x8DEF;&#x6269;&#x5C55;</option>
</select>
<select class="select chat-mode" v-model="mode">
<option value="sync">&#x540C;&#x6B65;&#x8C03;&#x7528;</option>
<option value="sse">SSE &#x6D41;&#x5F0F;</option>
</select>
</div>
</header>
<div class="quick-row" v-if="messages.length <= 1">
<button v-for="q in quickQuestions" :key="q" @click="useQuickQuestion(q)">{{ q }}</button>
</div>
<div class="msg-area chat-msg-area" ref="msgArea">
<div v-for="(m, i) in messages" :key="i" :class="['msg', m.role, m.streaming ? 'streaming' : '']">
<div class="msg-content">
<template v-if="editingIndex === i">
<textarea class="textarea edit-textarea" v-model="editingText" rows="2"
@keydown.enter.exact.prevent="submitEdit(i)"></textarea>
<div class="edit-actions">
<button class="btn btn-primary btn-sm" @click="submitEdit(i)" :disabled="isSending || !editingText.trim()">重新发送</button>
<button class="btn btn-outline btn-sm" @click="cancelEdit">取消</button>
<span>将清除此条之后的全部对话并重发</span>
</div>
</template>
<template v-else>
<!-- MCP 工具调用展示 -->
<div v-if="m.role === 'assistant' && m.toolCalls && m.toolCalls.length" class="mcp-tool-calls">
<div v-for="(tc, j) in m.toolCalls" :key="j" class="mcp-tool-item">
<div class="mcp-tool-header">
<span class="mcp-tool-icon">{{ tc.status === 'running' ? '⏳' : '✅' }}</span>
<span class="mcp-tool-name">{{ tc.tool }}</span>
<span v-if="tc.latencyMs" class="mcp-tool-latency">{{ tc.latencyMs }}ms</span>
</div>
</div>
</div>
<div v-if="m.role === 'assistant' && m.content" class="msg-bubble markdown-body" v-html="renderMd(m.content)"></div>
<div v-else-if="m.role === 'assistant'" class="msg-bubble"><span class="thinking">正在思考</span></div>
<div v-else class="msg-bubble">{{ m.content }}</div>
<message-sources v-if="m.role === 'assistant' && m.sources && m.sources.length" :sources="m.sources"></message-sources>
<div class="msg-tools">
<span>{{ m.time }}</span>
<button v-if="m.role === 'assistant' && m.content" @click="copyMessage(m.content)">复制</button>
<button v-if="m.role === 'assistant' && m.content && !m.streaming" @click="regenerate(i)">重新生成</button>
<button v-if="m.role === 'user' && !m.streaming" @click="startEditMessage(i)">编辑</button>
<button v-if="m.error" @click="retryLast">重试</button>
<template v-if="m.role === 'assistant' && m.content && !m.streaming">
<button @click="submitFeedback(m.id, 'up')" :style="{color: m.feedback === 'up' ? 'var(--primary)' : ''}" title="有帮助">👍</button>
<button @click="submitFeedback(m.id, 'down')" :style="{color: m.feedback === 'down' ? '#dc3545' : ''}" title="没帮助">👎</button>
</template>
</div>
</template>
</div>
</div>
</div>
<footer class="chat-composer">
<div class="composer-box">
<textarea
class="chat-textarea"
v-model="userInput"
placeholder="输入问题,Enter 发送,Shift + Enter 换行"
@keydown.enter.exact.prevent="send"
:disabled="isSending"
></textarea>
<button class="send-btn" @click="send" :disabled="isSending || !userInput.trim()">
{{ isSending ? '发送中' : '发送' }}
</button>
</div>
</footer>
</section>
</div>
`,
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
}
}
}

255
src/main/resources/static/components/ConversationManager.js

@ -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: `
<div class="card">
<h2>会话列表</h2>
<!-- 统计卡片 -->
<div class="stat-grid" v-if="stats" style="margin-bottom:16px;">
<div class="stat-card">
<div class="number">{{ stats.totalConversations || 0 }}</div>
<div class="label">总会话数</div>
</div>
<div class="stat-card">
<div class="number">{{ stats.totalMessages || 0 }}</div>
<div class="label">总消息数</div>
</div>
<div class="stat-card">
<div class="number">{{ stats.todayConversations || 0 }}</div>
<div class="label">今日会话</div>
</div>
<div class="stat-card">
<div class="number">{{ stats.todayMessages || 0 }}</div>
<div class="label">今日消息</div>
</div>
</div>
<!-- 搜索栏 -->
<div class="input-row">
<input type="text" class="input" v-model="keyword" placeholder="搜索会话ID或消息内容..." @keyup.enter="load(1)">
<input type="text" class="input" v-model="accountFilter" placeholder="外部用户ID(accountId)" @keyup.enter="load(1)">
<select class="select" v-model="roleFilter">
<option value="">全部角色</option>
<option v-for="role in roles" :key="role.id" :value="role.id">{{ role.name }}</option>
</select>
<button class="btn btn-primary btn-sm" @click="load(1)">搜索</button>
<button class="btn btn-outline btn-sm" @click="load()">刷新</button>
</div>
<!-- 会话列表表格 -->
<div style="overflow-x:auto;">
<table class="data-table">
<thead>
<tr>
<th>会话ID</th>
<th>外部用户ID</th>
<th>角色</th>
<th>消息数</th>
<th>最后消息时间</th>
<th>最后消息预览</th>
<th>操作</th>
</tr>
</thead>
<tbody>
<tr v-if="conversations.length === 0">
<td colspan="7" style="text-align:center;color:var(--sub);">暂无会话记录</td>
</tr>
<tr v-for="c in conversations" :key="c.conversationId">
<td>
<code style="font-size:12px;background:#f3f4f6;padding:2px 6px;border-radius:4px;">{{ c.conversationId }}</code>
</td>
<td>{{ c.accountId || '-' }}</td>
<td>{{ c.roleName || '-' }}</td>
<td>{{ c.messageCount }}</td>
<td>{{ formatDate(c.lastMessageTime) }}</td>
<td style="max-width:300px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">
{{ c.lastMessagePreview || '-' }}
</td>
<td>
<button class="btn btn-sm btn-outline" @click="viewMessages(c.conversationId)">查看消息</button>
<button class="btn btn-sm btn-primary" @click="downloadExport(c.conversationId)">导出</button>
<button class="btn btn-sm btn-danger" @click="remove(c.conversationId)">删除</button>
</td>
</tr>
</tbody>
</table>
</div>
<!-- 分页 -->
<div class="pagination" v-if="pages > 1">
<button :disabled="page <= 1" @click="load(page - 1)">上一页</button>
<template v-for="i in pages" :key="i">
<button v-if="i === 1 || i === pages || (i >= page - 2 && i <= page + 2)"
:class="{ active: i === page }" @click="load(i)">{{ i }}</button>
<span v-else-if="i === page - 3 || i === page + 3" style="padding:6px;">...</span>
</template>
<button :disabled="page >= pages" @click="load(page + 1)">下一页</button>
</div>
</div>
<!-- 消息详情弹窗 -->
<div class="modal-overlay" :class="{ active: messageModal.visible }" @click.self="closeMessageModal">
<div class="modal-box" style="max-width:900px;">
<button class="modal-close" @click="closeMessageModal">×</button>
<h2>会话消息详情</h2>
<p style="font-size:13px;color:var(--sub);margin-bottom:12px;">
会话ID: <code>{{ messageModal.conversationId }}</code>
<span v-if="messageModal.roleName"> · 角色{{ messageModal.roleName }}</span>
· {{ messageModal.messages.length }} 条消息
</p>
<div v-if="messageModal.messages.length === 0" style="text-align:center;color:var(--sub);padding:40px;">
暂无消息
</div>
<div v-else class="message-list" style="max-height:500px;overflow-y:auto;">
<div v-for="msg in messageModal.messages" :key="msg.id"
:class="['msg-item', msg.messageType === 'USER' ? 'msg-user' : msg.messageType === 'ASSISTANT' ? 'msg-assistant' : 'msg-system']"
style="margin-bottom:12px;padding:12px;border-radius:8px;">
<div style="display:flex;align-items:center;gap:8px;margin-bottom:6px;">
<span class="badge" :class="msg.messageType === 'USER' ? 'badge-get' : msg.messageType === 'ASSISTANT' ? 'badge-post' : ''">
{{ msg.messageType === 'USER' ? '用户' : msg.messageType === 'ASSISTANT' ? 'AI助手' : '系统' }}
</span>
<span style="font-size:11px;color:var(--sub);">{{ formatDate(msg.createTime) }}</span>
</div>
<div style="font-size:13px;line-height:1.6;white-space:pre-wrap;word-break:break-word;">{{ msg.content }}</div>
</div>
</div>
</div>
</div>
`,
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
}
}
}

340
src/main/resources/static/components/DashboardPanel.js

@ -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: `
<div class="dashboard-panel">
<!-- 时间范围选择器 -->
<div class="card">
<div style="display:flex; align-items:center; justify-content:space-between; flex-wrap:wrap; gap:12px;">
<h2 style="margin:0;">📈 运营数据看板</h2>
<div style="display:flex; align-items:center; gap:8px;">
<button class="btn btn-sm" :class="rangeDays === 7 ? 'btn-primary' : 'btn-outline'" @click="setRange(7)">近7天</button>
<button class="btn btn-sm" :class="rangeDays === 30 ? 'btn-primary' : 'btn-outline'" @click="setRange(30)">近30天</button>
<span style="color:var(--muted); font-size:13px;">|</span>
<input type="date" class="input" style="width:140px; flex:none; padding:6px 10px; font-size:13px;" v-model="customStart" />
<span style="color:var(--muted); font-size:13px;"></span>
<input type="date" class="input" style="width:140px; flex:none; padding:6px 10px; font-size:13px;" v-model="customEnd" />
<button class="btn btn-sm btn-purple" @click="loadCustomRange" :disabled="!customStart || !customEnd">查询</button>
</div>
</div>
</div>
<!-- 4个指标卡片 -->
<div class="stat-grid">
<div class="stat-card">
<div class="number">{{ overview.conversationCount ?? 0 }}</div>
<div class="label">今日对话数</div>
</div>
<div class="stat-card">
<div class="number" :style="{ color: satisfactionColor }">{{ formatPercent(overview.satisfactionRate) }}%</div>
<div class="label">满意率</div>
</div>
<div class="stat-card">
<div class="number" style="color: var(--success);">{{ formatPercent(overview.ragHitRate) }}%</div>
<div class="label">知识库命中率</div>
</div>
<div class="stat-card">
<div class="number">{{ overview.avgResponseTime ?? 0 }}<span style="font-size:14px; color:var(--muted);">ms</span></div>
<div class="label">平均响应时间</div>
</div>
</div>
<!-- 图表区域 -->
<div style="display:grid; grid-template-columns:1fr 1fr; gap:16px; margin-bottom:16px;">
<!-- 对话量趋势折线图 -->
<div class="card">
<h3 style="font-size:14px; font-weight:600; color:var(--text); margin-bottom:12px;">📊 对话量趋势</h3>
<div style="position:relative; height:260px;">
<canvas ref="conversationChartRef"></canvas>
</div>
</div>
<!-- 满意度趋势折线图 -->
<div class="card">
<h3 style="font-size:14px; font-weight:600; color:var(--text); margin-bottom:12px;">📊 满意度趋势</h3>
<div style="position:relative; height:260px;">
<canvas ref="satisfactionChartRef"></canvas>
</div>
</div>
</div>
<!-- 知识库分析 -->
<div style="display:grid; grid-template-columns:1fr 1fr; gap:16px;">
<!-- 文档命中排行 TOP-10 -->
<div class="card">
<h3 style="font-size:14px; font-weight:600; color:var(--text); margin-bottom:12px;">📚 文档命中排行 TOP-10</h3>
<div v-if="topHitDocuments.length === 0" class="empty" style="padding:30px;">暂无数据</div>
<table v-else style="width:100%; font-size:13px; border-collapse:collapse;">
<thead>
<tr style="border-bottom:1px solid var(--border);">
<th style="text-align:left; padding:8px 4px; color:var(--sub); font-weight:600;">排名</th>
<th style="text-align:left; padding:8px 4px; color:var(--sub); font-weight:600;">文档标题</th>
<th style="text-align:right; padding:8px 4px; color:var(--sub); font-weight:600;">命中次数</th>
<th style="text-align:right; padding:8px 4px; color:var(--sub); font-weight:600;">平均得分</th>
</tr>
</thead>
<tbody>
<tr v-for="(doc, idx) in topHitDocuments" :key="idx" style="border-bottom:1px solid var(--border);">
<td style="padding:8px 4px; color:var(--muted);">{{ idx + 1 }}</td>
<td style="padding:8px 4px; max-width:200px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap;" :title="doc.document_title">{{ doc.document_title || '-' }}</td>
<td style="text-align:right; padding:8px 4px; font-weight:600; color:var(--primary);">{{ doc.hit_count ?? doc.cnt ?? 0 }}</td>
<td style="text-align:right; padding:8px 4px; color:var(--sub);">{{ doc.avg_score != null ? Number(doc.avg_score).toFixed(3) : '-' }}</td>
</tr>
</tbody>
</table>
</div>
<!-- 未命中问题收集 -->
<div class="card">
<h3 style="font-size:14px; font-weight:600; color:var(--text); margin-bottom:12px;"> 未命中问题收集</h3>
<div v-if="missQuestions.length === 0" class="empty" style="padding:30px;">暂无数据</div>
<div v-else style="max-height:400px; overflow-y:auto;">
<div v-for="(item, idx) in missQuestions" :key="idx"
style="padding:8px 12px; border-bottom:1px solid var(--border); font-size:13px; display:flex; align-items:center; gap:8px;">
<span style="color:var(--muted); flex:none; min-width:20px;">{{ idx + 1 }}.</span>
<span style="flex:1; word-break:break-all;">{{ item.user_query }}</span>
<span v-if="item.last_time" style="color:var(--muted); font-size:11px; flex:none;">{{ formatDateShort(item.last_time) }}</span>
</div>
</div>
</div>
</div>
<!-- 加载状态 -->
<div v-if="loading" style="text-align:center; padding:20px; color:var(--muted);">加载中...</div>
</div>
`,
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
}
}
}

186
src/main/resources/static/components/DocDetail.js

@ -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: `
<div :class="['modal-overlay', store.detailModal.visible ? 'active' : '']" @click.self="store.closeDetail()">
<div class="modal-box" style="max-width:900px;">
<button class="modal-close" @click="store.closeDetail()">&times;</button>
<h2>文档详情</h2>
<template v-if="store.detailModal.doc">
<div style="margin-bottom:16px;">
<div style="display:grid;grid-template-columns:1fr 1fr;gap:12px;margin-bottom:16px;">
<div class="result-item"><div class="label">文档标题</div><div class="value">{{ store.detailModal.doc.title }}</div></div>
<div class="result-item"><div class="label">原始文件名</div><div class="value">{{ store.detailModal.doc.sourceName || '-' }}</div></div>
<div class="result-item"><div class="label">文件类型</div><div class="value">{{ store.detailModal.doc.fileType }}</div></div>
<div class="result-item"><div class="label">文件大小</div><div class="value">{{ formatBytes(store.detailModal.doc.fileSize || 0) }}</div></div>
<div class="result-item"><div class="label">状态</div><div class="value"><span :class="statusClass">{{ store.detailModal.doc.status }}</span></div></div>
<div class="result-item"><div class="label">分块数</div><div class="value">{{ store.detailModal.doc.chunkCount }}</div></div>
<div class="result-item"><div class="label">分类</div><div class="value">{{ store.getCategoryName(store.detailModal.doc.categoryId) }}</div></div>
<div class="result-item"><div class="label">创建时间</div><div class="value">{{ formatDate(store.detailModal.doc.createTime) }}</div></div>
</div>
<!-- P1-2.6: 原文预览Markdown 渲染 -->
<h3>原文内容</h3>
<div v-if="isMarkdown" style="background:#fafafa;padding:12px;border-radius:8px;border:1px solid var(--border);font-size:13px;line-height:1.6;max-height:300px;overflow-y:auto;" v-html="renderedContent"></div>
<div v-else style="background:#fafafa;padding:12px;border-radius:8px;border:1px solid var(--border);font-size:13px;line-height:1.6;max-height:300px;overflow-y:auto;white-space:pre-wrap;">{{ store.detailModal.doc.content || '-' }}</div>
</div>
<h3>分块详情{{ store.detailModal.chunks.length }} </h3>
<div v-if="store.detailModal.chunksError" style="margin:8px 0 12px;padding:10px 12px;border:1px solid #f59e0b;background:#fffbeb;color:#92400e;border-radius:8px;font-size:13px;">{{ store.detailModal.chunksError }}</div>
<div v-for="(chunk, i) in store.detailModal.chunks" :key="i" class="search-result" style="position:relative;">
<div class="meta">
#{{ i + 1 }} {{ getKeywords(chunk) ? '| 关键词: ' + getKeywords(chunk) : '' }}
<!-- P1-2.5: 分块操作按钮 -->
<span style="float:right;display:inline-flex;gap:4px;">
<button class="btn btn-sm btn-outline" @click="startEdit(i, chunk)" :disabled="editIdx === i">编辑</button>
<button class="btn btn-sm btn-danger" @click="removeChunk(i)">删除</button>
</span>
</div>
<!-- P1-2.5: 编辑模式 -->
<div v-if="editIdx === i" style="margin-top:8px;">
<textarea class="textarea" v-model="editContent" rows="6" style="width:100%;font-size:13px;"></textarea>
<div style="margin-top:6px;display:flex;gap:8px;">
<button class="btn btn-sm btn-primary" @click="saveEdit(i)" :disabled="editSaving">
{{ editSaving ? '保存中...' : '保存并重新向量化' }}
</button>
<button class="btn btn-sm btn-outline" @click="cancelEdit()">取消</button>
</div>
</div>
<!-- 正常展示模式 -->
<div v-else class="content">{{ chunk.content || '' }}</div>
</div>
</template>
</div>
</div>
`,
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 '<p style="color:var(--sub);">无内容</p>'
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
}
}
}

431
src/main/resources/static/components/DocList.js

@ -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: `
<div class="card">
<h2>文档列表</h2>
<div class="input-row">
<input class="input" v-model="keyword" placeholder="搜索文档标题或文件名..." @input="debouncedLoad" style="max-width:260px;">
<select class="select" v-model="filterCategory" @change="load()">
<option value="">全部分类</option>
<option v-for="c in store.categories" :key="c.id" :value="c.id">{{ c.name }}</option>
</select>
<select class="select" v-model="filterStatus" @change="load()">
<option value="">全部状态</option>
<option value="READY">已完成</option>
<option value="PROCESSING">处理中</option>
<option value="FAILED">失败</option>
</select>
<!-- P1-2.3: 标签筛选 -->
<select class="select" v-model="filterTag" @change="load()" style="max-width:150px;">
<option value="">全部标签</option>
<option v-for="t in store.tags" :key="t.tag" :value="t.tag">{{ t.tag }} ({{ t.count }})</option>
</select>
<button class="btn btn-outline btn-sm" @click="load()">刷新</button>
<!-- 处理中提示 -->
<span v-if="hasProcessing" style="font-size:12px;color:#d97706;font-weight:600;"> 有文档处理中...</span>
<!-- 批量操作按钮 -->
<template v-if="selectedIds.size > 0">
<span style="font-size:12px;color:var(--text);font-weight:600;">已选 {{ selectedIds.size }} </span>
<button class="btn btn-danger btn-sm" @click="batchRemove">批量删除</button>
<button class="btn btn-warn btn-sm" @click="batchReprocess">批量重新处理</button>
<!-- P1-2.1: 批量启用/禁用 -->
<button class="btn btn-sm" style="background:#059669;color:#fff;" @click="batchEnable">批量启用</button>
<button class="btn btn-sm" style="background:#6b7280;color:#fff;" @click="batchDisable">批量禁用</button>
<!-- P1-2.2: 批量移动分类 -->
<div style="display:inline-flex;align-items:center;gap:4px;">
<select class="select" v-model="moveCategoryId" style="min-width:120px;padding:4px 8px;font-size:12px;">
<option value="">移动到分类...</option>
<option v-for="c in store.categories" :key="c.id" :value="c.id">{{ c.name }}</option>
<option value="0">未分类</option>
</select>
<button class="btn btn-sm btn-outline" @click="batchMove" :disabled="!moveCategoryId && moveCategoryId !== '0'">移动</button>
</div>
<button class="btn btn-outline btn-sm" @click="clearSelection">取消选择</button>
</template>
</div>
<div style="overflow-x:auto;">
<table class="data-table">
<thead>
<tr>
<th><input type="checkbox" :checked="isAllSelected" @change="toggleSelectAll" style="cursor:pointer;"></th>
<th>标题</th>
<th>类型</th>
<th>大小</th>
<th>分类</th>
<th>状态</th>
<th>启用</th>
<th>分块数</th>
<th>创建时间</th>
<th>操作</th>
</tr>
</thead>
<tbody>
<tr v-if="documents.length === 0">
<td colspan="10" style="text-align:center;color:var(--sub);">暂无文档</td>
</tr>
<tr v-for="d in documents" :key="d.id"
:style="getRowStyle(d)">
<td><input type="checkbox" :checked="selectedIds.has(d.id)" @change="toggleSelect(d.id)" style="cursor:pointer;"></td>
<td>
<strong>{{ d.title }}</strong><br>
<span style="font-size:11px;color:var(--sub);">{{ d.sourceName || '' }}</span>
<!-- P1-2.3: 标签 badge -->
<div v-if="getDocTags(d).length > 0" style="margin-top:2px;">
<span v-for="tag in getDocTags(d)" :key="tag"
style="display:inline-block;font-size:10px;background:#e0f2fe;color:#0369a1;padding:1px 6px;border-radius:4px;margin-right:3px;">{{ tag }}</span>
</div>
</td>
<td><span class="category-tag">{{ d.fileType }}</span></td>
<td style="white-space:nowrap;">{{ formatSize(d.fileSize) }}</td>
<td>{{ store.getCategoryName(d.categoryId) }}</td>
<td><span :class="statusClass(d.status)">{{ statusLabel(d.status) }}</span></td>
<td>
<!-- P1-2.1: 启用/禁用开关 -->
<label class="switch-toggle" :title="d.enabled !== false ? '已启用(点击禁用)' : '已禁用(点击启用)'">
<input type="checkbox" :checked="d.enabled !== false" @change="toggle(d)">
<span class="switch-slider"></span>
</label>
</td>
<td>{{ d.chunkCount || 0 }}</td>
<td>{{ formatDate(d.createTime) }}</td>
<td>
<button class="btn btn-sm btn-outline" @click="viewDetail(d.id)">查看</button>
<button v-if="d.filePath" class="btn btn-sm btn-outline" @click="download(d.id)" title="下载原始文件">下载</button>
<button class="btn btn-sm btn-warn" @click="reprocess(d.id)">重新处理</button>
<button class="btn btn-sm btn-danger" @click="remove(d.id)">删除</button>
</td>
</tr>
</tbody>
</table>
</div>
<div class="pagination" v-if="pages > 1">
<button :disabled="page <= 1" @click="load(page - 1)">上一页</button>
<template v-for="i in pages" :key="i">
<button v-if="i === 1 || i === pages || (i >= page - 2 && i <= page + 2)"
:class="{ active: i === page }" @click="load(i)">{{ i }}</button>
<span v-else-if="i === page - 3 || i === page + 3" style="padding:6px;">...</span>
</template>
<button :disabled="page >= pages" @click="load(page + 1)">下一页</button>
</div>
</div>
`,
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
}
}
}

105
src/main/resources/static/components/DocSearch.js

@ -1,105 +0,0 @@
/**
* 🔍 知识库搜索测试支持多种检索模式
*/
import { ref } from 'vue'
import { searchDocuments } from '../js/api.js'
import { toast } from '../js/utils.js'
export default {
template: `
<div class="card">
<h2>知识库搜索测试</h2>
<p style="color:var(--sub);font-size:13px;margin-bottom:12px;">输入查询语句测试知识库检索效果支持向量关键词混合检索三种模式</p>
<div class="input-row">
<input class="input" v-model="query" placeholder="输入查询,如:Spring Boot 是什么?" @keydown.enter="search">
<label class="input-field-sm">
<span>检索模式</span>
<select class="input input-sm" v-model="searchMode">
<option value="VECTOR">向量检索</option>
<option value="KEYWORD">关键词检索</option>
<option value="HYBRID">混合检索</option>
</select>
</label>
<label class="input-field-sm">
<span>返回条数</span>
<input class="input input-sm" v-model.number="topK" type="number" min="1" max="20">
</label>
<label v-if="searchMode !== 'KEYWORD'" class="input-field-sm">
<span>相似度阈值</span>
<input class="input input-sm" v-model.number="threshold" type="number" min="0" max="1" step="0.1">
</label>
<button class="btn btn-primary" @click="search" :disabled="isSearching">{{ isSearching ? '搜索中...' : '搜索' }}</button>
</div>
<div v-if="isSearching" style="text-align:center;padding:20px;color:var(--sub);">搜索中...</div>
<div v-else-if="searched && results.length === 0" style="text-align:center;padding:20px;color:var(--sub);">未找到相关结果</div>
<div v-else-if="searched && errorMsg" style="color:var(--danger);">搜索失败{{ errorMsg }}</div>
<div v-else>
<div v-for="(r, i) in results" :key="i" class="search-result">
<div class="score">
<span style="display:inline-block;padding:1px 6px;border-radius:3px;font-size:11px;margin-right:6px;"
:style="{background: modeColor(r.searchMode), color: '#fff'}">{{ modeLabel(r.searchMode) }}</span>
得分: {{ formatScore(r) }} | {{ r.title || '无标题' }} | {{ r.sourceName || '无来源' }}
</div>
<div class="content">{{ r.content || '' }}</div>
</div>
</div>
</div>
`,
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 }
}
}

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

Loading…
Cancel
Save