Browse Source

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

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