120 changed files with 10208 additions and 9518 deletions
-
4.gitignore
-
3frontend/.gitignore
-
10frontend/auto-imports.d.ts
-
47frontend/components.d.ts
-
27frontend/cp-to-static.mjs
-
13frontend/index.html
-
3823frontend/package-lock.json
-
33frontend/package.json
-
23frontend/public/favicon.svg
-
48frontend/src/App.vue
-
8frontend/src/api/account.ts
-
13frontend/src/api/api-key.ts
-
17frontend/src/api/auth.ts
-
8frontend/src/api/category.ts
-
53frontend/src/api/chat.ts
-
21frontend/src/api/conversation.ts
-
9frontend/src/api/dashboard.ts
-
134frontend/src/api/document.ts
-
17frontend/src/api/faq.ts
-
14frontend/src/api/feedback.ts
-
11frontend/src/api/mcp-server.ts
-
23frontend/src/api/model-config.ts
-
41frontend/src/api/request.ts
-
11frontend/src/api/role.ts
-
18frontend/src/api/sensitive-word.ts
-
9frontend/src/api/system-config.ts
-
49frontend/src/api/upload.ts
-
19frontend/src/api/user.ts
-
8frontend/src/api/webhook.ts
-
37frontend/src/components/FormDialog.vue
-
47frontend/src/components/MessageSources.vue
-
38frontend/src/composables/useConfirm.ts
-
70frontend/src/composables/useCrudForm.ts
-
33frontend/src/composables/useDebounce.ts
-
36frontend/src/composables/useLoading.ts
-
51frontend/src/composables/usePagination.ts
-
68frontend/src/layouts/MainLayout.vue
-
104frontend/src/layouts/Sidebar.vue
-
52frontend/src/layouts/Topbar.vue
-
17frontend/src/main.ts
-
76frontend/src/router/index.ts
-
7frontend/src/shims-vue.d.ts
-
59frontend/src/stores/auth.ts
-
32frontend/src/stores/category.ts
-
40frontend/src/stores/dialog.ts
-
27frontend/src/stores/document.ts
-
44frontend/src/stores/navigation.ts
-
49frontend/src/styles/shared.css
-
23frontend/src/types/api.ts
-
234frontend/src/types/models.ts
-
3frontend/src/types/page.ts
-
8frontend/src/types/sse.ts
-
24frontend/src/utils/format.ts
-
22frontend/src/utils/markdown.ts
-
196frontend/src/utils/sse.ts
-
8frontend/src/utils/toast.ts
-
46frontend/src/utils/token.ts
-
58frontend/src/views/AccountManager.vue
-
108frontend/src/views/ApiKeyManager.vue
-
39frontend/src/views/AuditLogManager.vue
-
124frontend/src/views/CategoryManager.vue
-
684frontend/src/views/ChatPanel.vue
-
83frontend/src/views/ConversationManager.vue
-
144frontend/src/views/DashboardPanel.vue
-
130frontend/src/views/DocDetail.vue
-
264frontend/src/views/DocList.vue
-
33frontend/src/views/DocSearch.vue
-
19frontend/src/views/DocStats.vue
-
147frontend/src/views/DocUpload.vue
-
157frontend/src/views/FaqManager.vue
-
78frontend/src/views/LoginPage.vue
-
117frontend/src/views/McpServerManager.vue
-
719frontend/src/views/ModelConfigManager.vue
-
197frontend/src/views/PipelineFlow.vue
-
89frontend/src/views/RoleManager.vue
-
104frontend/src/views/SensitiveWordManager.vue
-
94frontend/src/views/SystemConfigManager.vue
-
117frontend/src/views/UserManager.vue
-
80frontend/src/views/WebhookManager.vue
-
259frontend/test_automation.py
-
192frontend/test_interact.py
-
61frontend/test_targeted.py
-
26frontend/tsconfig.json
-
78frontend/vite.config.ts
-
52pom.xml
-
62src/main/java/com/wok/supportbot/app/AssistantApp.java
-
64src/main/java/com/wok/supportbot/security/SdkAuthFilter.java
-
4src/main/java/com/wok/supportbot/security/SdkJwtTokenProvider.java
-
18src/main/java/com/wok/supportbot/security/SecurityConfig.java
-
8src/main/java/com/wok/supportbot/service/DocumentService.java
-
155src/main/resources/static/components/AccountManager.js
-
365src/main/resources/static/components/ApiKeyManager.js
-
103src/main/resources/static/components/AuditLogManager.js
-
322src/main/resources/static/components/CategoryManager.js
-
529src/main/resources/static/components/ChatPanel.js
-
255src/main/resources/static/components/ConversationManager.js
-
340src/main/resources/static/components/DashboardPanel.js
-
186src/main/resources/static/components/DocDetail.js
-
431src/main/resources/static/components/DocList.js
-
105src/main/resources/static/components/DocSearch.js
@ -0,0 +1,3 @@ |
|||
node_modules/ |
|||
dist/ |
|||
node/ |
|||
@ -0,0 +1,10 @@ |
|||
/* eslint-disable */ |
|||
/* prettier-ignore */ |
|||
// @ts-nocheck
|
|||
// noinspection JSUnusedGlobalSymbols
|
|||
// Generated by unplugin-auto-import
|
|||
// biome-ignore lint: disable
|
|||
export {} |
|||
declare global { |
|||
|
|||
} |
|||
@ -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'] |
|||
} |
|||
} |
|||
@ -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); |
|||
} |
|||
@ -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
File diff suppressed because it is too large
View File
@ -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" |
|||
} |
|||
} |
|||
@ -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> |
|||
@ -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> |
|||
@ -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) } |
|||
@ -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) |
|||
} |
|||
@ -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) |
|||
} |
|||
@ -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) } |
|||
@ -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) |
|||
} |
|||
@ -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) |
|||
} |
|||
@ -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) |
|||
} |
|||
@ -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) |
|||
} |
|||
@ -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) } |
|||
@ -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) } |
|||
@ -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) } |
|||
@ -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) } |
|||
@ -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 |
|||
@ -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) |
|||
} |
|||
@ -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) |
|||
} |
|||
@ -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) } |
|||
@ -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) |
|||
} |
|||
@ -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) } |
|||
@ -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) } |
|||
@ -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> |
|||
@ -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> |
|||
@ -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 } |
|||
} |
|||
@ -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 } |
|||
} |
|||
@ -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 } |
|||
} |
|||
@ -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 } |
|||
} |
|||
@ -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 } |
|||
} |
|||
@ -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> |
|||
@ -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> |
|||
@ -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> |
|||
@ -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') |
|||
@ -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 |
|||
@ -0,0 +1,7 @@ |
|||
/// <reference types="vite/client" />
|
|||
|
|||
declare module '*.vue' { |
|||
import type { DefineComponent } from 'vue' |
|||
const component: DefineComponent<{}, {}, any> |
|||
export default component |
|||
} |
|||
@ -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 } |
|||
}) |
|||
@ -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 } |
|||
}) |
|||
@ -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 } |
|||
}) |
|||
@ -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 } |
|||
}) |
|||
@ -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[] |
|||
} |
|||
@ -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; |
|||
} |
|||
@ -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 |
|||
} |
|||
@ -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 |
|||
} |
|||
@ -0,0 +1,3 @@ |
|||
/** 分页参数/响应类型 */ |
|||
import type { PageParams, PageData } from './api' |
|||
export type { PageParams, PageData } |
|||
@ -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 |
|||
} |
|||
@ -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}` |
|||
} |
|||
@ -0,0 +1,22 @@ |
|||
/** |
|||
* Markdown 渲染工具 —— 从 utils.js 原封不动搬移 |
|||
* |
|||
* 把 Markdown 文本渲染为安全的 HTML(marked 解析 + DOMPurify 消毒)。 |
|||
* 仅用于 AI 回复展示;用户输入不要走这里,避免 XSS。 |
|||
*/ |
|||
import { marked } from 'marked' |
|||
import DOMPurify from 'dompurify' |
|||
|
|||
// 单行换行也生效、启用 GitHub 风格 Markdown(表格/任务列表等)
|
|||
marked.setOptions({ breaks: true, gfm: true }) |
|||
|
|||
/** |
|||
* Markdown → 安全的 HTML |
|||
* @param text Markdown 原文 |
|||
* @returns 消毒后的 HTML 字符串 |
|||
*/ |
|||
export function renderMarkdown(text: string): string { |
|||
if (!text) return '' |
|||
const html = marked.parse(String(text)) |
|||
return DOMPurify.sanitize(html) |
|||
} |
|||
@ -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) |
|||
} |
|||
@ -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() |
|||
} |
|||
@ -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> |
|||
@ -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> |
|||
@ -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> |
|||
@ -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> |
|||
@ -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">·</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 |
|||
// 计算 userTurn(1-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> |
|||
@ -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> |
|||
@ -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> |
|||
@ -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> |
|||
@ -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> |
|||
@ -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> |
|||
@ -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> |
|||
@ -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> |
|||
@ -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 格式批量导入,每条包含 question、answer、similarQuestions(可选数组)</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> |
|||
@ -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> |
|||
@ -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">SSE(HTTP 服务)</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> |
|||
@ -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> |
|||
@ -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> |
|||
@ -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> |
|||
@ -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> |
|||
@ -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> |
|||
@ -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> |
|||
@ -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> |
|||
@ -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() |
|||
@ -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() |
|||
@ -0,0 +1,61 @@ |
|||
# -*- coding: utf-8 -*- |
|||
from playwright.sync_api import sync_playwright |
|||
import sys, io, re |
|||
|
|||
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8') |
|||
|
|||
def run(): |
|||
with sync_playwright() as p: |
|||
browser = p.chromium.launch(headless=True) |
|||
page = browser.new_page(viewport={'width': 1440, 'height': 900}) |
|||
|
|||
errors = [] |
|||
page.on('pageerror', lambda err: errors.append(str(err))) |
|||
|
|||
page.goto('http://localhost:5173', wait_until='networkidle', timeout=30000) |
|||
page.wait_for_timeout(1000) |
|||
|
|||
# Login |
|||
page.locator('input[type="text"]').first.fill('admin') |
|||
page.locator('input[type="password"]').first.fill('admin123') |
|||
page.locator('button').first.click() |
|||
page.wait_for_timeout(3000) |
|||
|
|||
# Test all routes after fix |
|||
routes = [ |
|||
'/chat', '/knowledge/stats', '/knowledge/document', '/knowledge/category', |
|||
'/knowledge/search', '/knowledge/faq', '/conversation', '/dashboard', |
|||
'/settings/role', '/settings/model-config', '/settings/sensitive', |
|||
'/settings/audit-log', '/settings/user', '/settings/api-key', |
|||
'/settings/webhook', '/settings/mcp-server', '/settings/pipeline-flow', |
|||
'/settings/system-config', |
|||
] |
|||
|
|||
all_ok = True |
|||
for route in routes: |
|||
errors.clear() |
|||
page.evaluate(f'window.location.hash = "{route}"') |
|||
page.wait_for_timeout(2500) |
|||
|
|||
main = page.locator('.main-content') |
|||
if main.count() > 0: |
|||
html = main.inner_html() |
|||
clean = re.sub(r'<!--.*?-->', '', html, flags=re.DOTALL) |
|||
clean = re.sub(r'\s+', ' ', clean).strip() |
|||
has_content = len(clean) > 20 |
|||
else: |
|||
has_content = False |
|||
clean = '' |
|||
|
|||
errs = list(set(e[:150] for e in errors)) |
|||
status = 'OK' if has_content and not errs else ('ERR' if errs else 'EMPTY') |
|||
print(f'[{status}] {route} | content_len={len(clean)} | errors={errs}') |
|||
|
|||
if not has_content or errs: |
|||
all_ok = False |
|||
|
|||
print(f'\n=== ALL OK: {all_ok} ===') |
|||
browser.close() |
|||
|
|||
if __name__ == '__main__': |
|||
run() |
|||
@ -0,0 +1,26 @@ |
|||
{ |
|||
"compilerOptions": { |
|||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", |
|||
"target": "ES2020", |
|||
"useDefineForClassFields": true, |
|||
"module": "ESNext", |
|||
"lib": ["ES2020", "DOM", "DOM.Iterable"], |
|||
"skipLibCheck": true, |
|||
"moduleResolution": "bundler", |
|||
"allowImportingTsExtensions": true, |
|||
"isolatedModules": true, |
|||
"moduleDetection": "force", |
|||
"noEmit": true, |
|||
"jsx": "preserve", |
|||
"strict": true, |
|||
"noUnusedLocals": false, |
|||
"noUnusedParameters": false, |
|||
"noFallthroughCasesInSwitch": true, |
|||
"noUncheckedSideEffectImports": true, |
|||
"baseUrl": ".", |
|||
"paths": { |
|||
"@/*": ["./src/*"] |
|||
} |
|||
}, |
|||
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue", "src/shims-vue.d.ts"] |
|||
} |
|||
@ -0,0 +1,78 @@ |
|||
import { defineConfig } from 'vite' |
|||
import vue from '@vitejs/plugin-vue' |
|||
import { resolve } from 'path' |
|||
import AutoImport from 'unplugin-auto-import/vite' |
|||
import Components from 'unplugin-vue-components/vite' |
|||
import { TDesignResolver } from 'unplugin-vue-components/resolvers' |
|||
|
|||
// Node 20.18 for Windows 缺少 getRandomValues,手动 polyfill
|
|||
import crypto from 'node:crypto' |
|||
if (!globalThis.crypto) { |
|||
(globalThis as any).crypto = crypto |
|||
} |
|||
if (!(globalThis.crypto as any).getRandomValues) { |
|||
(globalThis.crypto as any).getRandomValues = (arr: Uint8Array) => crypto.randomFillSync(arr) |
|||
} |
|||
|
|||
export default defineConfig({ |
|||
plugins: [ |
|||
vue(), |
|||
// TDesign 按需自动引入
|
|||
AutoImport({ |
|||
resolvers: [TDesignResolver({ library: 'vue-next' })], |
|||
}), |
|||
Components({ |
|||
resolvers: [TDesignResolver({ library: 'vue-next' })], |
|||
}), |
|||
], |
|||
// 同源部署:资源使用绝对路径 /
|
|||
base: '/', |
|||
resolve: { |
|||
alias: { |
|||
'@': resolve(__dirname, 'src'), |
|||
}, |
|||
}, |
|||
build: { |
|||
outDir: 'dist', |
|||
assetsDir: 'assets', |
|||
rollupOptions: { |
|||
output: { |
|||
// 拆出独立 chunk,浏览器可长期缓存
|
|||
manualChunks: { |
|||
'tdesign': ['tdesign-vue-next'], |
|||
'echarts': ['mermaid'], // mermaid 体积较大,独立打包
|
|||
}, |
|||
}, |
|||
}, |
|||
}, |
|||
server: { |
|||
port: 5173, |
|||
proxy: { |
|||
// 开发阶段:API 请求代理到 Spring Boot 后端 9090
|
|||
'/ai': 'http://localhost:9090', |
|||
'/auth': 'http://localhost:9090', |
|||
'/category': 'http://localhost:9090', |
|||
'/conversation': 'http://localhost:9090', |
|||
'/dashboard': 'http://localhost:9090', |
|||
'/document': 'http://localhost:9090', |
|||
'/faq': 'http://localhost:9090', |
|||
'/feedback': 'http://localhost:9090', |
|||
'/model-config': 'http://localhost:9090', |
|||
'/sensitive-word': 'http://localhost:9090', |
|||
'/upload': 'http://localhost:9090', |
|||
'/api-key': 'http://localhost:9090', |
|||
'/webhook': 'http://localhost:9090', |
|||
'/mcp-server': 'http://localhost:9090', |
|||
'/system-config': 'http://localhost:9090', |
|||
'/sys-user': 'http://localhost:9090', |
|||
'/sys-role': 'http://localhost:9090', |
|||
'/tag': 'http://localhost:9090', |
|||
'/open-api': 'http://localhost:9090', |
|||
'/sdk': 'http://localhost:9090', |
|||
'/doc.html': 'http://localhost:9090', |
|||
'/swagger-ui': 'http://localhost:9090', |
|||
'/v3': 'http://localhost:9090', |
|||
'/webjars': 'http://localhost:9090', |
|||
}, |
|||
}, |
|||
}) |
|||
@ -1,155 +0,0 @@ |
|||
/** |
|||
* 账号管理 |
|||
* 管理对话账号,并为账号绑定默认客服角色。 |
|||
*/ |
|||
import { ref, reactive, onMounted } from 'vue' |
|||
import { getAllAccounts, createAccount, updateAccount, deleteAccount, getRoleList } from '../js/api.js' |
|||
import { toast } from '../js/utils.js' |
|||
|
|||
export default { |
|||
template: `
|
|||
<div class="card"> |
|||
<h2>账号管理</h2> |
|||
<p style="font-size:12px;color:var(--sub);margin:4px 0 12px;"> |
|||
为每个账号绑定一个默认角色。对话页选择账号后,系统会按账号绑定的角色回答,并把会话历史归属到该账号。 |
|||
</p> |
|||
|
|||
<div style="border:1px solid var(--border);border-radius:8px;padding:12px;margin-bottom:16px;"> |
|||
<div class="input-row"> |
|||
<input class="input" v-model="form.name" placeholder="账号名称 *"> |
|||
<input class="input" v-model="form.description" placeholder="账号说明(可选)"> |
|||
<select class="select" v-model="form.roleId"> |
|||
<option value="">未绑定角色</option> |
|||
<option v-for="role in roles" :key="role.id" :value="role.id">{{ role.name }}</option> |
|||
</select> |
|||
<label class="toggle-line"><input type="checkbox" v-model="form.enabled"><span>启用</span></label> |
|||
</div> |
|||
<div class="input-row" style="margin-top:8px;"> |
|||
<button class="btn btn-success" @click="submit">{{ editingId ? '保存修改' : '新增账号' }}</button> |
|||
<button v-if="editingId" class="btn btn-outline btn-sm" @click="resetForm">取消编辑</button> |
|||
<button class="btn btn-outline btn-sm" @click="reload">刷新</button> |
|||
</div> |
|||
</div> |
|||
|
|||
<div v-if="!accounts.length" style="font-size:13px;color:var(--sub);">暂无账号</div> |
|||
<div v-else style="overflow-x:auto;"> |
|||
<table class="data-table"> |
|||
<thead> |
|||
<tr> |
|||
<th>账号</th> |
|||
<th>说明</th> |
|||
<th>绑定角色</th> |
|||
<th>状态</th> |
|||
<th>操作</th> |
|||
</tr> |
|||
</thead> |
|||
<tbody> |
|||
<tr v-for="account in accounts" :key="account.id"> |
|||
<td> |
|||
<strong>{{ account.name }}</strong> |
|||
<div style="font-size:11px;color:var(--sub);">{{ account.account_key }}</div> |
|||
</td> |
|||
<td>{{ account.description || '-' }}</td> |
|||
<td>{{ account.role_name || '未绑定' }}</td> |
|||
<td>{{ account.enabled === false ? '停用' : '启用' }}</td> |
|||
<td> |
|||
<button class="btn btn-outline btn-sm" @click="startEdit(account)">编辑</button> |
|||
<button class="btn btn-danger btn-sm" @click="remove(account)">删除</button> |
|||
</td> |
|||
</tr> |
|||
</tbody> |
|||
</table> |
|||
</div> |
|||
</div> |
|||
`,
|
|||
setup() { |
|||
const accounts = ref([]) |
|||
const roles = ref([]) |
|||
const editingId = ref('') |
|||
const form = reactive({ name: '', description: '', roleId: '', enabled: true }) |
|||
|
|||
async function loadRoles() { |
|||
const json = await getRoleList() |
|||
if (json.success) { |
|||
roles.value = json.data || [] |
|||
} |
|||
} |
|||
|
|||
async function reload() { |
|||
try { |
|||
const json = await getAllAccounts() |
|||
if (json.success) { |
|||
accounts.value = json.data || [] |
|||
} |
|||
} catch (e) { |
|||
toast('加载账号失败:' + e.message, 'error') |
|||
} |
|||
} |
|||
|
|||
function resetForm() { |
|||
editingId.value = '' |
|||
form.name = '' |
|||
form.description = '' |
|||
form.roleId = '' |
|||
form.enabled = true |
|||
} |
|||
|
|||
function startEdit(account) { |
|||
editingId.value = account.id |
|||
form.name = account.name || '' |
|||
form.description = account.description || '' |
|||
form.roleId = account.role_id || '' |
|||
form.enabled = account.enabled !== false |
|||
} |
|||
|
|||
async function submit() { |
|||
if (!form.name.trim()) { |
|||
toast('请输入账号名称', 'error') |
|||
return |
|||
} |
|||
const payload = { |
|||
name: form.name, |
|||
description: form.description, |
|||
roleId: form.roleId || null, |
|||
enabled: form.enabled |
|||
} |
|||
try { |
|||
const json = editingId.value |
|||
? await updateAccount(editingId.value, payload) |
|||
: await createAccount(payload) |
|||
if (json.success) { |
|||
toast(editingId.value ? '账号已更新' : '账号已创建', 'success') |
|||
resetForm() |
|||
reload() |
|||
} else { |
|||
toast(json.message || '操作失败', 'error') |
|||
} |
|||
} catch (e) { |
|||
toast('操作失败:' + e.message, 'error') |
|||
} |
|||
} |
|||
|
|||
async function remove(account) { |
|||
if (!confirm(`确定删除账号「${account.name}」?历史会话记录会保留归属信息。`)) return |
|||
try { |
|||
const json = await deleteAccount(account.id) |
|||
if (json.success) { |
|||
toast('账号已删除', 'success') |
|||
if (editingId.value === account.id) resetForm() |
|||
reload() |
|||
} else { |
|||
toast(json.message || '删除失败', 'error') |
|||
} |
|||
} catch (e) { |
|||
toast('删除失败:' + e.message, 'error') |
|||
} |
|||
} |
|||
|
|||
onMounted(() => { |
|||
loadRoles() |
|||
reload() |
|||
}) |
|||
|
|||
return { accounts, roles, editingId, form, reload, resetForm, startEdit, submit, remove } |
|||
} |
|||
} |
|||
@ -1,365 +0,0 @@ |
|||
/** |
|||
* API Key 管理组件 |
|||
* 支持 API Key 的生成、吊销、启用、删除、复制、分页查询、角色绑定 |
|||
*/ |
|||
import { ref, onMounted, computed } from 'vue' |
|||
import { listApiKeys, createApiKey, revokeApiKey, enableApiKey, deleteApiKey, getAllRoles, updateApiKeyRoles } from '../js/api.js' |
|||
import { toast } from '../js/utils.js' |
|||
|
|||
export default { |
|||
template: `
|
|||
<div class="card" style="margin-top:16px;"> |
|||
<h3 style="margin-bottom:12px;">🔑 API Key 管理</h3> |
|||
|
|||
<!-- 操作栏 --> |
|||
<div style="display:flex;gap:8px;margin-bottom:12px;flex-wrap:wrap;align-items:center;"> |
|||
<button @click="openCreateDialog" style="padding:6px 14px;background:var(--primary);color:#fff;border:none;border-radius:6px;cursor:pointer;">+ 新建 API Key</button> |
|||
<span style="margin-left:auto;font-size:13px;color:#666;">共 {{ total }} 个 Key</span> |
|||
</div> |
|||
|
|||
<!-- API Key 表格 --> |
|||
<table style="width:100%;border-collapse:collapse;"> |
|||
<thead> |
|||
<tr style="border-bottom:2px solid var(--border);"> |
|||
<th style="text-align:left;padding:8px;">名称</th> |
|||
<th style="text-align:left;padding:8px;">Key</th> |
|||
<th style="text-align:center;padding:8px;">频率限制</th> |
|||
<th style="text-align:center;padding:8px;">调用次数</th> |
|||
<th style="text-align:center;padding:8px;">绑定角色</th> |
|||
<th style="text-align:center;padding:8px;">过期时间</th> |
|||
<th style="text-align:center;padding:8px;">状态</th> |
|||
<th style="text-align:right;padding:8px;">操作</th> |
|||
</tr> |
|||
</thead> |
|||
<tbody> |
|||
<tr v-if="loading"><td colspan="8" style="text-align:center;padding:20px;">加载中...</td></tr> |
|||
<tr v-else-if="keys.length === 0"><td colspan="8" style="text-align:center;padding:20px;color:#999;">暂无 API Key</td></tr> |
|||
<tr v-for="k in keys" :key="k.id" style="border-bottom:1px solid var(--border);"> |
|||
<td style="padding:8px;"> |
|||
<div style="font-weight:500;">{{ k.name || '未命名' }}</div> |
|||
<div v-if="k.description" style="font-size:12px;color:#999;margin-top:2px;">{{ k.description }}</div> |
|||
</td> |
|||
<td style="padding:8px;"> |
|||
<div style="display:flex;align-items:center;gap:6px;"> |
|||
<code style="font-size:12px;background:var(--bg);padding:2px 6px;border-radius:4px;">{{ maskKey(k.keyValue) }}</code> |
|||
<button @click="copyKey(k.keyValue)" title="复制完整 Key" style="padding:2px 6px;background:none;border:1px solid var(--border);border-radius:4px;cursor:pointer;font-size:12px;">📋</button> |
|||
</div> |
|||
</td> |
|||
<td style="text-align:center;padding:8px;">{{ k.rateLimit || 60 }} 次/分钟</td> |
|||
<td style="text-align:center;padding:8px;"> |
|||
{{ k.currentCalls || 0 }} |
|||
<span v-if="k.maxCalls" style="color:#999;">/ {{ k.maxCalls }}</span> |
|||
<span v-else style="color:#999;">(不限)</span> |
|||
</td> |
|||
<td style="text-align:center;padding:8px;font-size:12px;"> |
|||
<span v-if="getBoundRoleCount(k.roleIds) > 0" style="color:var(--primary);">{{ getBoundRoleCount(k.roleIds) }} 个角色</span> |
|||
<span v-else style="color:#999;">不限(全部)</span> |
|||
</td> |
|||
<td style="text-align:center;padding:8px;font-size:12px;"> |
|||
{{ k.expireTime ? formatDate(k.expireTime) : '永不过期' }} |
|||
</td> |
|||
<td style="text-align:center;padding:8px;"> |
|||
<span :style="{color: k.enabled ? '#28a745' : '#dc3545', cursor:'pointer'}"> |
|||
{{ k.enabled ? '✅ 有效' : '❌ 已吊销' }} |
|||
</span> |
|||
</td> |
|||
<td style="text-align:right;padding:8px;"> |
|||
<button @click="openRoleDialog(k)" style="padding:3px 8px;background:none;border:1px solid var(--primary);color:var(--primary);border-radius:4px;cursor:pointer;margin-right:4px;font-size:12px;">绑定角色</button> |
|||
<button v-if="k.enabled" @click="doRevoke(k.id)" style="padding:3px 8px;background:none;border:1px solid #dc3545;color:#dc3545;border-radius:4px;cursor:pointer;margin-right:4px;font-size:12px;">吊销</button> |
|||
<button v-else @click="doEnable(k.id)" style="padding:3px 8px;background:none;border:1px solid #28a745;color:#28a745;border-radius:4px;cursor:pointer;margin-right:4px;font-size:12px;">启用</button> |
|||
<button @click="doDelete(k.id)" style="padding:3px 8px;background:none;border:1px solid #dc3545;color:#dc3545;border-radius:4px;cursor:pointer;font-size:12px;">删除</button> |
|||
</td> |
|||
</tr> |
|||
</tbody> |
|||
</table> |
|||
|
|||
<!-- 分页 --> |
|||
<div v-if="total > pageSize" style="display:flex;justify-content:center;gap:8px;margin-top:12px;"> |
|||
<button @click="page > 1 && (page--, loadList())" :disabled="page <= 1" style="padding:4px 10px;">上一页</button> |
|||
<span style="line-height:32px;">第 {{ page }} / {{ Math.ceil(total / pageSize) }} 页(共 {{ total }} 条)</span> |
|||
<button @click="page < Math.ceil(total / pageSize) && (page++, loadList())" :disabled="page >= Math.ceil(total / pageSize)" style="padding:4px 10px;">下一页</button> |
|||
</div> |
|||
|
|||
<!-- 新建弹窗 --> |
|||
<div v-if="showCreateDialog" style="position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,.5);display:flex;align-items:center;justify-content:center;z-index:1000;"> |
|||
<div style="background:var(--card);border-radius:12px;padding:24px;width:520px;max-width:90vw;max-height:90vh;overflow-y:auto;"> |
|||
<h4 style="margin-bottom:16px;">新建 API Key</h4> |
|||
<div style="margin-bottom:12px;"> |
|||
<label style="display:block;margin-bottom:4px;font-size:13px;">名称 *</label> |
|||
<input v-model="form.name" placeholder="如:生产环境 Key" style="width:100%;padding:8px;border:1px solid var(--border);border-radius:6px;box-sizing:border-box;" /> |
|||
</div> |
|||
<div style="margin-bottom:12px;"> |
|||
<label style="display:block;margin-bottom:4px;font-size:13px;">描述</label> |
|||
<input v-model="form.description" placeholder="用途说明(可选)" style="width:100%;padding:8px;border:1px solid var(--border);border-radius:6px;box-sizing:border-box;" /> |
|||
</div> |
|||
<div style="display:flex;gap:8px;margin-bottom:12px;"> |
|||
<div style="flex:1;"> |
|||
<label style="display:block;margin-bottom:4px;font-size:13px;">频率限制(次/分钟)</label> |
|||
<input v-model.number="form.rateLimit" type="number" min="1" placeholder="60" style="width:100%;padding:8px;border:1px solid var(--border);border-radius:6px;box-sizing:border-box;" /> |
|||
</div> |
|||
<div style="flex:1;"> |
|||
<label style="display:block;margin-bottom:4px;font-size:13px;">最大调用次数</label> |
|||
<input v-model.number="form.maxCalls" type="number" min="1" placeholder="不限" style="width:100%;padding:8px;border:1px solid var(--border);border-radius:6px;box-sizing:border-box;" /> |
|||
</div> |
|||
</div> |
|||
<div style="margin-bottom:12px;"> |
|||
<label style="display:block;margin-bottom:4px;font-size:13px;">过期时间(可选)</label> |
|||
<input v-model="form.expireTime" type="datetime-local" style="width:100%;padding:8px;border:1px solid var(--border);border-radius:6px;box-sizing:border-box;" /> |
|||
</div> |
|||
<div style="margin-bottom:12px;"> |
|||
<label style="display:block;margin-bottom:4px;font-size:13px;">绑定客服角色(可选,不选则不限制)</label> |
|||
<div v-if="allRoles.length === 0" style="font-size:12px;color:#999;padding:4px 0;">暂无可用角色</div> |
|||
<div v-for="role in allRoles" :key="role.id" style="display:inline-flex;align-items:center;gap:4px;margin:2px 8px 2px 0;font-size:13px;"> |
|||
<label style="display:inline-flex;align-items:center;gap:4px;cursor:pointer;"> |
|||
<input type="checkbox" :value="Number(role.id)" v-model="form.roleIds" /> |
|||
{{ role.name }} <span style="color:#999;font-size:11px;">({{ role.role_key }})</span> |
|||
</label> |
|||
</div> |
|||
</div> |
|||
<div style="display:flex;justify-content:flex-end;gap:8px;"> |
|||
<button @click="showCreateDialog = false" style="padding:8px 16px;background:none;border:1px solid var(--border);border-radius:6px;cursor:pointer;">取消</button> |
|||
<button @click="doCreate" :disabled="!form.name" style="padding:8px 16px;background:var(--primary);color:#fff;border:none;border-radius:6px;cursor:pointer;">创建</button> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
|
|||
<!-- 创建成功提示弹窗(显示完整 Key) --> |
|||
<div v-if="showKeyReveal" style="position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,.5);display:flex;align-items:center;justify-content:center;z-index:1001;"> |
|||
<div style="background:var(--card);border-radius:12px;padding:24px;width:520px;max-width:90vw;"> |
|||
<h4 style="margin-bottom:8px;">✅ API Key 已创建</h4> |
|||
<p style="color:#dc3545;font-size:13px;margin-bottom:12px;">⚠ 请立即复制保存,关闭后将无法再次查看完整 Key。</p> |
|||
<div style="background:var(--bg);padding:12px;border-radius:6px;font-family:monospace;word-break:break-all;font-size:13px;margin-bottom:12px;"> |
|||
{{ revealedKey }} |
|||
</div> |
|||
<div style="display:flex;justify-content:flex-end;gap:8px;"> |
|||
<button @click="copyKey(revealedKey)" style="padding:8px 16px;background:var(--primary);color:#fff;border:none;border-radius:6px;cursor:pointer;">📋 复制 Key</button> |
|||
<button @click="showKeyReveal = false" style="padding:8px 16px;background:none;border:1px solid var(--border);border-radius:6px;cursor:pointer;">关闭</button> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
|
|||
<!-- 角色绑定弹窗 --> |
|||
<div v-if="showRoleDialog" style="position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,.5);display:flex;align-items:center;justify-content:center;z-index:1000;"> |
|||
<div style="background:var(--card);border-radius:12px;padding:24px;width:520px;max-width:90vw;max-height:90vh;overflow-y:auto;"> |
|||
<h4 style="margin-bottom:8px;">绑定客服角色 — {{ editingKeyName }}</h4> |
|||
<p style="color:#666;font-size:13px;margin-bottom:16px;">勾选允许此 API Key 使用的客服角色,不勾选任何角色则不限制(可使用所有角色)</p> |
|||
|
|||
<div v-if="roleLoading" style="text-align:center;padding:20px;color:#999;">加载角色列表...</div> |
|||
<div v-else-if="allRoles.length === 0" style="text-align:center;padding:20px;color:#999;">暂无可用角色,请先创建客服角色</div> |
|||
<div v-else> |
|||
<div v-for="role in allRoles" :key="role.id" |
|||
style="display:flex;align-items:center;gap:8px;padding:10px 12px;margin-bottom:4px;border:1px solid var(--border);border-radius:6px;"> |
|||
<label style="display:inline-flex;align-items:center;gap:4px;cursor:pointer;flex:1;"> |
|||
<input type="checkbox" v-model="selectedRoleIds" :value="Number(role.id)" /> |
|||
<div> |
|||
<div style="font-weight:500;font-size:14px;">{{ role.name }}</div> |
|||
<div style="font-size:12px;color:#999;">{{ role.role_key }}<span v-if="role.description"> · {{ role.description }}</span></div> |
|||
</div> |
|||
</label> |
|||
</div> |
|||
</div> |
|||
|
|||
<div style="display:flex;justify-content:space-between;align-items:center;margin-top:16px;"> |
|||
<span style="font-size:12px;color:#999;">已选 {{ selectedRoleIds.length }} 个角色{{ selectedRoleIds.length === 0 ? '(不限制)' : '' }}</span> |
|||
<div style="display:flex;gap:8px;"> |
|||
<button @click="showRoleDialog = false" style="padding:8px 16px;background:none;border:1px solid var(--border);border-radius:6px;cursor:pointer;">取消</button> |
|||
<button @click="doSaveRoles" style="padding:8px 16px;background:var(--primary);color:#fff;border:none;border-radius:6px;cursor:pointer;">保存</button> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
`,
|
|||
|
|||
setup() { |
|||
const keys = ref([]) |
|||
const loading = ref(false) |
|||
const page = ref(1) |
|||
const pageSize = ref(20) |
|||
const total = ref(0) |
|||
const showCreateDialog = ref(false) |
|||
const showKeyReveal = ref(false) |
|||
const revealedKey = ref('') |
|||
const form = ref({ name: '', description: '', rateLimit: null, maxCalls: null, expireTime: '', roleIds: [] }) |
|||
|
|||
// 角色绑定相关状态
|
|||
const allRoles = ref([]) |
|||
const roleLoading = ref(false) |
|||
const showRoleDialog = ref(false) |
|||
const editingKeyId = ref(null) |
|||
const editingKeyName = ref('') |
|||
const selectedRoleIds = ref([]) |
|||
|
|||
async function loadList() { |
|||
loading.value = true |
|||
try { |
|||
const res = await listApiKeys(page.value, pageSize.value) |
|||
if (res.success) { |
|||
keys.value = res.data || [] |
|||
total.value = res.total || 0 |
|||
} |
|||
} catch (e) { |
|||
toast('加载失败: ' + e.message, 'error') |
|||
} |
|||
loading.value = false |
|||
} |
|||
|
|||
async function loadRoles() { |
|||
roleLoading.value = true |
|||
try { |
|||
const res = await getAllRoles() |
|||
allRoles.value = res.data || [] |
|||
} catch (e) { |
|||
console.warn('加载角色列表失败', e) |
|||
} |
|||
roleLoading.value = false |
|||
} |
|||
|
|||
function openCreateDialog() { |
|||
form.value = { name: '', description: '', rateLimit: null, maxCalls: null, expireTime: '', roleIds: [] } |
|||
loadRoles() |
|||
showCreateDialog.value = true |
|||
} |
|||
|
|||
async function doCreate() { |
|||
const data = { name: form.value.name, description: form.value.description || '' } |
|||
if (form.value.rateLimit) data.rateLimit = form.value.rateLimit |
|||
if (form.value.maxCalls) data.maxCalls = form.value.maxCalls |
|||
if (form.value.expireTime) data.expireTime = form.value.expireTime |
|||
if (form.value.roleIds && form.value.roleIds.length > 0) data.roleIds = form.value.roleIds |
|||
|
|||
try { |
|||
const res = await createApiKey(data) |
|||
if (res.success) { |
|||
toast('创建成功', 'success') |
|||
showCreateDialog.value = false |
|||
// 显示完整 Key 供用户复制
|
|||
if (res.data && res.data.keyValue) { |
|||
revealedKey.value = res.data.keyValue |
|||
showKeyReveal.value = true |
|||
} |
|||
loadList() |
|||
} else { |
|||
toast(res.message || '创建失败', 'error') |
|||
} |
|||
} catch (e) { |
|||
toast('创建失败: ' + e.message, 'error') |
|||
} |
|||
} |
|||
|
|||
async function doRevoke(id) { |
|||
if (!confirm('确认吊销该 API Key?吊销后使用该 Key 的请求将被拒绝。')) return |
|||
try { |
|||
const res = await revokeApiKey(id) |
|||
if (res.success) { toast('已吊销', 'success'); loadList() } |
|||
else toast(res.message || '吊销失败', 'error') |
|||
} catch (e) { toast('吊销失败: ' + e.message, 'error') } |
|||
} |
|||
|
|||
async function doEnable(id) { |
|||
try { |
|||
const res = await enableApiKey(id) |
|||
if (res.success) { toast('已启用', 'success'); loadList() } |
|||
else toast(res.message || '启用失败', 'error') |
|||
} catch (e) { toast('启用失败: ' + e.message, 'error') } |
|||
} |
|||
|
|||
async function doDelete(id) { |
|||
if (!confirm('确认删除该 API Key?此操作不可恢复。')) return |
|||
try { |
|||
const res = await deleteApiKey(id) |
|||
if (res.success) { toast('已删除', 'success'); loadList() } |
|||
else toast(res.message || '删除失败', 'error') |
|||
} catch (e) { toast('删除失败: ' + e.message, 'error') } |
|||
} |
|||
|
|||
function openRoleDialog(key) { |
|||
editingKeyId.value = key.id |
|||
editingKeyName.value = key.name || '未命名' |
|||
try { |
|||
selectedRoleIds.value = key.roleIds ? JSON.parse(key.roleIds).map(Number) : [] |
|||
} catch { selectedRoleIds.value = [] } |
|||
loadRoles() |
|||
showRoleDialog.value = true |
|||
} |
|||
|
|||
function toggleRole(roleId) { |
|||
const idx = selectedRoleIds.value.indexOf(roleId) |
|||
if (idx >= 0) selectedRoleIds.value.splice(idx, 1) |
|||
else selectedRoleIds.value.push(roleId) |
|||
} |
|||
|
|||
function toggleRole(roleId) { |
|||
const idx = selectedRoleIds.value.indexOf(roleId) |
|||
if (idx >= 0) selectedRoleIds.value.splice(idx, 1) |
|||
else selectedRoleIds.value.push(roleId) |
|||
} |
|||
|
|||
async function doSaveRoles() { |
|||
try { |
|||
const res = await updateApiKeyRoles(editingKeyId.value, selectedRoleIds.value) |
|||
if (res.success) { |
|||
toast('角色绑定更新成功', 'success') |
|||
showRoleDialog.value = false |
|||
loadList() |
|||
} else { |
|||
toast(res.message || '更新失败', 'error') |
|||
} |
|||
} catch (e) { |
|||
toast('更新失败: ' + e.message, 'error') |
|||
} |
|||
} |
|||
|
|||
function getBoundRoleCount(roleIdsStr) { |
|||
if (!roleIdsStr) return 0 |
|||
try { |
|||
const arr = JSON.parse(roleIdsStr) |
|||
return Array.isArray(arr) ? arr.length : 0 |
|||
} catch { return 0 } |
|||
} |
|||
|
|||
function maskKey(key) { |
|||
if (!key || key.length < 12) return key || '***' |
|||
return key.substring(0, 6) + '****' + key.substring(key.length - 4) |
|||
} |
|||
|
|||
function copyKey(key) { |
|||
if (!key) return |
|||
navigator.clipboard.writeText(key).then(() => { |
|||
toast('已复制到剪贴板', 'success') |
|||
}).catch(() => { |
|||
// 降级方案
|
|||
const ta = document.createElement('textarea') |
|||
ta.value = key |
|||
document.body.appendChild(ta) |
|||
ta.select() |
|||
document.execCommand('copy') |
|||
document.body.removeChild(ta) |
|||
toast('已复制到剪贴板', 'success') |
|||
}) |
|||
} |
|||
|
|||
function formatDate(dateVal) { |
|||
if (!dateVal) return '-' |
|||
const d = new Date(dateVal) |
|||
const y = d.getFullYear() |
|||
const m = String(d.getMonth() + 1).padStart(2, '0') |
|||
const day = String(d.getDate()).padStart(2, '0') |
|||
const h = String(d.getHours()).padStart(2, '0') |
|||
const min = String(d.getMinutes()).padStart(2, '0') |
|||
return y + '-' + m + '-' + day + ' ' + h + ':' + min |
|||
} |
|||
|
|||
onMounted(() => loadList()) |
|||
|
|||
return { |
|||
keys, loading, page, pageSize, total, |
|||
showCreateDialog, showKeyReveal, revealedKey, form, |
|||
allRoles, roleLoading, showRoleDialog, editingKeyId, editingKeyName, selectedRoleIds, |
|||
loadList, openCreateDialog, doCreate, |
|||
doRevoke, doEnable, doDelete, |
|||
openRoleDialog, doSaveRoles, |
|||
getBoundRoleCount, maskKey, copyKey, formatDate |
|||
} |
|||
} |
|||
} |
|||
@ -1,103 +0,0 @@ |
|||
/** |
|||
* 内容审计日志组件 |
|||
* 展示敏感词命中记录,支持分页浏览 |
|||
*/ |
|||
import { listAuditLogs } from '../js/api.js' |
|||
import { toast } from '../js/utils.js' |
|||
|
|||
export default { |
|||
template: `
|
|||
<div class="card"> |
|||
<h2>📋 内容审计日志</h2> |
|||
<p style="font-size:13px;color:var(--sub);margin-bottom:16px;">记录所有敏感词命中事件,包含输入/输出方向、违规内容、命中词及处理方式。</p> |
|||
|
|||
<!-- 表格 --> |
|||
<div style="overflow-x:auto;"> |
|||
<table class="data-table"> |
|||
<thead> |
|||
<tr> |
|||
<th>时间</th> |
|||
<th>方向</th> |
|||
<th>违规内容</th> |
|||
<th>命中词</th> |
|||
<th>处理方式</th> |
|||
</tr> |
|||
</thead> |
|||
<tbody> |
|||
<tr v-if="loading"><td colspan="5" style="text-align:center;padding:20px;">加载中...</td></tr> |
|||
<tr v-else-if="logs.length === 0"><td colspan="5" style="text-align:center;padding:20px;color:var(--sub);">暂无审计日志</td></tr> |
|||
<tr v-for="log in logs" :key="log.id"> |
|||
<td style="white-space:nowrap;">{{ formatTime(log.createTime) }}</td> |
|||
<td style="text-align:center;"> |
|||
<span :style="{color: log.direction === 'INPUT' ? '#007bff' : '#28a745'}"> |
|||
{{ log.direction === 'INPUT' ? '输入' : '输出' }} |
|||
</span> |
|||
</td> |
|||
<td style="max-width:260px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;" :title="log.originalText">{{ log.originalText }}</td> |
|||
<td style="font-size:12px;">{{ formatHitWords(log.hitWords) }}</td> |
|||
<td style="text-align:center;"> |
|||
<span :style="{color: log.actionTaken === 'BLOCK' ? '#dc3545' : log.actionTaken === 'MASK' ? '#ffc107' : '#28a745'}"> |
|||
{{ log.actionTaken === 'BLOCK' ? '拦截' : log.actionTaken === 'MASK' ? '脱敏' : '通过' }} |
|||
</span> |
|||
</td> |
|||
</tr> |
|||
</tbody> |
|||
</table> |
|||
</div> |
|||
|
|||
<!-- 分页 --> |
|||
<div v-if="total > pageSize" style="display:flex;justify-content:center;gap:8px;margin-top:12px;"> |
|||
<button @click="page > 1 && (page--, loadLogs())" :disabled="page <= 1" style="padding:4px 10px;">上一页</button> |
|||
<span style="line-height:32px;">第 {{ page }} / {{ Math.ceil(total / pageSize) }} 页(共 {{ total }} 条)</span> |
|||
<button @click="page < Math.ceil(total / pageSize) && (page++, loadLogs())" :disabled="page >= Math.ceil(total / pageSize)" style="padding:4px 10px;">下一页</button> |
|||
</div> |
|||
</div> |
|||
`,
|
|||
|
|||
data() { |
|||
return { |
|||
logs: [], |
|||
loading: false, |
|||
page: 1, |
|||
pageSize: 30, |
|||
total: 0 |
|||
} |
|||
}, |
|||
|
|||
mounted() { |
|||
this.loadLogs() |
|||
}, |
|||
|
|||
methods: { |
|||
async loadLogs() { |
|||
this.loading = true |
|||
try { |
|||
const res = await listAuditLogs(this.page, this.pageSize) |
|||
if (res.success) { |
|||
this.logs = res.data?.records || res.data || [] |
|||
this.total = res.data?.total || 0 |
|||
} else { |
|||
toast('加载审计日志失败: ' + (res.message || '未知错误'), 'error') |
|||
} |
|||
} catch (e) { |
|||
toast('加载审计日志失败: ' + e.message, 'error') |
|||
} |
|||
this.loading = false |
|||
}, |
|||
|
|||
formatTime(t) { return t ? new Date(t).toLocaleString('zh-CN') : '' }, |
|||
|
|||
formatHitWords(hw) { |
|||
if (!hw) return '' |
|||
if (hw.type === 'jsonb' && hw.value) { |
|||
try { hw = JSON.parse(hw.value) } catch { return hw.value } |
|||
} |
|||
if (typeof hw === 'string') { try { hw = JSON.parse(hw) } catch { return hw } } |
|||
if (hw && hw.hits && Array.isArray(hw.hits)) { |
|||
return hw.hits.map(h => `${h.word || ''}(${h.category || ''})`).join('、') |
|||
} |
|||
if (Array.isArray(hw)) return hw.map(h => typeof h === 'string' ? h : h.word || '').join('、') |
|||
return String(hw) |
|||
} |
|||
} |
|||
} |
|||
@ -1,322 +0,0 @@ |
|||
/** |
|||
* 🏷️ 分类管理 |
|||
* P0-3: 支持编辑 + 树形层级展示 + 创建时选择父分类 |
|||
*/ |
|||
import { ref, computed } from 'vue' |
|||
import { store } from '../js/store.js' |
|||
import { createCategory, updateCategory, deleteCategory } from '../js/api.js' |
|||
import { toast } from '../js/utils.js' |
|||
|
|||
export default { |
|||
template: `
|
|||
<div class="card"> |
|||
<h2>分类管理</h2> |
|||
|
|||
<!-- 创建新分类 --> |
|||
<div style="padding:12px;background:#fafafa;border-radius:8px;border:1px solid var(--border);margin-bottom:16px;"> |
|||
<div style="font-weight:600;font-size:13px;margin-bottom:8px;">创建新分类</div> |
|||
<div class="input-row" style="margin-bottom:0;"> |
|||
<select class="select" v-model="newParentId" style="max-width:180px;"> |
|||
<option value="">顶级分类</option> |
|||
<option v-for="c in parentOptions" :key="c.id" :value="c.id"> |
|||
{{ parentLabel(c) }} |
|||
</option> |
|||
</select> |
|||
<input class="input" v-model="newName" placeholder="分类名称" @keyup.enter="create"> |
|||
<input class="input" v-model="newDescription" placeholder="描述(可选)"> |
|||
<input class="input input-sm" v-model.number="newSortOrder" placeholder="排序" type="number" style="max-width:80px;"> |
|||
<button class="btn btn-success" @click="create">创建</button> |
|||
</div> |
|||
</div> |
|||
|
|||
<!-- 分类树列表 --> |
|||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:8px;"> |
|||
<span style="font-size:13px;color:var(--sub);">共 {{ store.categories.length }} 个分类</span> |
|||
<button class="btn btn-outline btn-sm" @click="store.loadCategories()">刷新</button> |
|||
</div> |
|||
|
|||
<div v-if="flatTree.length === 0" style="padding:20px;text-align:center;color:var(--sub);font-size:13px;">暂无分类,请在上方创建</div> |
|||
<div v-else> |
|||
<div v-for="node in flatTree" :key="node.id" |
|||
style="padding:10px 12px;border-bottom:1px solid var(--border);display:flex;justify-content:space-between;align-items:center;" |
|||
:style="{ paddingLeft: (node.level * 24 + 12) + 'px' }"> |
|||
|
|||
<!-- 左侧:展开/折叠 + 名称 + 描述 --> |
|||
<div style="display:flex;align-items:center;gap:6px;min-width:0;flex:1;"> |
|||
<!-- 展开/折叠按钮 --> |
|||
<span v-if="node.hasChildren" @click="toggleExpand(node.id)" |
|||
style="cursor:pointer;user-select:none;font-size:12px;width:16px;text-align:center;color:var(--sub);"> |
|||
{{ expandedIds.has(node.id) ? '▼' : '▶' }} |
|||
</span> |
|||
<span v-else style="width:16px;"></span> |
|||
|
|||
<!-- 分类名称和描述 --> |
|||
<div style="min-width:0;"> |
|||
<strong style="font-size:13px;">{{ node.name }}</strong> |
|||
<span v-if="node.description" style="color:var(--sub);font-size:12px;margin-left:8px;">{{ node.description }}</span> |
|||
<span style="color:var(--sub);font-size:11px;margin-left:8px;"> |
|||
({{ node.documentCount || 0 }} 篇文档) |
|||
</span> |
|||
</div> |
|||
</div> |
|||
|
|||
<!-- 右侧:操作按钮 --> |
|||
<div style="display:flex;gap:4px;flex-shrink:0;"> |
|||
<button class="btn btn-sm btn-outline" @click="openEdit(node)">编辑</button> |
|||
<button class="btn btn-sm btn-danger" @click="remove(node)">删除</button> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
|
|||
<!-- 编辑弹窗 --> |
|||
<div v-if="editModal.visible" style="position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,0.4);display:flex;align-items:center;justify-content:center;z-index:1000;" @click.self="closeEdit"> |
|||
<div style="background:white;border-radius:12px;padding:24px;min-width:400px;max-width:500px;box-shadow:0 20px 60px rgba(0,0,0,0.15);"> |
|||
<h3 style="margin:0 0 16px 0;font-size:16px;">编辑分类</h3> |
|||
<div style="display:flex;flex-direction:column;gap:12px;"> |
|||
<div> |
|||
<label style="font-size:12px;color:var(--sub);display:block;margin-bottom:4px;">分类名称</label> |
|||
<input class="input" v-model="editModal.name" placeholder="分类名称" @keyup.enter="saveEdit"> |
|||
</div> |
|||
<div> |
|||
<label style="font-size:12px;color:var(--sub);display:block;margin-bottom:4px;">描述</label> |
|||
<input class="input" v-model="editModal.description" placeholder="描述(可选)"> |
|||
</div> |
|||
<div> |
|||
<label style="font-size:12px;color:var(--sub);display:block;margin-bottom:4px;">排序权重</label> |
|||
<input class="input input-sm" v-model.number="editModal.sortOrder" type="number" style="max-width:120px;"> |
|||
</div> |
|||
</div> |
|||
<div style="display:flex;justify-content:flex-end;gap:8px;margin-top:20px;"> |
|||
<button class="btn btn-outline" @click="closeEdit">取消</button> |
|||
<button class="btn btn-primary" @click="saveEdit">保存</button> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
`,
|
|||
setup() { |
|||
// ==================== 创建分类 ====================
|
|||
const newName = ref('') |
|||
const newDescription = ref('') |
|||
const newParentId = ref('') |
|||
const newSortOrder = ref(0) |
|||
|
|||
// 父分类选项(排除顶级,只显示可以成为父分类的节点,限制最多2级)
|
|||
const parentOptions = computed(() => { |
|||
const opts = [] |
|||
function addOptions(categories, level) { |
|||
for (const cat of categories) { |
|||
opts.push({ id: cat.id, name: cat.name, level }) |
|||
if (cat.children && cat.children.length > 0 && level < 2) { |
|||
addOptions(cat.children, level + 1) |
|||
} |
|||
} |
|||
} |
|||
addOptions(categoryTree.value, 0) |
|||
return opts |
|||
}) |
|||
|
|||
// 父分类下拉显示文本(用 └ 前缀表示层级)
|
|||
function parentLabel(c) { |
|||
return c.level > 0 ? '─'.repeat(c.level) + ' ' + c.name : c.name |
|||
} |
|||
|
|||
async function create() { |
|||
if (!newName.value.trim()) { |
|||
toast('请输入分类名称', 'error') |
|||
return |
|||
} |
|||
try { |
|||
const json = await createCategory({ |
|||
name: newName.value.trim(), |
|||
description: newDescription.value.trim(), |
|||
parentId: newParentId.value || null, |
|||
sortOrder: newSortOrder.value |
|||
}) |
|||
if (json.success) { |
|||
toast('分类创建成功', 'success') |
|||
newName.value = '' |
|||
newDescription.value = '' |
|||
newParentId.value = '' |
|||
newSortOrder.value = 0 |
|||
await store.loadCategories() |
|||
} else { |
|||
toast(json.message || '创建失败', 'error') |
|||
} |
|||
} catch (e) { |
|||
toast('创建分类失败:' + e.message, 'error') |
|||
} |
|||
} |
|||
|
|||
// ==================== 树形展示 ====================
|
|||
const expandedIds = ref(new Set()) |
|||
|
|||
// 构建分类树(从扁平列表构建层级结构)
|
|||
const categoryTree = computed(() => { |
|||
const categories = store.categories || [] |
|||
const nodeMap = new Map() |
|||
const roots = [] |
|||
|
|||
// 先创建所有节点
|
|||
for (const cat of categories) { |
|||
nodeMap.set(cat.id, { ...cat, children: [], level: 0 }) |
|||
} |
|||
|
|||
// 构建父子关系
|
|||
for (const node of nodeMap.values()) { |
|||
if (node.parentId && node.parentId !== '0' && node.parentId !== 0) { |
|||
const parent = nodeMap.get(node.parentId) |
|||
if (parent) { |
|||
parent.children.push(node) |
|||
} else { |
|||
roots.push(node) |
|||
} |
|||
} else { |
|||
roots.push(node) |
|||
} |
|||
} |
|||
|
|||
// 按 sortOrder 升序排列(与后端一致)
|
|||
function sortChildren(nodes) { |
|||
nodes.sort((a, b) => (a.sortOrder || 0) - (b.sortOrder || 0)) |
|||
for (const node of nodes) { |
|||
sortChildren(node.children) |
|||
} |
|||
} |
|||
sortChildren(roots) |
|||
|
|||
return roots |
|||
}) |
|||
|
|||
// 展平树为列表(带层级信息)
|
|||
const flatTree = computed(() => { |
|||
const result = [] |
|||
function traverse(nodes, level) { |
|||
for (const node of nodes) { |
|||
result.push({ |
|||
...node, |
|||
level, |
|||
hasChildren: node.children && node.children.length > 0 |
|||
}) |
|||
// 只展示已展开节点的子节点
|
|||
if (node.children && node.children.length > 0 && expandedIds.value.has(node.id)) { |
|||
traverse(node.children, level + 1) |
|||
} |
|||
} |
|||
} |
|||
traverse(categoryTree.value, 0) |
|||
return result |
|||
}) |
|||
|
|||
function toggleExpand(id) { |
|||
const newSet = new Set(expandedIds.value) |
|||
if (newSet.has(id)) { |
|||
newSet.delete(id) |
|||
} else { |
|||
newSet.add(id) |
|||
} |
|||
expandedIds.value = newSet |
|||
} |
|||
|
|||
// 初始时展开所有有子节点的分类
|
|||
function expandAll() { |
|||
const ids = new Set() |
|||
function collectIds(nodes) { |
|||
for (const node of nodes) { |
|||
if (node.children && node.children.length > 0) { |
|||
ids.add(node.id) |
|||
collectIds(node.children) |
|||
} |
|||
} |
|||
} |
|||
collectIds(categoryTree.value) |
|||
expandedIds.value = ids |
|||
} |
|||
|
|||
// 加载分类后自动展开
|
|||
store.loadCategories().then(() => { |
|||
setTimeout(expandAll, 100) |
|||
}) |
|||
|
|||
// ==================== 编辑分类 ====================
|
|||
const editModal = ref({ |
|||
visible: false, |
|||
id: null, |
|||
name: '', |
|||
description: '', |
|||
sortOrder: 0 |
|||
}) |
|||
|
|||
function openEdit(node) { |
|||
editModal.value = { |
|||
visible: true, |
|||
id: node.id, |
|||
name: node.name, |
|||
description: node.description || '', |
|||
sortOrder: node.sortOrder || 0 |
|||
} |
|||
} |
|||
|
|||
function closeEdit() { |
|||
editModal.value = { visible: false, id: null, name: '', description: '', sortOrder: 0 } |
|||
} |
|||
|
|||
async function saveEdit() { |
|||
if (!editModal.value.name.trim()) { |
|||
toast('请输入分类名称', 'error') |
|||
return |
|||
} |
|||
try { |
|||
const json = await updateCategory(editModal.value.id, { |
|||
name: editModal.value.name.trim(), |
|||
description: editModal.value.description.trim(), |
|||
sortOrder: editModal.value.sortOrder |
|||
}) |
|||
if (json.success) { |
|||
toast('分类更新成功', 'success') |
|||
closeEdit() |
|||
await store.loadCategories() |
|||
setTimeout(expandAll, 100) |
|||
} else { |
|||
toast(json.message || '更新失败', 'error') |
|||
} |
|||
} catch (e) { |
|||
toast('更新分类失败:' + e.message, 'error') |
|||
} |
|||
} |
|||
|
|||
// ==================== 删除分类 ====================
|
|||
async function remove(node) { |
|||
const childCount = countDescendants(node) |
|||
const childMsg = childCount > 0 ? `\n注意:此分类下有 ${childCount} 个子分类,将一并清空关联` : '' |
|||
if (!confirm(`确定删除分类"${node.name}"?关联文档将变为未分类${childMsg}`)) return |
|||
try { |
|||
const json = await deleteCategory(node.id) |
|||
if (json.success) { |
|||
toast('分类已删除', 'success') |
|||
await store.loadCategories() |
|||
setTimeout(expandAll, 100) |
|||
} else { |
|||
toast(json.message || '删除失败', 'error') |
|||
} |
|||
} catch (e) { |
|||
toast('删除分类失败:' + e.message, 'error') |
|||
} |
|||
} |
|||
|
|||
function countDescendants(node) { |
|||
if (!node.children || node.children.length === 0) return 0 |
|||
let count = node.children.length |
|||
for (const child of node.children) { |
|||
count += countDescendants(child) |
|||
} |
|||
return count |
|||
} |
|||
|
|||
return { |
|||
newName, newDescription, newParentId, newSortOrder, parentOptions, parentLabel, |
|||
create, store, flatTree, expandedIds, toggleExpand, |
|||
editModal, openEdit, closeEdit, saveEdit, remove |
|||
} |
|||
} |
|||
} |
|||
@ -1,529 +0,0 @@ |
|||
/** |
|||
* 智能客服对话面板 |
|||
*/ |
|||
import { ref, computed, nextTick, onMounted } from 'vue' |
|||
import { chatSync, chatRagSync, chatSSEUrl, chatRagSSEUrl, getRoleList, getActiveModelConfig, truncateConversation, ragSources, submitFeedback as submitFeedbackApi } from '../js/api.js' |
|||
import { toast, readSSEStream, readSSEStreamWithEvents, renderMarkdown } from '../js/utils.js' |
|||
import { store } from '../js/store.js' |
|||
import MessageSources from './MessageSources.js' |
|||
|
|||
const FALLBACK_ROLE = { |
|||
id: '', |
|||
key: 'general', |
|||
name: '客服', |
|||
desc: '可检索全部知识库', |
|||
tone: '用户咨询、业务办理、常见问题、问题受理与进度说明', |
|||
badge: '默认', |
|||
categoryIds: [] |
|||
} |
|||
|
|||
const QUICK_QUESTIONS = [ |
|||
'我的问题应该找哪个部门处理?', |
|||
'如何办理业务申请?', |
|||
'流程进度一直没更新怎么办?', |
|||
'费用报销需要准备哪些材料?', |
|||
'办公用品或资产怎么申请?' |
|||
] |
|||
|
|||
export default { |
|||
components: { 'message-sources': MessageSources }, |
|||
template: `
|
|||
<div class="chat-shell"> |
|||
<aside class="chat-sidebar"> |
|||
<div class="agent-card"> |
|||
<div class="agent-avatar">SB</div> |
|||
<div> |
|||
<div class="agent-name">Support Bot</div> |
|||
<div class="agent-status"><span></span>在线客服助手</div> |
|||
</div> |
|||
</div> |
|||
|
|||
<div class="side-section side-section-grow"> |
|||
<div class="side-label">客服助手</div> |
|||
<div class="assistant-list"> |
|||
<button |
|||
v-for="role in roles" |
|||
:key="role.key" |
|||
:class="['assistant-card', selectedRole === role.key ? 'active' : '']" |
|||
@click="selectRole(role.key)" |
|||
> |
|||
<span class="assistant-avatar-sm">{{ role.badge }}</span> |
|||
<span class="assistant-meta"> |
|||
<strong>{{ role.name }}</strong> |
|||
<small>{{ role.desc }}</small> |
|||
</span> |
|||
</button> |
|||
</div> |
|||
</div> |
|||
</aside> |
|||
|
|||
<section class="chat-main"> |
|||
<header class="chat-header"> |
|||
<div class="chat-title"> |
|||
<h2>{{ currentRole.name }}</h2> |
|||
<div class="chat-subline"> |
|||
<span :class="['rag-dot', isRagMode ? 'on' : '']"></span>{{ ragStatusText }} |
|||
<span class="dot-sep">·</span>{{ selectedCategoryNames.length ? selectedCategoryNames.join(String.fromCharCode(12289)) : '\u5168\u90e8\u77e5\u8bc6\u5e93' }} |
|||
</div> |
|||
</div> |
|||
<div class="chat-actions"> |
|||
<div class="model-chip" :class="{'model-chip-error': modelLoadError}" :title="modelTitle"> |
|||
<span>调用模型</span> |
|||
<strong>{{ activeModelText }}</strong> |
|||
</div> |
|||
<label class="rag-toggle"> |
|||
<input type="checkbox" v-model="isRagMode"> |
|||
<span>RAG 检索</span> |
|||
</label> |
|||
<select v-if="isRagMode" class="select rag-strategy" v-model="ragStrategy"> |
|||
<option value="NONE">不重写</option> |
|||
<option value="REWRITE">查询重写</option> |
|||
<option value="TRANSLATION">翻译扩展</option> |
|||
<option value="COMPRESSION">查询压缩</option> |
|||
<option value="MULTI_QUERY">多路扩展</option> |
|||
</select> |
|||
<select class="select chat-mode" v-model="mode"> |
|||
<option value="sync">同步调用</option> |
|||
<option value="sse">SSE 流式</option> |
|||
</select> |
|||
</div> |
|||
</header> |
|||
|
|||
<div class="quick-row" v-if="messages.length <= 1"> |
|||
<button v-for="q in quickQuestions" :key="q" @click="useQuickQuestion(q)">{{ q }}</button> |
|||
</div> |
|||
|
|||
<div class="msg-area chat-msg-area" ref="msgArea"> |
|||
<div v-for="(m, i) in messages" :key="i" :class="['msg', m.role, m.streaming ? 'streaming' : '']"> |
|||
<div class="msg-content"> |
|||
<template v-if="editingIndex === i"> |
|||
<textarea class="textarea edit-textarea" v-model="editingText" rows="2" |
|||
@keydown.enter.exact.prevent="submitEdit(i)"></textarea> |
|||
<div class="edit-actions"> |
|||
<button class="btn btn-primary btn-sm" @click="submitEdit(i)" :disabled="isSending || !editingText.trim()">重新发送</button> |
|||
<button class="btn btn-outline btn-sm" @click="cancelEdit">取消</button> |
|||
<span>将清除此条之后的全部对话并重发</span> |
|||
</div> |
|||
</template> |
|||
<template v-else> |
|||
<!-- MCP 工具调用展示 --> |
|||
<div v-if="m.role === 'assistant' && m.toolCalls && m.toolCalls.length" class="mcp-tool-calls"> |
|||
<div v-for="(tc, j) in m.toolCalls" :key="j" class="mcp-tool-item"> |
|||
<div class="mcp-tool-header"> |
|||
<span class="mcp-tool-icon">{{ tc.status === 'running' ? '⏳' : '✅' }}</span> |
|||
<span class="mcp-tool-name">{{ tc.tool }}</span> |
|||
<span v-if="tc.latencyMs" class="mcp-tool-latency">{{ tc.latencyMs }}ms</span> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
<div v-if="m.role === 'assistant' && m.content" class="msg-bubble markdown-body" v-html="renderMd(m.content)"></div> |
|||
<div v-else-if="m.role === 'assistant'" class="msg-bubble"><span class="thinking">正在思考</span></div> |
|||
<div v-else class="msg-bubble">{{ m.content }}</div> |
|||
<message-sources v-if="m.role === 'assistant' && m.sources && m.sources.length" :sources="m.sources"></message-sources> |
|||
<div class="msg-tools"> |
|||
<span>{{ m.time }}</span> |
|||
<button v-if="m.role === 'assistant' && m.content" @click="copyMessage(m.content)">复制</button> |
|||
<button v-if="m.role === 'assistant' && m.content && !m.streaming" @click="regenerate(i)">重新生成</button> |
|||
<button v-if="m.role === 'user' && !m.streaming" @click="startEditMessage(i)">编辑</button> |
|||
<button v-if="m.error" @click="retryLast">重试</button> |
|||
<template v-if="m.role === 'assistant' && m.content && !m.streaming"> |
|||
<button @click="submitFeedback(m.id, 'up')" :style="{color: m.feedback === 'up' ? 'var(--primary)' : ''}" title="有帮助">👍</button> |
|||
<button @click="submitFeedback(m.id, 'down')" :style="{color: m.feedback === 'down' ? '#dc3545' : ''}" title="没帮助">👎</button> |
|||
</template> |
|||
</div> |
|||
</template> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
|
|||
<footer class="chat-composer"> |
|||
<div class="composer-box"> |
|||
<textarea |
|||
class="chat-textarea" |
|||
v-model="userInput" |
|||
placeholder="输入问题,Enter 发送,Shift + Enter 换行" |
|||
@keydown.enter.exact.prevent="send" |
|||
:disabled="isSending" |
|||
></textarea> |
|||
<button class="send-btn" @click="send" :disabled="isSending || !userInput.trim()"> |
|||
{{ isSending ? '发送中' : '发送' }} |
|||
</button> |
|||
</div> |
|||
</footer> |
|||
</section> |
|||
</div> |
|||
`,
|
|||
setup() { |
|||
const chatId = ref('') |
|||
const mode = ref('sync') |
|||
const selectedRole = ref('general') |
|||
const roles = ref([FALLBACK_ROLE]) |
|||
const isRagMode = ref(false) |
|||
const ragStrategy = ref('MULTI_QUERY') |
|||
const activeModel = ref(null) |
|||
const modelLoadError = ref('') |
|||
const userInput = ref('') |
|||
const lastUserInput = ref('') |
|||
const isSending = ref(false) |
|||
const messages = ref([ |
|||
{ |
|||
role: 'assistant', |
|||
content: '\u60a8\u597d\uff0c\u6211\u662f\u667a\u80fd\u5ba2\u670d\u52a9\u624b\u3002\u53ef\u4ee5\u54a8\u8be2\u5ba2\u670d\u3001\u8d22\u52a1\u3001\u884c\u653f\u76f8\u5173\u95ee\u9898\uff1b\u9700\u8981\u57fa\u4e8e\u77e5\u8bc6\u5e93\u56de\u7b54\u65f6\uff0c\u53ef\u4ee5\u5728\u4e0a\u65b9\u5f00\u542f RAG \u68c0\u7d22\u3002', |
|||
streaming: false, |
|||
time: formatTime() |
|||
} |
|||
]) |
|||
const msgArea = ref(null) |
|||
const editingIndex = ref(-1) |
|||
const editingText = ref('') |
|||
|
|||
const currentRole = computed(() => roles.value.find(role => role.key === selectedRole.value) || roles.value[0] || FALLBACK_ROLE) |
|||
const selectedCategoryIds = computed(() => currentRole.value.categoryIds || []) |
|||
const selectedCategoryNames = computed(() => { |
|||
const selected = new Set(selectedCategoryIds.value) |
|||
return store.categories |
|||
.filter(category => selected.has(String(category.id))) |
|||
.map(category => category.name) |
|||
}) |
|||
const activeModelText = computed(() => { |
|||
if (activeModel.value) { |
|||
const provider = providerLabel(activeModel.value.provider) |
|||
const modelName = activeModel.value.modelName || activeModel.value.model_name || '-' |
|||
return provider ? `${provider} / ${modelName}` : modelName |
|||
} |
|||
return modelLoadError.value ? '未配置' : '加载中' |
|||
}) |
|||
const modelTitle = computed(() => { |
|||
if (activeModel.value) { |
|||
const model = activeModel.value |
|||
const name = model.name ? `${model.name}:` : '' |
|||
const provider = providerLabel(model.provider) |
|||
const modelName = model.modelName || model.model_name || '-' |
|||
return `对话模型:${name}${provider ? provider + ' / ' : ''}${modelName}` |
|||
} |
|||
return modelLoadError.value || '正在加载对话模型' |
|||
}) |
|||
const ragStatusText = computed(() => { |
|||
if (!isRagMode.value) return '普通对话' |
|||
const names = { |
|||
NONE: 'RAG:不重写', |
|||
REWRITE: 'RAG:查询重写', |
|||
TRANSLATION: 'RAG:翻译扩展', |
|||
COMPRESSION: 'RAG:查询压缩', |
|||
MULTI_QUERY: 'RAG:多路扩展' |
|||
} |
|||
return names[ragStrategy.value] || 'RAG 已启用' |
|||
}) |
|||
|
|||
function formatTime() { |
|||
return new Date().toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' }) |
|||
} |
|||
|
|||
function generateMsgId() { |
|||
return 'msg_' + Date.now() + '_' + Math.random().toString(36).substring(2, 8) |
|||
} |
|||
|
|||
function newChatId() { |
|||
chatId.value = 'web_' + Date.now() + '_' + Math.random().toString(36).slice(2, 8) |
|||
} |
|||
function selectRole(roleKey) { |
|||
selectedRole.value = roleKey |
|||
newChatId() |
|||
clearMessages(roleKey) |
|||
} |
|||
function clearMessages(roleKey = selectedRole.value) { |
|||
const role = roles.value.find(item => item.key === roleKey) || FALLBACK_ROLE |
|||
messages.value = [{ |
|||
role: 'assistant', |
|||
content: `已切换到${role.name}。请直接输入你的问题。`, |
|||
streaming: false, |
|||
time: formatTime() |
|||
}] |
|||
} |
|||
|
|||
function useQuickQuestion(question) { |
|||
userInput.value = question |
|||
send() |
|||
} |
|||
|
|||
function providerLabel(provider) { |
|||
const map = { |
|||
dashscope: '通义千问', |
|||
openai: 'OpenAI', |
|||
volcengine: '豆包', |
|||
zhipu: '智谱', |
|||
baidu: '百度千帆', |
|||
other: '自定义' |
|||
} |
|||
return map[String(provider || '').toLowerCase()] || provider || '' |
|||
} |
|||
|
|||
async function loadActiveModel() { |
|||
try { |
|||
const json = await getActiveModelConfig('CHAT') |
|||
if (json.success) { |
|||
activeModel.value = json.data |
|||
modelLoadError.value = '' |
|||
} else { |
|||
activeModel.value = null |
|||
modelLoadError.value = json.message || '对话模型未配置' |
|||
} |
|||
} catch (e) { |
|||
activeModel.value = null |
|||
modelLoadError.value = e.message || '模型加载失败' |
|||
} |
|||
} |
|||
|
|||
async function loadRoles() { |
|||
try { |
|||
const json = await getRoleList() |
|||
if (json.success) { |
|||
roles.value = (json.data || []).map(role => ({ |
|||
id: role.id, |
|||
key: role.role_key || role.roleKey || String(role.id), |
|||
name: role.name, |
|||
desc: role.description || '', |
|||
tone: role.description || '专业、耐心、简洁', |
|||
badge: role.name ? role.name.slice(0, 2) : '角色', |
|||
categoryIds: (role.categoryIds || []).map(String) |
|||
})) |
|||
if (!roles.value.some(role => role.key === selectedRole.value)) { |
|||
selectedRole.value = roles.value[0]?.key || 'general' |
|||
} |
|||
} |
|||
} catch (e) { |
|||
toast('加载客服角色失败:' + e.message, 'error') |
|||
} |
|||
} |
|||
|
|||
|
|||
function currentRoleId() { |
|||
// 角色人设与知识库范围统一由服务端依据 roleId 套用,这里仅取出当前角色ID。
|
|||
// 空ID(如未加载完成的兜底角色)时返回空串,服务端将退回基础提示词与全量知识库。
|
|||
return currentRole.value && currentRole.value.id ? String(currentRole.value.id) : '' |
|||
} |
|||
|
|||
|
|||
async function scrollToBottom() { |
|||
await nextTick() |
|||
if (msgArea.value) msgArea.value.scrollTop = msgArea.value.scrollHeight |
|||
} |
|||
|
|||
async function send() { |
|||
const text = userInput.value.trim() |
|||
if (!text || isSending.value) return |
|||
|
|||
userInput.value = '' |
|||
lastUserInput.value = text |
|||
isSending.value = true |
|||
messages.value.push({ id: generateMsgId(), role: 'user', content: text, streaming: false, time: formatTime() }) |
|||
|
|||
const assistantMsg = { id: generateMsgId(), role: 'assistant', content: '', streaming: true, time: formatTime(), sources: [], toolCalls: [] } |
|||
messages.value.push(assistantMsg) |
|||
await scrollToBottom() |
|||
|
|||
const cid = chatId.value || ('web_' + Date.now()) |
|||
chatId.value = cid |
|||
// 发送用户原始问题(不再包装角色信息,避免污染会话记忆);
|
|||
// 角色人设与知识库范围由服务端依据 roleId 强制套用。
|
|||
const roleId = currentRoleId() |
|||
|
|||
try { |
|||
if (isRagMode.value && mode.value === 'sync') { |
|||
assistantMsg.content = await chatRagSync(text, cid, ragStrategy.value, roleId) |
|||
} else if (isRagMode.value) { |
|||
const url = chatRagSSEUrl(text, cid, ragStrategy.value, roleId) |
|||
await readSSEStreamWithEvents(url, { |
|||
onMessage: async (chunk) => { |
|||
assistantMsg.content += chunk |
|||
await scrollToBottom() |
|||
}, |
|||
onToolCallStart: (data) => { |
|||
assistantMsg.toolCalls.push({ tool: data.tool, input: data.input, status: 'running', result: null }) |
|||
scrollToBottom() |
|||
}, |
|||
onToolCallResult: (data) => { |
|||
const tc = assistantMsg.toolCalls.find(t => t.tool === data.tool && t.status === 'running') |
|||
if (tc) { |
|||
tc.status = 'done' |
|||
tc.result = data.result |
|||
tc.latencyMs = data.latencyMs |
|||
} |
|||
scrollToBottom() |
|||
}, |
|||
onError: (data) => { |
|||
assistantMsg.content += '\n\n⚠️ ' + (data.message || '工具调用出错') |
|||
}, |
|||
onDone: () => {} |
|||
}) |
|||
} else if (mode.value === 'sync') { |
|||
assistantMsg.content = await chatSync(text, cid, roleId) |
|||
} else { |
|||
const url = chatSSEUrl(text, cid, roleId) |
|||
await readSSEStream(url, async (chunk) => { |
|||
assistantMsg.content += chunk |
|||
await scrollToBottom() |
|||
}, () => {}) |
|||
} |
|||
// RAG 模式:回答完成后拉取本次命中的知识库片段,挂到该条回答下方
|
|||
if (isRagMode.value) { |
|||
try { |
|||
const sj = await ragSources(text, cid, ragStrategy.value, roleId) |
|||
if (sj && sj.success) assistantMsg.sources = sj.data || [] |
|||
} catch (_) { /* 来源获取失败不影响主回答 */ } |
|||
} |
|||
} catch (e) { |
|||
assistantMsg.content = '请求失败:' + e.message |
|||
assistantMsg.error = true |
|||
toast('对话失败:' + e.message, 'error') |
|||
} finally { |
|||
assistantMsg.streaming = false |
|||
isSending.value = false |
|||
await scrollToBottom() |
|||
} |
|||
} |
|||
|
|||
function retryLast() { |
|||
if (!lastUserInput.value || isSending.value) return |
|||
userInput.value = lastUserInput.value |
|||
send() |
|||
} |
|||
|
|||
function startEditMessage(i) { |
|||
if (isSending.value) return |
|||
editingIndex.value = i |
|||
editingText.value = messages.value[i].content || '' |
|||
} |
|||
|
|||
function cancelEdit() { |
|||
editingIndex.value = -1 |
|||
editingText.value = '' |
|||
} |
|||
|
|||
async function submitEdit(i) { |
|||
const text = editingText.value.trim() |
|||
if (!text || isSending.value) return |
|||
// 这是第几条用户消息(1-based,忽略问候语 / AI 消息)
|
|||
let userTurn = 0 |
|||
for (let j = 0; j <= i; j++) { |
|||
if (messages.value[j].role === 'user') userTurn++ |
|||
} |
|||
// 先让后端清掉这条用户消息及其之后的全部消息(修复对话记忆,避免污染上下文)
|
|||
try { |
|||
const json = await truncateConversation(chatId.value, userTurn) |
|||
if (json && json.success === false) { |
|||
toast(json.message || '截断失败', 'error') |
|||
return |
|||
} |
|||
} catch (e) { |
|||
toast('截断失败:' + e.message, 'error') |
|||
return |
|||
} |
|||
// 切掉本地的本条及其之后消息,再用新内容重发
|
|||
messages.value = messages.value.slice(0, i) |
|||
editingIndex.value = -1 |
|||
editingText.value = '' |
|||
userInput.value = text |
|||
await send() |
|||
} |
|||
|
|||
async function regenerate(i) { |
|||
if (isSending.value) return |
|||
// 找到这条 AI 回答对应的上一条用户消息
|
|||
let userIndex = -1 |
|||
for (let j = i - 1; j >= 0; j--) { |
|||
if (messages.value[j].role === 'user') { userIndex = j; break } |
|||
} |
|||
if (userIndex < 0) return |
|||
const text = messages.value[userIndex].content |
|||
let userTurn = 0 |
|||
for (let j = 0; j <= userIndex; j++) { |
|||
if (messages.value[j].role === 'user') userTurn++ |
|||
} |
|||
// 截断该用户消息及其之后(含本条回答),用相同问题重新生成
|
|||
try { |
|||
const json = await truncateConversation(chatId.value, userTurn) |
|||
if (json && json.success === false) { toast(json.message || '重新生成失败', 'error'); return } |
|||
} catch (e) { toast('重新生成失败:' + e.message, 'error'); return } |
|||
messages.value = messages.value.slice(0, userIndex) |
|||
userInput.value = text |
|||
await send() |
|||
} |
|||
|
|||
async function copyMessage(content) { |
|||
try { |
|||
await navigator.clipboard.writeText(content) |
|||
toast('已复制回复', 'success') |
|||
} catch (e) { |
|||
toast('复制失败', 'error') |
|||
} |
|||
} |
|||
|
|||
// ===== P0-002: 消息反馈 =====
|
|||
async function submitFeedback(msgId, type) { |
|||
const msgIndex = messages.value.findIndex(m => m.id === msgId && m.role === 'assistant') |
|||
if (msgIndex === -1) return |
|||
|
|||
const msg = messages.value[msgIndex] |
|||
const wasActive = msg.feedback === type |
|||
const newFeedback = wasActive ? null : type |
|||
msg.feedback = newFeedback |
|||
|
|||
try { |
|||
await submitFeedbackApi({ |
|||
messageId: String(msgId), |
|||
conversationId: chatId.value, |
|||
feedbackType: newFeedback ? (newFeedback === 'up' ? 'THUMBS_UP' : 'THUMBS_DOWN') : null |
|||
}) |
|||
if (newFeedback) { |
|||
toast(newFeedback === 'up' ? '感谢反馈 👍' : '感谢反馈,我们会持续改进', 'success') |
|||
} |
|||
} catch (e) { |
|||
console.error('反馈提交失败:', e) |
|||
} |
|||
} |
|||
|
|||
onMounted(() => { |
|||
newChatId() |
|||
store.loadCategories() |
|||
loadRoles() |
|||
loadActiveModel() |
|||
}) |
|||
|
|||
return { |
|||
store, |
|||
roles, |
|||
quickQuestions: QUICK_QUESTIONS, |
|||
chatId, |
|||
mode, |
|||
selectedRole, |
|||
selectedCategoryNames, |
|||
isRagMode, |
|||
ragStrategy, |
|||
activeModelText, |
|||
modelTitle, |
|||
modelLoadError, |
|||
userInput, |
|||
isSending, |
|||
messages, |
|||
msgArea, |
|||
currentRole, |
|||
ragStatusText, |
|||
newChatId, |
|||
selectRole, |
|||
clearMessages, |
|||
useQuickQuestion, |
|||
send, |
|||
retryLast, |
|||
copyMessage, |
|||
renderMd: renderMarkdown, |
|||
editingIndex, |
|||
editingText, |
|||
startEditMessage, |
|||
cancelEdit, |
|||
submitEdit, |
|||
regenerate, |
|||
submitFeedback |
|||
} |
|||
} |
|||
} |
|||
@ -1,255 +0,0 @@ |
|||
/** |
|||
* 💬 会话管理组件 |
|||
* 展示会话列表、消息详情、删除、导出功能 |
|||
*/ |
|||
import { ref } from 'vue' |
|||
import { listConversations, deleteConversation, getConversationMessages, getConversationStats, exportConversation, getRoleList } from '../js/api.js' |
|||
import { toast, formatDate } from '../js/utils.js' |
|||
|
|||
export default { |
|||
template: `
|
|||
<div class="card"> |
|||
<h2>会话列表</h2> |
|||
|
|||
<!-- 统计卡片 --> |
|||
<div class="stat-grid" v-if="stats" style="margin-bottom:16px;"> |
|||
<div class="stat-card"> |
|||
<div class="number">{{ stats.totalConversations || 0 }}</div> |
|||
<div class="label">总会话数</div> |
|||
</div> |
|||
<div class="stat-card"> |
|||
<div class="number">{{ stats.totalMessages || 0 }}</div> |
|||
<div class="label">总消息数</div> |
|||
</div> |
|||
<div class="stat-card"> |
|||
<div class="number">{{ stats.todayConversations || 0 }}</div> |
|||
<div class="label">今日会话</div> |
|||
</div> |
|||
<div class="stat-card"> |
|||
<div class="number">{{ stats.todayMessages || 0 }}</div> |
|||
<div class="label">今日消息</div> |
|||
</div> |
|||
</div> |
|||
|
|||
<!-- 搜索栏 --> |
|||
<div class="input-row"> |
|||
<input type="text" class="input" v-model="keyword" placeholder="搜索会话ID或消息内容..." @keyup.enter="load(1)"> |
|||
<input type="text" class="input" v-model="accountFilter" placeholder="外部用户ID(accountId)" @keyup.enter="load(1)"> |
|||
<select class="select" v-model="roleFilter"> |
|||
<option value="">全部角色</option> |
|||
<option v-for="role in roles" :key="role.id" :value="role.id">{{ role.name }}</option> |
|||
</select> |
|||
<button class="btn btn-primary btn-sm" @click="load(1)">搜索</button> |
|||
<button class="btn btn-outline btn-sm" @click="load()">刷新</button> |
|||
</div> |
|||
|
|||
<!-- 会话列表表格 --> |
|||
<div style="overflow-x:auto;"> |
|||
<table class="data-table"> |
|||
<thead> |
|||
<tr> |
|||
<th>会话ID</th> |
|||
<th>外部用户ID</th> |
|||
<th>角色</th> |
|||
<th>消息数</th> |
|||
<th>最后消息时间</th> |
|||
<th>最后消息预览</th> |
|||
<th>操作</th> |
|||
</tr> |
|||
</thead> |
|||
<tbody> |
|||
<tr v-if="conversations.length === 0"> |
|||
<td colspan="7" style="text-align:center;color:var(--sub);">暂无会话记录</td> |
|||
</tr> |
|||
<tr v-for="c in conversations" :key="c.conversationId"> |
|||
<td> |
|||
<code style="font-size:12px;background:#f3f4f6;padding:2px 6px;border-radius:4px;">{{ c.conversationId }}</code> |
|||
</td> |
|||
<td>{{ c.accountId || '-' }}</td> |
|||
<td>{{ c.roleName || '-' }}</td> |
|||
<td>{{ c.messageCount }}</td> |
|||
<td>{{ formatDate(c.lastMessageTime) }}</td> |
|||
<td style="max-width:300px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;"> |
|||
{{ c.lastMessagePreview || '-' }} |
|||
</td> |
|||
<td> |
|||
<button class="btn btn-sm btn-outline" @click="viewMessages(c.conversationId)">查看消息</button> |
|||
<button class="btn btn-sm btn-primary" @click="downloadExport(c.conversationId)">导出</button> |
|||
<button class="btn btn-sm btn-danger" @click="remove(c.conversationId)">删除</button> |
|||
</td> |
|||
</tr> |
|||
</tbody> |
|||
</table> |
|||
</div> |
|||
|
|||
<!-- 分页 --> |
|||
<div class="pagination" v-if="pages > 1"> |
|||
<button :disabled="page <= 1" @click="load(page - 1)">上一页</button> |
|||
<template v-for="i in pages" :key="i"> |
|||
<button v-if="i === 1 || i === pages || (i >= page - 2 && i <= page + 2)" |
|||
:class="{ active: i === page }" @click="load(i)">{{ i }}</button> |
|||
<span v-else-if="i === page - 3 || i === page + 3" style="padding:6px;">...</span> |
|||
</template> |
|||
<button :disabled="page >= pages" @click="load(page + 1)">下一页</button> |
|||
</div> |
|||
</div> |
|||
|
|||
<!-- 消息详情弹窗 --> |
|||
<div class="modal-overlay" :class="{ active: messageModal.visible }" @click.self="closeMessageModal"> |
|||
<div class="modal-box" style="max-width:900px;"> |
|||
<button class="modal-close" @click="closeMessageModal">×</button> |
|||
<h2>会话消息详情</h2> |
|||
<p style="font-size:13px;color:var(--sub);margin-bottom:12px;"> |
|||
会话ID: <code>{{ messageModal.conversationId }}</code> |
|||
<span v-if="messageModal.roleName"> · 角色:{{ messageModal.roleName }}</span> |
|||
· 共 {{ messageModal.messages.length }} 条消息 |
|||
</p> |
|||
|
|||
<div v-if="messageModal.messages.length === 0" style="text-align:center;color:var(--sub);padding:40px;"> |
|||
暂无消息 |
|||
</div> |
|||
|
|||
<div v-else class="message-list" style="max-height:500px;overflow-y:auto;"> |
|||
<div v-for="msg in messageModal.messages" :key="msg.id" |
|||
:class="['msg-item', msg.messageType === 'USER' ? 'msg-user' : msg.messageType === 'ASSISTANT' ? 'msg-assistant' : 'msg-system']" |
|||
style="margin-bottom:12px;padding:12px;border-radius:8px;"> |
|||
<div style="display:flex;align-items:center;gap:8px;margin-bottom:6px;"> |
|||
<span class="badge" :class="msg.messageType === 'USER' ? 'badge-get' : msg.messageType === 'ASSISTANT' ? 'badge-post' : ''"> |
|||
{{ msg.messageType === 'USER' ? '用户' : msg.messageType === 'ASSISTANT' ? 'AI助手' : '系统' }} |
|||
</span> |
|||
<span style="font-size:11px;color:var(--sub);">{{ formatDate(msg.createTime) }}</span> |
|||
</div> |
|||
<div style="font-size:13px;line-height:1.6;white-space:pre-wrap;word-break:break-word;">{{ msg.content }}</div> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
`,
|
|||
setup() { |
|||
const conversations = ref([]) |
|||
const page = ref(1) |
|||
const pages = ref(1) |
|||
const total = ref(0) |
|||
const keyword = ref('') |
|||
const roleFilter = ref('') |
|||
const accountFilter = ref('') |
|||
const roles = ref([]) |
|||
const stats = ref(null) |
|||
|
|||
// 消息详情弹窗
|
|||
const messageModal = ref({ |
|||
visible: false, |
|||
conversationId: '', |
|||
roleName: '', |
|||
messages: [] |
|||
}) |
|||
|
|||
async function load(p = 1) { |
|||
page.value = p |
|||
try { |
|||
const json = await listConversations( |
|||
p, 10, |
|||
keyword.value || undefined, |
|||
accountFilter.value || undefined, |
|||
roleFilter.value || undefined |
|||
) |
|||
if (json.success) { |
|||
conversations.value = json.data || [] |
|||
total.value = json.total || 0 |
|||
pages.value = json.pages || 1 |
|||
} else { |
|||
toast(json.message || '查询失败', 'error') |
|||
} |
|||
} catch (e) { |
|||
toast('加载会话失败:' + e.message, 'error') |
|||
} |
|||
} |
|||
|
|||
async function loadFilters() { |
|||
try { |
|||
const roleJson = await getRoleList() |
|||
if (roleJson.success) roles.value = roleJson.data || [] |
|||
} catch (e) { |
|||
console.error('加载筛选项失败', e) |
|||
} |
|||
} |
|||
async function loadStats() { |
|||
try { |
|||
const json = await getConversationStats() |
|||
if (json.success) { |
|||
stats.value = json.data |
|||
} |
|||
} catch (e) { |
|||
console.error('加载会话统计失败', e) |
|||
} |
|||
} |
|||
|
|||
async function viewMessages(conversationId) { |
|||
try { |
|||
const json = await getConversationMessages(conversationId) |
|||
if (json.success) { |
|||
messageModal.value.conversationId = conversationId |
|||
messageModal.value.messages = json.data || [] |
|||
const current = conversations.value.find(item => item.conversationId === conversationId) |
|||
messageModal.value.roleName = current?.roleName || '' |
|||
messageModal.value.visible = true |
|||
} else { |
|||
toast(json.message || '加载消息失败', 'error') |
|||
} |
|||
} catch (e) { |
|||
toast('加载消息失败:' + e.message, 'error') |
|||
} |
|||
} |
|||
|
|||
function closeMessageModal() { |
|||
messageModal.value.visible = false |
|||
messageModal.value.conversationId = '' |
|||
messageModal.value.roleName = '' |
|||
messageModal.value.messages = [] |
|||
} |
|||
|
|||
async function remove(conversationId) { |
|||
if (!confirm('确定删除此会话?该会话下的所有消息将被逻辑删除')) return |
|||
try { |
|||
const json = await deleteConversation(conversationId) |
|||
if (json.success) { |
|||
toast(`会话删除成功,共删除 ${json.deletedMessages || 0} 条消息`, 'success') |
|||
load(page.value) |
|||
loadStats() |
|||
} else { |
|||
toast(json.message || '删除失败', 'error') |
|||
} |
|||
} catch (e) { |
|||
toast('删除失败:' + e.message, 'error') |
|||
} |
|||
} |
|||
|
|||
async function downloadExport(conversationId) { |
|||
try { |
|||
const content = await exportConversation(conversationId) |
|||
const blob = new Blob([content], { type: 'text/plain;charset=utf-8' }) |
|||
const url = URL.createObjectURL(blob) |
|||
const a = document.createElement('a') |
|||
a.href = url |
|||
a.download = `conversation_${conversationId}.txt` |
|||
document.body.appendChild(a) |
|||
a.click() |
|||
document.body.removeChild(a) |
|||
URL.revokeObjectURL(url) |
|||
toast('导出成功', 'success') |
|||
} catch (e) { |
|||
toast('导出失败:' + e.message, 'error') |
|||
} |
|||
} |
|||
|
|||
// 初始加载
|
|||
loadFilters() |
|||
load() |
|||
loadStats() |
|||
|
|||
return { |
|||
conversations, page, pages, total, keyword, accountFilter, roleFilter, roles, stats, messageModal, |
|||
load, loadStats, viewMessages, closeMessageModal, remove, downloadExport, formatDate |
|||
} |
|||
} |
|||
} |
|||
@ -1,340 +0,0 @@ |
|||
/** |
|||
* 运营看板组件 |
|||
* 指标卡片 + Chart.js 趋势图表 + 知识库文档命中排行 + 未命中问题收集 |
|||
*/ |
|||
import { ref, computed, onMounted, onBeforeUnmount, nextTick } from 'vue' |
|||
import { getDashboardOverview, getDashboardTrend, getDashboardKnowledge, getDashboardCustom } from '../js/api.js' |
|||
|
|||
export default { |
|||
template: `
|
|||
<div class="dashboard-panel"> |
|||
<!-- 时间范围选择器 --> |
|||
<div class="card"> |
|||
<div style="display:flex; align-items:center; justify-content:space-between; flex-wrap:wrap; gap:12px;"> |
|||
<h2 style="margin:0;">📈 运营数据看板</h2> |
|||
<div style="display:flex; align-items:center; gap:8px;"> |
|||
<button class="btn btn-sm" :class="rangeDays === 7 ? 'btn-primary' : 'btn-outline'" @click="setRange(7)">近7天</button> |
|||
<button class="btn btn-sm" :class="rangeDays === 30 ? 'btn-primary' : 'btn-outline'" @click="setRange(30)">近30天</button> |
|||
<span style="color:var(--muted); font-size:13px;">|</span> |
|||
<input type="date" class="input" style="width:140px; flex:none; padding:6px 10px; font-size:13px;" v-model="customStart" /> |
|||
<span style="color:var(--muted); font-size:13px;">至</span> |
|||
<input type="date" class="input" style="width:140px; flex:none; padding:6px 10px; font-size:13px;" v-model="customEnd" /> |
|||
<button class="btn btn-sm btn-purple" @click="loadCustomRange" :disabled="!customStart || !customEnd">查询</button> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
|
|||
<!-- 4个指标卡片 --> |
|||
<div class="stat-grid"> |
|||
<div class="stat-card"> |
|||
<div class="number">{{ overview.conversationCount ?? 0 }}</div> |
|||
<div class="label">今日对话数</div> |
|||
</div> |
|||
<div class="stat-card"> |
|||
<div class="number" :style="{ color: satisfactionColor }">{{ formatPercent(overview.satisfactionRate) }}%</div> |
|||
<div class="label">满意率</div> |
|||
</div> |
|||
<div class="stat-card"> |
|||
<div class="number" style="color: var(--success);">{{ formatPercent(overview.ragHitRate) }}%</div> |
|||
<div class="label">知识库命中率</div> |
|||
</div> |
|||
<div class="stat-card"> |
|||
<div class="number">{{ overview.avgResponseTime ?? 0 }}<span style="font-size:14px; color:var(--muted);">ms</span></div> |
|||
<div class="label">平均响应时间</div> |
|||
</div> |
|||
</div> |
|||
|
|||
<!-- 图表区域 --> |
|||
<div style="display:grid; grid-template-columns:1fr 1fr; gap:16px; margin-bottom:16px;"> |
|||
<!-- 对话量趋势折线图 --> |
|||
<div class="card"> |
|||
<h3 style="font-size:14px; font-weight:600; color:var(--text); margin-bottom:12px;">📊 对话量趋势</h3> |
|||
<div style="position:relative; height:260px;"> |
|||
<canvas ref="conversationChartRef"></canvas> |
|||
</div> |
|||
</div> |
|||
<!-- 满意度趋势折线图 --> |
|||
<div class="card"> |
|||
<h3 style="font-size:14px; font-weight:600; color:var(--text); margin-bottom:12px;">📊 满意度趋势</h3> |
|||
<div style="position:relative; height:260px;"> |
|||
<canvas ref="satisfactionChartRef"></canvas> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
|
|||
<!-- 知识库分析 --> |
|||
<div style="display:grid; grid-template-columns:1fr 1fr; gap:16px;"> |
|||
<!-- 文档命中排行 TOP-10 --> |
|||
<div class="card"> |
|||
<h3 style="font-size:14px; font-weight:600; color:var(--text); margin-bottom:12px;">📚 文档命中排行 TOP-10</h3> |
|||
<div v-if="topHitDocuments.length === 0" class="empty" style="padding:30px;">暂无数据</div> |
|||
<table v-else style="width:100%; font-size:13px; border-collapse:collapse;"> |
|||
<thead> |
|||
<tr style="border-bottom:1px solid var(--border);"> |
|||
<th style="text-align:left; padding:8px 4px; color:var(--sub); font-weight:600;">排名</th> |
|||
<th style="text-align:left; padding:8px 4px; color:var(--sub); font-weight:600;">文档标题</th> |
|||
<th style="text-align:right; padding:8px 4px; color:var(--sub); font-weight:600;">命中次数</th> |
|||
<th style="text-align:right; padding:8px 4px; color:var(--sub); font-weight:600;">平均得分</th> |
|||
</tr> |
|||
</thead> |
|||
<tbody> |
|||
<tr v-for="(doc, idx) in topHitDocuments" :key="idx" style="border-bottom:1px solid var(--border);"> |
|||
<td style="padding:8px 4px; color:var(--muted);">{{ idx + 1 }}</td> |
|||
<td style="padding:8px 4px; max-width:200px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap;" :title="doc.document_title">{{ doc.document_title || '-' }}</td> |
|||
<td style="text-align:right; padding:8px 4px; font-weight:600; color:var(--primary);">{{ doc.hit_count ?? doc.cnt ?? 0 }}</td> |
|||
<td style="text-align:right; padding:8px 4px; color:var(--sub);">{{ doc.avg_score != null ? Number(doc.avg_score).toFixed(3) : '-' }}</td> |
|||
</tr> |
|||
</tbody> |
|||
</table> |
|||
</div> |
|||
|
|||
<!-- 未命中问题收集 --> |
|||
<div class="card"> |
|||
<h3 style="font-size:14px; font-weight:600; color:var(--text); margin-bottom:12px;">❓ 未命中问题收集</h3> |
|||
<div v-if="missQuestions.length === 0" class="empty" style="padding:30px;">暂无数据</div> |
|||
<div v-else style="max-height:400px; overflow-y:auto;"> |
|||
<div v-for="(item, idx) in missQuestions" :key="idx" |
|||
style="padding:8px 12px; border-bottom:1px solid var(--border); font-size:13px; display:flex; align-items:center; gap:8px;"> |
|||
<span style="color:var(--muted); flex:none; min-width:20px;">{{ idx + 1 }}.</span> |
|||
<span style="flex:1; word-break:break-all;">{{ item.user_query }}</span> |
|||
<span v-if="item.last_time" style="color:var(--muted); font-size:11px; flex:none;">{{ formatDateShort(item.last_time) }}</span> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
|
|||
<!-- 加载状态 --> |
|||
<div v-if="loading" style="text-align:center; padding:20px; color:var(--muted);">加载中...</div> |
|||
</div> |
|||
`,
|
|||
setup() { |
|||
const loading = ref(false) |
|||
const rangeDays = ref(7) |
|||
const customStart = ref('') |
|||
const customEnd = ref('') |
|||
const overview = ref({}) |
|||
const trendData = ref([]) |
|||
const topHitDocuments = ref([]) |
|||
const missQuestions = ref([]) |
|||
|
|||
// Chart.js 实例引用(用于销毁重建)
|
|||
let conversationChart = null |
|||
let satisfactionChart = null |
|||
const conversationChartRef = ref(null) |
|||
const satisfactionChartRef = ref(null) |
|||
|
|||
// 满意率颜色:>=80% 绿色,>=60% 橙色,<60% 红色
|
|||
const satisfactionColor = computed(() => { |
|||
const rate = overview.value.satisfactionRate || 0 |
|||
if (rate >= 80) return 'var(--success)' |
|||
if (rate >= 60) return 'var(--warn)' |
|||
return 'var(--danger)' |
|||
}) |
|||
|
|||
// 格式化百分比(保留一位小数)
|
|||
function formatPercent(val) { |
|||
if (val == null) return '0.0' |
|||
return Number(val).toFixed(1) |
|||
} |
|||
|
|||
// 格式化日期简写(仅月-日)
|
|||
function formatDateShort(dateVal) { |
|||
if (!dateVal) return '' |
|||
const d = new Date(dateVal) |
|||
return `${d.getMonth() + 1}/${d.getDate()}` |
|||
} |
|||
|
|||
// 设置时间范围并加载趋势
|
|||
async function setRange(days) { |
|||
rangeDays.value = days |
|||
await loadTrend(days) |
|||
} |
|||
|
|||
// 加载今日概览
|
|||
async function loadOverview() { |
|||
try { |
|||
const res = await getDashboardOverview() |
|||
if (res.success) overview.value = res.data |
|||
} catch (e) { |
|||
console.error('加载概览数据失败', e) |
|||
} |
|||
} |
|||
|
|||
// 加载趋势数据
|
|||
async function loadTrend(days) { |
|||
try { |
|||
const res = await getDashboardTrend(days) |
|||
if (res.success) { |
|||
trendData.value = res.data || [] |
|||
await nextTick() |
|||
renderCharts() |
|||
} |
|||
} catch (e) { |
|||
console.error('加载趋势数据失败', e) |
|||
} |
|||
} |
|||
|
|||
// 加载自定义范围数据
|
|||
async function loadCustomRange() { |
|||
if (!customStart.value || !customEnd.value) return |
|||
loading.value = true |
|||
try { |
|||
const res = await getDashboardCustom(customStart.value, customEnd.value) |
|||
if (res.success) { |
|||
trendData.value = res.data || [] |
|||
rangeDays.value = 0 |
|||
await nextTick() |
|||
renderCharts() |
|||
} |
|||
} catch (e) { |
|||
console.error('加载自定义范围数据失败', e) |
|||
} finally { |
|||
loading.value = false |
|||
} |
|||
} |
|||
|
|||
// 加载知识库分析
|
|||
async function loadKnowledgeAnalysis() { |
|||
try { |
|||
const res = await getDashboardKnowledge() |
|||
if (res.success) { |
|||
topHitDocuments.value = res.data?.topHitDocuments || [] |
|||
missQuestions.value = res.data?.missQuestions || [] |
|||
} |
|||
} catch (e) { |
|||
console.error('加载知识库分析数据失败', e) |
|||
} |
|||
} |
|||
|
|||
// 渲染 Chart.js 图表
|
|||
async function renderCharts() { |
|||
const { Chart, LineController, LineElement, PointElement, LinearScale, CategoryScale, Tooltip, Legend, Filler } = await import('chart.js') |
|||
|
|||
// 注册所需组件
|
|||
Chart.register(LineController, LineElement, PointElement, LinearScale, CategoryScale, Tooltip, Legend, Filler) |
|||
|
|||
const labels = trendData.value.map(s => { |
|||
const d = new Date(s.snapshotDate || s.snapshot_date) |
|||
return `${d.getMonth() + 1}/${d.getDate()}` |
|||
}) |
|||
|
|||
// === 对话量趋势图 ===
|
|||
if (conversationChart) conversationChart.destroy() |
|||
if (conversationChartRef.value) { |
|||
const convData = trendData.value.map(s => s.conversationCount ?? s.conversation_count ?? 0) |
|||
const msgData = trendData.value.map(s => s.messageCount ?? s.message_count ?? 0) |
|||
|
|||
conversationChart = new Chart(conversationChartRef.value, { |
|||
type: 'line', |
|||
data: { |
|||
labels, |
|||
datasets: [ |
|||
{ |
|||
label: '对话数', |
|||
data: convData, |
|||
borderColor: '#3b82f6', |
|||
backgroundColor: 'rgba(59,130,246,0.1)', |
|||
fill: true, |
|||
tension: 0.3, |
|||
pointRadius: 4, |
|||
pointHoverRadius: 6 |
|||
}, |
|||
{ |
|||
label: '消息数', |
|||
data: msgData, |
|||
borderColor: '#8b5cf6', |
|||
backgroundColor: 'rgba(139,92,246,0.1)', |
|||
fill: true, |
|||
tension: 0.3, |
|||
pointRadius: 4, |
|||
pointHoverRadius: 6 |
|||
} |
|||
] |
|||
}, |
|||
options: { |
|||
responsive: true, |
|||
maintainAspectRatio: false, |
|||
plugins: { legend: { position: 'bottom', labels: { boxWidth: 12, padding: 16, font: { size: 12 } } } }, |
|||
scales: { |
|||
y: { beginAtZero: true, ticks: { font: { size: 11 } }, grid: { color: 'rgba(0,0,0,0.05)' } }, |
|||
x: { ticks: { font: { size: 11 } }, grid: { display: false } } |
|||
} |
|||
} |
|||
}) |
|||
} |
|||
|
|||
// === 满意度趋势图(👍/👎 双线) ===
|
|||
if (satisfactionChart) satisfactionChart.destroy() |
|||
if (satisfactionChartRef.value) { |
|||
const upData = trendData.value.map(s => s.thumbsUpCount ?? s.thumbs_up_count ?? 0) |
|||
const downData = trendData.value.map(s => s.thumbsDownCount ?? s.thumbs_down_count ?? 0) |
|||
|
|||
satisfactionChart = new Chart(satisfactionChartRef.value, { |
|||
type: 'line', |
|||
data: { |
|||
labels, |
|||
datasets: [ |
|||
{ |
|||
label: '👍 有帮助', |
|||
data: upData, |
|||
borderColor: '#10b981', |
|||
backgroundColor: 'rgba(16,185,129,0.1)', |
|||
fill: true, |
|||
tension: 0.3, |
|||
pointRadius: 4, |
|||
pointHoverRadius: 6 |
|||
}, |
|||
{ |
|||
label: '👎 没帮助', |
|||
data: downData, |
|||
borderColor: '#ef4444', |
|||
backgroundColor: 'rgba(239,68,68,0.1)', |
|||
fill: true, |
|||
tension: 0.3, |
|||
pointRadius: 4, |
|||
pointHoverRadius: 6 |
|||
} |
|||
] |
|||
}, |
|||
options: { |
|||
responsive: true, |
|||
maintainAspectRatio: false, |
|||
plugins: { legend: { position: 'bottom', labels: { boxWidth: 12, padding: 16, font: { size: 12 } } } }, |
|||
scales: { |
|||
y: { beginAtZero: true, ticks: { font: { size: 11 } }, grid: { color: 'rgba(0,0,0,0.05)' } }, |
|||
x: { ticks: { font: { size: 11 } }, grid: { display: false } } |
|||
} |
|||
} |
|||
}) |
|||
} |
|||
} |
|||
|
|||
// 销毁图表
|
|||
function destroyCharts() { |
|||
if (conversationChart) { conversationChart.destroy(); conversationChart = null } |
|||
if (satisfactionChart) { satisfactionChart.destroy(); satisfactionChart = null } |
|||
} |
|||
|
|||
onMounted(async () => { |
|||
loading.value = true |
|||
await Promise.all([ |
|||
loadOverview(), |
|||
loadTrend(7), |
|||
loadKnowledgeAnalysis() |
|||
]) |
|||
loading.value = false |
|||
}) |
|||
|
|||
onBeforeUnmount(() => { |
|||
destroyCharts() |
|||
}) |
|||
|
|||
return { |
|||
loading, rangeDays, customStart, customEnd, |
|||
overview, trendData, topHitDocuments, missQuestions, |
|||
conversationChartRef, satisfactionChartRef, |
|||
satisfactionColor, formatPercent, formatDateShort, |
|||
setRange, loadCustomRange |
|||
} |
|||
} |
|||
} |
|||
@ -1,186 +0,0 @@ |
|||
/** |
|||
* 📄 文档详情弹窗 |
|||
* P1-2.5: 分块编辑/删除 |
|||
* P1-2.6: Markdown 渲染预览 |
|||
*/ |
|||
import { computed, ref } from 'vue' |
|||
import { store } from '../js/store.js' |
|||
import { updateChunk, deleteChunk } from '../js/api.js' |
|||
import { formatBytes, formatDate, renderMarkdown } from '../js/utils.js' |
|||
import { toast } from '../js/utils.js' |
|||
|
|||
export default { |
|||
template: `
|
|||
<div :class="['modal-overlay', store.detailModal.visible ? 'active' : '']" @click.self="store.closeDetail()"> |
|||
<div class="modal-box" style="max-width:900px;"> |
|||
<button class="modal-close" @click="store.closeDetail()">×</button> |
|||
<h2>文档详情</h2> |
|||
|
|||
<template v-if="store.detailModal.doc"> |
|||
<div style="margin-bottom:16px;"> |
|||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:12px;margin-bottom:16px;"> |
|||
<div class="result-item"><div class="label">文档标题</div><div class="value">{{ store.detailModal.doc.title }}</div></div> |
|||
<div class="result-item"><div class="label">原始文件名</div><div class="value">{{ store.detailModal.doc.sourceName || '-' }}</div></div> |
|||
<div class="result-item"><div class="label">文件类型</div><div class="value">{{ store.detailModal.doc.fileType }}</div></div> |
|||
<div class="result-item"><div class="label">文件大小</div><div class="value">{{ formatBytes(store.detailModal.doc.fileSize || 0) }}</div></div> |
|||
<div class="result-item"><div class="label">状态</div><div class="value"><span :class="statusClass">{{ store.detailModal.doc.status }}</span></div></div> |
|||
<div class="result-item"><div class="label">分块数</div><div class="value">{{ store.detailModal.doc.chunkCount }}</div></div> |
|||
<div class="result-item"><div class="label">分类</div><div class="value">{{ store.getCategoryName(store.detailModal.doc.categoryId) }}</div></div> |
|||
<div class="result-item"><div class="label">创建时间</div><div class="value">{{ formatDate(store.detailModal.doc.createTime) }}</div></div> |
|||
</div> |
|||
|
|||
<!-- P1-2.6: 原文预览(Markdown 渲染) --> |
|||
<h3>原文内容</h3> |
|||
<div v-if="isMarkdown" style="background:#fafafa;padding:12px;border-radius:8px;border:1px solid var(--border);font-size:13px;line-height:1.6;max-height:300px;overflow-y:auto;" v-html="renderedContent"></div> |
|||
<div v-else style="background:#fafafa;padding:12px;border-radius:8px;border:1px solid var(--border);font-size:13px;line-height:1.6;max-height:300px;overflow-y:auto;white-space:pre-wrap;">{{ store.detailModal.doc.content || '-' }}</div> |
|||
</div> |
|||
|
|||
<h3>分块详情({{ store.detailModal.chunks.length }} 个)</h3> |
|||
<div v-if="store.detailModal.chunksError" style="margin:8px 0 12px;padding:10px 12px;border:1px solid #f59e0b;background:#fffbeb;color:#92400e;border-radius:8px;font-size:13px;">{{ store.detailModal.chunksError }}</div> |
|||
|
|||
<div v-for="(chunk, i) in store.detailModal.chunks" :key="i" class="search-result" style="position:relative;"> |
|||
<div class="meta"> |
|||
#{{ i + 1 }} {{ getKeywords(chunk) ? '| 关键词: ' + getKeywords(chunk) : '' }} |
|||
<!-- P1-2.5: 分块操作按钮 --> |
|||
<span style="float:right;display:inline-flex;gap:4px;"> |
|||
<button class="btn btn-sm btn-outline" @click="startEdit(i, chunk)" :disabled="editIdx === i">编辑</button> |
|||
<button class="btn btn-sm btn-danger" @click="removeChunk(i)">删除</button> |
|||
</span> |
|||
</div> |
|||
<!-- P1-2.5: 编辑模式 --> |
|||
<div v-if="editIdx === i" style="margin-top:8px;"> |
|||
<textarea class="textarea" v-model="editContent" rows="6" style="width:100%;font-size:13px;"></textarea> |
|||
<div style="margin-top:6px;display:flex;gap:8px;"> |
|||
<button class="btn btn-sm btn-primary" @click="saveEdit(i)" :disabled="editSaving"> |
|||
{{ editSaving ? '保存中...' : '保存并重新向量化' }} |
|||
</button> |
|||
<button class="btn btn-sm btn-outline" @click="cancelEdit()">取消</button> |
|||
</div> |
|||
</div> |
|||
<!-- 正常展示模式 --> |
|||
<div v-else class="content">{{ chunk.content || '' }}</div> |
|||
</div> |
|||
</template> |
|||
</div> |
|||
</div> |
|||
`,
|
|||
setup() { |
|||
const statusClass = computed(() => { |
|||
const status = store.detailModal.doc?.status |
|||
return status === 'READY' ? 'status-ready' |
|||
: status === 'PROCESSING' ? 'status-processing' |
|||
: 'status-failed' |
|||
}) |
|||
|
|||
// P1-2.6: 判断是否为 Markdown 类型
|
|||
const isMarkdown = computed(() => { |
|||
const fileType = store.detailModal.doc?.fileType |
|||
return fileType === 'md' || fileType === 'markdown' |
|||
}) |
|||
|
|||
// P1-2.6: Markdown 渲染后的内容
|
|||
const renderedContent = computed(() => { |
|||
const content = store.detailModal.doc?.content |
|||
if (!content) return '<p style="color:var(--sub);">无内容</p>' |
|||
return renderMarkdown(content) |
|||
}) |
|||
|
|||
function getKeywords(chunk) { |
|||
let meta = chunk.metadata |
|||
if (typeof meta === 'object' && meta && meta.value) meta = meta.value |
|||
try { |
|||
const m = typeof meta === 'string' ? JSON.parse(meta) : meta |
|||
return m.excerpt_keywords || '' |
|||
} catch (e) { |
|||
return '' |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* P0-3: 从 chunk metadata 中获取真实的 chunkIndex |
|||
* 避免使用 v-for 数组索引(删除/编辑后索引会偏移) |
|||
*/ |
|||
function getRealChunkIndex(chunk, fallbackIdx) { |
|||
let meta = chunk.metadata |
|||
if (typeof meta === 'object' && meta && meta.value) meta = meta.value |
|||
try { |
|||
const m = typeof meta === 'string' ? JSON.parse(meta) : meta |
|||
if (m && m.chunkIndex !== undefined && m.chunkIndex !== null) { |
|||
const idx = parseInt(m.chunkIndex, 10) |
|||
if (!isNaN(idx)) return idx |
|||
} |
|||
} catch (e) { |
|||
// 解析失败,使用 fallback
|
|||
} |
|||
return fallbackIdx |
|||
} |
|||
|
|||
// P1-2.5: 分块编辑状态
|
|||
const editIdx = ref(-1) |
|||
const editContent = ref('') |
|||
const editSaving = ref(false) |
|||
|
|||
function startEdit(idx, chunk) { |
|||
editIdx.value = idx |
|||
editContent.value = chunk.content || '' |
|||
} |
|||
|
|||
function cancelEdit() { |
|||
editIdx.value = -1 |
|||
editContent.value = '' |
|||
} |
|||
|
|||
async function saveEdit(idx) { |
|||
const docId = store.detailModal.doc?.id |
|||
if (!docId) return |
|||
// P0-3: 使用真实 chunkIndex 而非数组索引
|
|||
const chunk = store.detailModal.chunks[idx] |
|||
const realIdx = getRealChunkIndex(chunk, idx) |
|||
editSaving.value = true |
|||
try { |
|||
const json = await updateChunk(docId, realIdx, editContent.value) |
|||
if (json.success) { |
|||
toast(json.message || '分块已更新', 'success') |
|||
editIdx.value = -1 |
|||
// 重新加载分块
|
|||
await store.openDetail(docId) |
|||
} else { |
|||
toast(json.message || '更新失败', 'error') |
|||
} |
|||
} catch (e) { |
|||
toast('更新分块失败:' + e.message, 'error') |
|||
} finally { |
|||
editSaving.value = false |
|||
} |
|||
} |
|||
|
|||
// P1-2.5: 删除分块
|
|||
async function removeChunk(idx) { |
|||
const docId = store.detailModal.doc?.id |
|||
if (!docId) return |
|||
// P0-3: 使用真实 chunkIndex 而非数组索引
|
|||
const chunk = store.detailModal.chunks[idx] |
|||
const realIdx = getRealChunkIndex(chunk, idx) |
|||
if (!confirm(`确定删除第 ${realIdx + 1} 个分块?对应的向量也将被移除`)) return |
|||
try { |
|||
const json = await deleteChunk(docId, realIdx) |
|||
if (json.success) { |
|||
toast(json.message || '分块已删除', 'success') |
|||
// 重新加载分块和文档详情
|
|||
await store.openDetail(docId) |
|||
} else { |
|||
toast(json.message || '删除失败', 'error') |
|||
} |
|||
} catch (e) { |
|||
toast('删除分块失败:' + e.message, 'error') |
|||
} |
|||
} |
|||
|
|||
return { |
|||
store, statusClass, isMarkdown, renderedContent, getKeywords, getRealChunkIndex, |
|||
formatBytes, formatDate, renderMarkdown, |
|||
editIdx, editContent, editSaving, |
|||
startEdit, cancelEdit, saveEdit, removeChunk |
|||
} |
|||
} |
|||
} |
|||
@ -1,431 +0,0 @@ |
|||
/** |
|||
* 📋 文档列表 + 分页 + 批量操作 |
|||
* P0-1: 关键词搜索(标题/文件名模糊匹配) |
|||
* P0-2: 文件大小列 + 分类名称列 |
|||
* P0-4: 处理中状态自动轮询刷新 |
|||
* P1-2.1: 文档启用/禁用(开关按钮 + 批量操作) |
|||
* P1-2.2: 批量移动分类 |
|||
* P1-2.3: 标签筛选 + 标签 badge 展示 |
|||
*/ |
|||
import { ref, computed, onUnmounted } from 'vue' |
|||
import { store } from '../js/store.js' |
|||
import { listDocuments, deleteDocument, reprocessDocument, batchDeleteDocuments, batchReprocessDocuments, toggleDocument, batchToggleDocuments, batchMoveDocuments, downloadDocument } from '../js/api.js' |
|||
import { toast, formatDate, formatBytes } from '../js/utils.js' |
|||
|
|||
export default { |
|||
template: `
|
|||
<div class="card"> |
|||
<h2>文档列表</h2> |
|||
<div class="input-row"> |
|||
<input class="input" v-model="keyword" placeholder="搜索文档标题或文件名..." @input="debouncedLoad" style="max-width:260px;"> |
|||
<select class="select" v-model="filterCategory" @change="load()"> |
|||
<option value="">全部分类</option> |
|||
<option v-for="c in store.categories" :key="c.id" :value="c.id">{{ c.name }}</option> |
|||
</select> |
|||
<select class="select" v-model="filterStatus" @change="load()"> |
|||
<option value="">全部状态</option> |
|||
<option value="READY">已完成</option> |
|||
<option value="PROCESSING">处理中</option> |
|||
<option value="FAILED">失败</option> |
|||
</select> |
|||
<!-- P1-2.3: 标签筛选 --> |
|||
<select class="select" v-model="filterTag" @change="load()" style="max-width:150px;"> |
|||
<option value="">全部标签</option> |
|||
<option v-for="t in store.tags" :key="t.tag" :value="t.tag">{{ t.tag }} ({{ t.count }})</option> |
|||
</select> |
|||
<button class="btn btn-outline btn-sm" @click="load()">刷新</button> |
|||
|
|||
<!-- 处理中提示 --> |
|||
<span v-if="hasProcessing" style="font-size:12px;color:#d97706;font-weight:600;">⏳ 有文档处理中...</span> |
|||
|
|||
<!-- 批量操作按钮 --> |
|||
<template v-if="selectedIds.size > 0"> |
|||
<span style="font-size:12px;color:var(--text);font-weight:600;">已选 {{ selectedIds.size }} 项</span> |
|||
<button class="btn btn-danger btn-sm" @click="batchRemove">批量删除</button> |
|||
<button class="btn btn-warn btn-sm" @click="batchReprocess">批量重新处理</button> |
|||
<!-- P1-2.1: 批量启用/禁用 --> |
|||
<button class="btn btn-sm" style="background:#059669;color:#fff;" @click="batchEnable">批量启用</button> |
|||
<button class="btn btn-sm" style="background:#6b7280;color:#fff;" @click="batchDisable">批量禁用</button> |
|||
<!-- P1-2.2: 批量移动分类 --> |
|||
<div style="display:inline-flex;align-items:center;gap:4px;"> |
|||
<select class="select" v-model="moveCategoryId" style="min-width:120px;padding:4px 8px;font-size:12px;"> |
|||
<option value="">移动到分类...</option> |
|||
<option v-for="c in store.categories" :key="c.id" :value="c.id">{{ c.name }}</option> |
|||
<option value="0">未分类</option> |
|||
</select> |
|||
<button class="btn btn-sm btn-outline" @click="batchMove" :disabled="!moveCategoryId && moveCategoryId !== '0'">移动</button> |
|||
</div> |
|||
<button class="btn btn-outline btn-sm" @click="clearSelection">取消选择</button> |
|||
</template> |
|||
</div> |
|||
|
|||
<div style="overflow-x:auto;"> |
|||
<table class="data-table"> |
|||
<thead> |
|||
<tr> |
|||
<th><input type="checkbox" :checked="isAllSelected" @change="toggleSelectAll" style="cursor:pointer;"></th> |
|||
<th>标题</th> |
|||
<th>类型</th> |
|||
<th>大小</th> |
|||
<th>分类</th> |
|||
<th>状态</th> |
|||
<th>启用</th> |
|||
<th>分块数</th> |
|||
<th>创建时间</th> |
|||
<th>操作</th> |
|||
</tr> |
|||
</thead> |
|||
<tbody> |
|||
<tr v-if="documents.length === 0"> |
|||
<td colspan="10" style="text-align:center;color:var(--sub);">暂无文档</td> |
|||
</tr> |
|||
<tr v-for="d in documents" :key="d.id" |
|||
:style="getRowStyle(d)"> |
|||
<td><input type="checkbox" :checked="selectedIds.has(d.id)" @change="toggleSelect(d.id)" style="cursor:pointer;"></td> |
|||
<td> |
|||
<strong>{{ d.title }}</strong><br> |
|||
<span style="font-size:11px;color:var(--sub);">{{ d.sourceName || '' }}</span> |
|||
<!-- P1-2.3: 标签 badge --> |
|||
<div v-if="getDocTags(d).length > 0" style="margin-top:2px;"> |
|||
<span v-for="tag in getDocTags(d)" :key="tag" |
|||
style="display:inline-block;font-size:10px;background:#e0f2fe;color:#0369a1;padding:1px 6px;border-radius:4px;margin-right:3px;">{{ tag }}</span> |
|||
</div> |
|||
</td> |
|||
<td><span class="category-tag">{{ d.fileType }}</span></td> |
|||
<td style="white-space:nowrap;">{{ formatSize(d.fileSize) }}</td> |
|||
<td>{{ store.getCategoryName(d.categoryId) }}</td> |
|||
<td><span :class="statusClass(d.status)">{{ statusLabel(d.status) }}</span></td> |
|||
<td> |
|||
<!-- P1-2.1: 启用/禁用开关 --> |
|||
<label class="switch-toggle" :title="d.enabled !== false ? '已启用(点击禁用)' : '已禁用(点击启用)'"> |
|||
<input type="checkbox" :checked="d.enabled !== false" @change="toggle(d)"> |
|||
<span class="switch-slider"></span> |
|||
</label> |
|||
</td> |
|||
<td>{{ d.chunkCount || 0 }}</td> |
|||
<td>{{ formatDate(d.createTime) }}</td> |
|||
<td> |
|||
<button class="btn btn-sm btn-outline" @click="viewDetail(d.id)">查看</button> |
|||
<button v-if="d.filePath" class="btn btn-sm btn-outline" @click="download(d.id)" title="下载原始文件">下载</button> |
|||
<button class="btn btn-sm btn-warn" @click="reprocess(d.id)">重新处理</button> |
|||
<button class="btn btn-sm btn-danger" @click="remove(d.id)">删除</button> |
|||
</td> |
|||
</tr> |
|||
</tbody> |
|||
</table> |
|||
</div> |
|||
|
|||
<div class="pagination" v-if="pages > 1"> |
|||
<button :disabled="page <= 1" @click="load(page - 1)">上一页</button> |
|||
<template v-for="i in pages" :key="i"> |
|||
<button v-if="i === 1 || i === pages || (i >= page - 2 && i <= page + 2)" |
|||
:class="{ active: i === page }" @click="load(i)">{{ i }}</button> |
|||
<span v-else-if="i === page - 3 || i === page + 3" style="padding:6px;">...</span> |
|||
</template> |
|||
<button :disabled="page >= pages" @click="load(page + 1)">下一页</button> |
|||
</div> |
|||
</div> |
|||
`,
|
|||
setup() { |
|||
const documents = ref([]) |
|||
const page = ref(1) |
|||
const pages = ref(1) |
|||
const total = ref(0) |
|||
const filterCategory = ref('') |
|||
const filterStatus = ref('') |
|||
const keyword = ref('') |
|||
const filterTag = ref('') |
|||
const selectedIds = ref(new Set()) |
|||
const moveCategoryId = ref('') |
|||
|
|||
// 防抖搜索
|
|||
let debounceTimer = null |
|||
function debouncedLoad() { |
|||
clearTimeout(debounceTimer) |
|||
debounceTimer = setTimeout(() => load(1), 300) |
|||
} |
|||
|
|||
// 轮询控制
|
|||
let pollTimer = null |
|||
const hasProcessing = ref(false) |
|||
|
|||
function startPolling() { |
|||
stopPolling() |
|||
hasProcessing.value = true |
|||
pollTimer = setInterval(async () => { |
|||
await load(page.value) |
|||
if (!documents.value.some(d => d.status === 'PROCESSING')) { |
|||
stopPolling() |
|||
store.loadStats() |
|||
toast('所有文档处理完成', 'success') |
|||
} |
|||
}, 2000) |
|||
} |
|||
|
|||
function stopPolling() { |
|||
hasProcessing.value = false |
|||
if (pollTimer) { |
|||
clearInterval(pollTimer) |
|||
pollTimer = null |
|||
} |
|||
} |
|||
|
|||
// 组件卸载时清理定时器
|
|||
onUnmounted(() => { |
|||
stopPolling() |
|||
clearTimeout(debounceTimer) |
|||
}) |
|||
|
|||
// 计算是否全选(computed 确保响应式更新)
|
|||
const isAllSelected = computed(() => { |
|||
return documents.value.length > 0 && documents.value.every(d => selectedIds.value.has(d.id)) |
|||
}) |
|||
|
|||
function statusClass(status) { |
|||
return status === 'READY' ? 'status-ready' |
|||
: status === 'PROCESSING' ? 'status-processing' |
|||
: 'status-failed' |
|||
} |
|||
|
|||
function statusLabel(status) { |
|||
return status === 'READY' ? '已完成' |
|||
: status === 'PROCESSING' ? '处理中' |
|||
: status === 'FAILED' ? '失败' |
|||
: status |
|||
} |
|||
|
|||
function formatSize(bytes) { |
|||
if (!bytes || bytes === 0) return '-' |
|||
return formatBytes(bytes) |
|||
} |
|||
|
|||
/** P1-2.3: 从文档的 tags JSONB 中提取标签数组 */ |
|||
function getDocTags(doc) { |
|||
if (!doc.tags) return [] |
|||
const t = doc.tags |
|||
if (t.tags && Array.isArray(t.tags)) return t.tags |
|||
if (Array.isArray(t)) return t |
|||
return [] |
|||
} |
|||
|
|||
/** P1-2.1: 禁用文档行灰色标记 */ |
|||
function getRowStyle(doc) { |
|||
const base = selectedIds.value.has(doc.id) ? 'background:#f3f4f6;' : '' |
|||
if (doc.enabled === false) return base + 'opacity:0.5;' |
|||
return base |
|||
} |
|||
|
|||
async function load(p = 1) { |
|||
page.value = p |
|||
// 切换页面时清空选择
|
|||
selectedIds.value = new Set() |
|||
try { |
|||
const json = await listDocuments(p, 10, |
|||
filterCategory.value || undefined, |
|||
filterStatus.value || undefined, |
|||
keyword.value.trim() || undefined, |
|||
filterTag.value || undefined) |
|||
if (json.success) { |
|||
documents.value = json.data || [] |
|||
total.value = json.total || 0 |
|||
pages.value = json.pages || 1 |
|||
// 检查是否有处理中的文档,启动轮询
|
|||
if (documents.value.some(d => d.status === 'PROCESSING')) { |
|||
if (!pollTimer) startPolling() |
|||
} |
|||
} else { |
|||
toast(json.message || '查询失败', 'error') |
|||
} |
|||
} catch (e) { |
|||
toast('加载文档失败:' + e.message, 'error') |
|||
} |
|||
} |
|||
|
|||
function toggleSelect(id) { |
|||
const s = new Set(selectedIds.value) |
|||
if (s.has(id)) s.delete(id) |
|||
else s.add(id) |
|||
selectedIds.value = s |
|||
} |
|||
|
|||
function toggleSelectAll() { |
|||
if (isAllSelected.value) { |
|||
selectedIds.value = new Set() |
|||
} else { |
|||
const s = new Set() |
|||
documents.value.forEach(d => s.add(d.id)) |
|||
selectedIds.value = s |
|||
} |
|||
} |
|||
|
|||
function clearSelection() { |
|||
selectedIds.value = new Set() |
|||
} |
|||
|
|||
function viewDetail(id) { |
|||
store.openDetail(id) |
|||
} |
|||
|
|||
function download(id) { |
|||
downloadDocument(id) |
|||
} |
|||
|
|||
// P1-2.1: 切换单个文档启用/禁用
|
|||
async function toggle(doc) { |
|||
try { |
|||
const json = await toggleDocument(doc.id) |
|||
if (json.success) { |
|||
doc.enabled = json.data.enabled |
|||
toast(json.message, 'success') |
|||
} else { |
|||
toast(json.message || '操作失败', 'error') |
|||
// 恢复开关状态
|
|||
load(page.value) |
|||
} |
|||
} catch (e) { |
|||
toast('操作失败:' + e.message, 'error') |
|||
load(page.value) |
|||
} |
|||
} |
|||
|
|||
async function remove(id) { |
|||
if (!confirm('确定删除此文档?关联的向量也将被删除')) return |
|||
try { |
|||
const json = await deleteDocument(id) |
|||
if (json.success) { |
|||
toast(`已删除,连带删除 ${json.deletedVectors || 0} 个向量`, 'success') |
|||
load(page.value) |
|||
store.loadStats() |
|||
} else { |
|||
toast(json.message || '删除失败', 'error') |
|||
} |
|||
} catch (e) { |
|||
toast('删除失败:' + e.message, 'error') |
|||
} |
|||
} |
|||
|
|||
async function reprocess(id) { |
|||
if (!confirm('确定重新处理此文档?将重新分块并向量化')) return |
|||
try { |
|||
const json = await reprocessDocument(id) |
|||
if (json.success) { |
|||
toast(json.message || '已提交重新处理', 'success') |
|||
load(page.value) |
|||
if (!pollTimer) startPolling() |
|||
} else { |
|||
toast(json.message || '重新处理失败', 'error') |
|||
} |
|||
} catch (e) { |
|||
toast('重新处理失败:' + e.message, 'error') |
|||
} |
|||
} |
|||
|
|||
async function batchRemove() { |
|||
const ids = Array.from(selectedIds.value) |
|||
if (!confirm(`确定删除选中的 ${ids.length} 个文档?关联的向量也将被删除`)) return |
|||
try { |
|||
const json = await batchDeleteDocuments(ids) |
|||
if (json.success) { |
|||
toast(json.message, 'success') |
|||
selectedIds.value = new Set() |
|||
load(page.value) |
|||
store.loadStats() |
|||
} else { |
|||
toast(json.message || '批量删除失败', 'error') |
|||
} |
|||
} catch (e) { |
|||
toast('批量删除失败:' + e.message, 'error') |
|||
} |
|||
} |
|||
|
|||
async function batchReprocess() { |
|||
const ids = Array.from(selectedIds.value) |
|||
if (!confirm(`确定重新处理选中的 ${ids.length} 个文档?`)) return |
|||
try { |
|||
const json = await batchReprocessDocuments(ids) |
|||
if (json.success) { |
|||
toast(json.message, 'success') |
|||
selectedIds.value = new Set() |
|||
load(page.value) |
|||
if (!pollTimer) startPolling() |
|||
} else { |
|||
toast(json.message || '批量重新处理失败', 'error') |
|||
} |
|||
} catch (e) { |
|||
toast('批量重新处理失败:' + e.message, 'error') |
|||
} |
|||
} |
|||
|
|||
// P1-2.1: 批量启用
|
|||
async function batchEnable() { |
|||
const ids = Array.from(selectedIds.value) |
|||
if (!confirm(`确定启用选中的 ${ids.length} 个文档?`)) return |
|||
try { |
|||
const json = await batchToggleDocuments(ids, true) |
|||
if (json.success) { |
|||
toast(json.message, 'success') |
|||
selectedIds.value = new Set() |
|||
load(page.value) |
|||
} else { |
|||
toast(json.message || '批量启用失败', 'error') |
|||
} |
|||
} catch (e) { |
|||
toast('批量启用失败:' + e.message, 'error') |
|||
} |
|||
} |
|||
|
|||
// P1-2.1: 批量禁用
|
|||
async function batchDisable() { |
|||
const ids = Array.from(selectedIds.value) |
|||
if (!confirm(`确定禁用选中的 ${ids.length} 个文档?禁用后将不参与 RAG 检索`)) return |
|||
try { |
|||
const json = await batchToggleDocuments(ids, false) |
|||
if (json.success) { |
|||
toast(json.message, 'success') |
|||
selectedIds.value = new Set() |
|||
load(page.value) |
|||
} else { |
|||
toast(json.message || '批量禁用失败', 'error') |
|||
} |
|||
} catch (e) { |
|||
toast('批量禁用失败:' + e.message, 'error') |
|||
} |
|||
} |
|||
|
|||
// P1-2.2: 批量移动分类
|
|||
async function batchMove() { |
|||
const ids = Array.from(selectedIds.value) |
|||
const catId = moveCategoryId.value |
|||
if (!catId && catId !== '0') { |
|||
toast('请选择目标分类', 'error') |
|||
return |
|||
} |
|||
const catName = catId === '0' ? '未分类' : store.getCategoryName(catId) |
|||
if (!confirm(`确定将选中的 ${ids.length} 个文档移动到「${catName}」?`)) return |
|||
try { |
|||
// 注意:不可用 parseInt,雪花 ID 超出 Number.MAX_SAFE_INTEGER 会丢失精度
|
|||
const json = await batchMoveDocuments(ids, catId === '0' ? 0 : catId) |
|||
if (json.success) { |
|||
toast(json.message, 'success') |
|||
selectedIds.value = new Set() |
|||
moveCategoryId.value = '' |
|||
load(page.value) |
|||
store.loadStats() |
|||
} else { |
|||
toast(json.message || '批量移动失败', 'error') |
|||
} |
|||
} catch (e) { |
|||
toast('批量移动失败:' + e.message, 'error') |
|||
} |
|||
} |
|||
|
|||
return { |
|||
documents, page, pages, total, filterCategory, filterStatus, keyword, filterTag, |
|||
selectedIds, moveCategoryId, store, statusClass, statusLabel, isAllSelected, hasProcessing, |
|||
formatSize, debouncedLoad, getDocTags, getRowStyle, |
|||
load, toggleSelect, toggleSelectAll, clearSelection, |
|||
viewDetail, toggle, remove, reprocess, batchRemove, batchReprocess, |
|||
batchEnable, batchDisable, batchMove, formatDate, download |
|||
} |
|||
} |
|||
} |
|||
@ -1,105 +0,0 @@ |
|||
/** |
|||
* 🔍 知识库搜索测试(支持多种检索模式) |
|||
*/ |
|||
import { ref } from 'vue' |
|||
import { searchDocuments } from '../js/api.js' |
|||
import { toast } from '../js/utils.js' |
|||
|
|||
export default { |
|||
template: `
|
|||
<div class="card"> |
|||
<h2>知识库搜索测试</h2> |
|||
<p style="color:var(--sub);font-size:13px;margin-bottom:12px;">输入查询语句,测试知识库检索效果(支持向量、关键词、混合检索三种模式)</p> |
|||
<div class="input-row"> |
|||
<input class="input" v-model="query" placeholder="输入查询,如:Spring Boot 是什么?" @keydown.enter="search"> |
|||
<label class="input-field-sm"> |
|||
<span>检索模式</span> |
|||
<select class="input input-sm" v-model="searchMode"> |
|||
<option value="VECTOR">向量检索</option> |
|||
<option value="KEYWORD">关键词检索</option> |
|||
<option value="HYBRID">混合检索</option> |
|||
</select> |
|||
</label> |
|||
<label class="input-field-sm"> |
|||
<span>返回条数</span> |
|||
<input class="input input-sm" v-model.number="topK" type="number" min="1" max="20"> |
|||
</label> |
|||
<label v-if="searchMode !== 'KEYWORD'" class="input-field-sm"> |
|||
<span>相似度阈值</span> |
|||
<input class="input input-sm" v-model.number="threshold" type="number" min="0" max="1" step="0.1"> |
|||
</label> |
|||
<button class="btn btn-primary" @click="search" :disabled="isSearching">{{ isSearching ? '搜索中...' : '搜索' }}</button> |
|||
</div> |
|||
|
|||
<div v-if="isSearching" style="text-align:center;padding:20px;color:var(--sub);">搜索中...</div> |
|||
|
|||
<div v-else-if="searched && results.length === 0" style="text-align:center;padding:20px;color:var(--sub);">未找到相关结果</div> |
|||
|
|||
<div v-else-if="searched && errorMsg" style="color:var(--danger);">搜索失败:{{ errorMsg }}</div> |
|||
|
|||
<div v-else> |
|||
<div v-for="(r, i) in results" :key="i" class="search-result"> |
|||
<div class="score"> |
|||
<span style="display:inline-block;padding:1px 6px;border-radius:3px;font-size:11px;margin-right:6px;" |
|||
:style="{background: modeColor(r.searchMode), color: '#fff'}">{{ modeLabel(r.searchMode) }}</span> |
|||
得分: {{ formatScore(r) }} | {{ r.title || '无标题' }} | {{ r.sourceName || '无来源' }} |
|||
</div> |
|||
<div class="content">{{ r.content || '' }}</div> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
`,
|
|||
setup() { |
|||
const query = ref('') |
|||
const topK = ref(5) |
|||
const threshold = ref(0.5) |
|||
const searchMode = ref('VECTOR') |
|||
const results = ref([]) |
|||
const isSearching = ref(false) |
|||
const searched = ref(false) |
|||
const errorMsg = ref('') |
|||
|
|||
async function search() { |
|||
if (!query.value.trim()) { |
|||
toast('请输入查询内容', 'error') |
|||
return |
|||
} |
|||
isSearching.value = true |
|||
searched.value = false |
|||
errorMsg.value = '' |
|||
try { |
|||
const json = await searchDocuments(query.value, topK.value, threshold.value, null, null, searchMode.value) |
|||
if (!json.success) { |
|||
errorMsg.value = json.message |
|||
results.value = [] |
|||
} else { |
|||
results.value = json.data || [] |
|||
} |
|||
searched.value = true |
|||
} catch (e) { |
|||
errorMsg.value = e.message |
|||
results.value = [] |
|||
searched.value = true |
|||
} finally { |
|||
isSearching.value = false |
|||
} |
|||
} |
|||
|
|||
function formatScore(r) { |
|||
if (r.searchMode === 'KEYWORD' || r.searchMode === 'HYBRID') { |
|||
return r.score != null ? r.score.toFixed(4) : '-' |
|||
} |
|||
return r.score != null ? (1 - r.score).toFixed(4) : '-' |
|||
} |
|||
|
|||
function modeLabel(m) { |
|||
return { VECTOR: '向量', KEYWORD: '关键词', HYBRID: '混合' }[m] || m || '向量' |
|||
} |
|||
|
|||
function modeColor(m) { |
|||
return { VECTOR: '#007bff', KEYWORD: '#28a745', HYBRID: '#6f42c1' }[m] || '#6c757d' |
|||
} |
|||
|
|||
return { query, topK, threshold, searchMode, results, isSearching, searched, errorMsg, search, formatScore, modeLabel, modeColor } |
|||
} |
|||
} |
|||
Some files were not shown because too many files changed in this diff
Write
Preview
Loading…
Cancel
Save
Reference in new issue