From 416bf36f1af53b933ef2770941ae1ffd1beda790 Mon Sep 17 00:00:00 2001 From: DrSmoothl <1787882683@qq.com> Date: Sat, 28 Feb 2026 22:47:40 +0800 Subject: [PATCH 01/26] refactor(database_model): replace Literal with Enum for modified_by field --- src/common/database/database_model.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/common/database/database_model.py b/src/common/database/database_model.py index 112ce2bd..bf349af1 100644 --- a/src/common/database/database_model.py +++ b/src/common/database/database_model.py @@ -1,4 +1,4 @@ -from typing import Optional, Literal +from typing import Optional from sqlalchemy import Column, Float, Enum as SQLEnum, DateTime from sqlmodel import SQLModel, Field, LargeBinary from enum import Enum @@ -14,6 +14,9 @@ class ImageType(str, Enum): EMOJI = "emoji" IMAGE = "image" +class ModifiedBy(str, Enum): + AI = "ai" + USER = "user" class Messages(SQLModel, table=True): __tablename__ = "mai_messages" # type: ignore @@ -183,7 +186,7 @@ class Expression(SQLModel, table=True): checked: bool = Field(default=False) # 是否已经被检查过 rejected: bool = Field(default=False) # 是否被拒绝但是未更新 - modified_by: Optional[Literal["ai", "user"]] = Field(default=None) # 最后修改者,标记用户或AI,为空表示未检查 + modified_by: Optional[ModifiedBy] = Field(sa_column=Column(SQLEnum(ModifiedBy), nullable=True)) # 最后修改者,标记用户或AI,为空表示未检查 class Jargon(SQLModel, table=True): From f9dd197f53d2c9d4fb0da31d0cff452823299449 Mon Sep 17 00:00:00 2001 From: DrSmoothl <1787882683@qq.com> Date: Sun, 1 Mar 2026 16:53:34 +0800 Subject: [PATCH 02/26] refactor(types): add unified API response types and error helpers --- dashboard/src/lib/api-helpers.ts | 55 ++++++++++++++++++++++++++++++++ dashboard/src/types/api.ts | 8 +++++ 2 files changed, 63 insertions(+) create mode 100644 dashboard/src/lib/api-helpers.ts create mode 100644 dashboard/src/types/api.ts diff --git a/dashboard/src/lib/api-helpers.ts b/dashboard/src/lib/api-helpers.ts new file mode 100644 index 00000000..a9e429ce --- /dev/null +++ b/dashboard/src/lib/api-helpers.ts @@ -0,0 +1,55 @@ +/** + * API response parsing and error handling helpers + * Provides unified error handling across API modules + */ + +import type { ApiResponse } from '@/types/api' + +/** + * Parse an HTTP response into a typed ApiResponse + * Handles JSON parsing, error extraction, and HTTP status codes + */ +export async function parseResponse(response: Response): Promise> { + if (response.ok) { + try { + const data = await response.json() + return { success: true, data } + } catch { + return { + success: false, + error: 'Failed to parse response body', + } + } + } + + try { + const errorData = await response.json() + const errorMessage = + errorData.error?.detail ?? + errorData.error?.message ?? + errorData.detail ?? + errorData.message ?? + response.statusText + + return { + success: false, + error: String(errorMessage), + } + } catch { + return { + success: false, + error: response.statusText || 'Unknown error', + } + } +} + +/** + * Extract data from successful ApiResponse or throw error + * Simplifies error handling in async functions + */ +export function throwIfError(result: ApiResponse): T { + if (result.success) { + return result.data + } + throw new Error(result.error) +} diff --git a/dashboard/src/types/api.ts b/dashboard/src/types/api.ts new file mode 100644 index 00000000..8727727f --- /dev/null +++ b/dashboard/src/types/api.ts @@ -0,0 +1,8 @@ +/** + * Unified API response type definition + * Discriminated union for type-safe error handling + */ + +export type ApiResponse = + | { success: true; data: T } + | { success: false; error: string } From 1533ec505b23f635b0caef96a0f9b91884c604ff Mon Sep 17 00:00:00 2001 From: DrSmoothl <1787882683@qq.com> Date: Sun, 1 Mar 2026 16:57:01 +0800 Subject: [PATCH 03/26] chore(deps): remove unused Jotai dependency - Jotai was declared in package.json but never imported or used in any source code - Verified with grep -r 'jotai' dashboard/src/ (zero matches) - Build remains unaffected after removal --- dashboard/package.json | 1 - 1 file changed, 1 deletion(-) diff --git a/dashboard/package.json b/dashboard/package.json index 5a925629..b627cddb 100644 --- a/dashboard/package.json +++ b/dashboard/package.json @@ -61,7 +61,6 @@ "date-fns": "^4.1.0", "html-to-image": "^1.11.13", "idb": "^8.0.3", - "jotai": "^2.16.0", "katex": "^0.16.27", "lucide-react": "^0.556.0", "react": "^19.2.1", From e1c8a30376c19ff7b68362a8bc18bf2016ea4e9b Mon Sep 17 00:00:00 2001 From: DrSmoothl <1787882683@qq.com> Date: Sun, 1 Mar 2026 16:57:45 +0800 Subject: [PATCH 04/26] refactor(lib): unify WebSocket utilities into ws-utils --- dashboard/src/lib/log-websocket.ts | 163 +++++----------------- dashboard/src/lib/plugin-api.ts | 94 ++++--------- dashboard/src/lib/ws-utils.ts | 211 +++++++++++++++++++++++++++++ 3 files changed, 270 insertions(+), 198 deletions(-) create mode 100644 dashboard/src/lib/ws-utils.ts diff --git a/dashboard/src/lib/log-websocket.ts b/dashboard/src/lib/log-websocket.ts index 19d928cf..4ad69127 100644 --- a/dashboard/src/lib/log-websocket.ts +++ b/dashboard/src/lib/log-websocket.ts @@ -3,8 +3,9 @@ * 确保整个应用只有一个 WebSocket 连接 */ -import { fetchWithAuth, checkAuthStatus } from './fetch-with-auth' +import { checkAuthStatus } from './fetch-with-auth' import { getSetting } from './settings-manager' +import { createReconnectingWebSocket } from './ws-utils' export interface LogEntry { id: string @@ -18,10 +19,7 @@ type LogCallback = (log: LogEntry) => void type ConnectionCallback = (connected: boolean) => void class LogWebSocketManager { - private ws: WebSocket | null = null - private reconnectTimeout: number | null = null - private reconnectAttempts = 0 - private heartbeatInterval: number | null = null + private wsControl: ReturnType | null = null // 订阅者 private logCallbacks: Set = new Set() @@ -54,9 +52,9 @@ class LogWebSocketManager { } /** - * 获取 WebSocket URL + * 获取 WebSocket URL(不含 token 参数) */ - private getWebSocketUrl(token?: string): string { + private getWebSocketUrl(): string { let baseUrl: string if (import.meta.env.DEV) { // 开发模式:连接到 WebUI 后端服务器 @@ -67,49 +65,13 @@ class LogWebSocketManager { const host = window.location.host baseUrl = `${protocol}//${host}/ws/logs` } - - // 如果有 token,添加到 URL 参数 - if (token) { - return `${baseUrl}?token=${encodeURIComponent(token)}` - } return baseUrl } - /** - * 获取 WebSocket 临时认证 token - */ - private async getWsToken(): Promise { - try { - // 使用相对路径,让前端代理处理请求,避免 CORS 问题 - const response = await fetchWithAuth('/api/webui/ws-token', { - method: 'GET', - credentials: 'include', // 携带 Cookie - }) - - if (!response.ok) { - console.error('获取 WebSocket token 失败:', response.status) - return null - } - - const data = await response.json() - if (data.success && data.token) { - return data.token - } - return null - } catch (error) { - console.error('获取 WebSocket token 失败:', error) - return null - } - } - /** * 连接 WebSocket(会先检查登录状态) */ async connect() { - if (this.ws?.readyState === WebSocket.OPEN || this.ws?.readyState === WebSocket.CONNECTING) { - return - } - // 检查是否在登录页面 if (window.location.pathname === '/auth') { console.log('📡 在登录页面,跳过 WebSocket 连接') @@ -123,114 +85,51 @@ class LogWebSocketManager { return } - // 先获取临时认证 token - const wsToken = await this.getWsToken() - if (!wsToken) { - console.log('📡 无法获取 WebSocket token,跳过连接') - return - } - - const wsUrl = this.getWebSocketUrl(wsToken) + const wsUrl = this.getWebSocketUrl() - try { - this.ws = new WebSocket(wsUrl) - - this.ws.onopen = () => { - this.isConnected = true - this.reconnectAttempts = 0 - this.notifyConnection(true) - this.startHeartbeat() - } - - this.ws.onmessage = (event) => { + // 使用 ws-utils 创建 WebSocket + this.wsControl = createReconnectingWebSocket(wsUrl, { + onMessage: (data: string) => { try { - // 忽略心跳响应 - if (event.data === 'pong') { - return - } - - const log: LogEntry = JSON.parse(event.data) + const log: LogEntry = JSON.parse(data) this.notifyLog(log) } catch (error) { console.error('解析日志消息失败:', error) } - } - - this.ws.onerror = (error) => { + }, + onOpen: () => { + this.isConnected = true + this.notifyConnection(true) + }, + onClose: () => { + this.isConnected = false + this.notifyConnection(false) + }, + onError: (error) => { console.error('❌ WebSocket 错误:', error) this.isConnected = false this.notifyConnection(false) - } + }, + heartbeatInterval: 30000, + maxRetries: this.getMaxReconnectAttempts(), + backoffBase: this.getReconnectInterval(), + maxBackoff: 30000, + }) - this.ws.onclose = () => { - this.isConnected = false - this.notifyConnection(false) - this.stopHeartbeat() - this.attemptReconnect() - } - } catch (error) { - console.error('创建 WebSocket 连接失败:', error) - this.attemptReconnect() - } - } - - /** - * 尝试重连 - */ - private attemptReconnect() { - const maxAttempts = this.getMaxReconnectAttempts() - if (this.reconnectAttempts >= maxAttempts) { - return - } - - this.reconnectAttempts += 1 - const baseInterval = this.getReconnectInterval() - const delay = Math.min(baseInterval * this.reconnectAttempts, 30000) - - this.reconnectTimeout = window.setTimeout(() => { - this.connect() // connect 是 async 但这里不需要 await,它内部会处理错误 - }, delay) - } - - /** - * 启动心跳 - */ - private startHeartbeat() { - this.heartbeatInterval = window.setInterval(() => { - if (this.ws?.readyState === WebSocket.OPEN) { - this.ws.send('ping') - } - }, 30000) // 每30秒发送一次心跳 - } - - /** - * 停止心跳 - */ - private stopHeartbeat() { - if (this.heartbeatInterval !== null) { - clearInterval(this.heartbeatInterval) - this.heartbeatInterval = null - } + // 启动连接 + await this.wsControl.connect() } /** * 断开连接 */ disconnect() { - if (this.reconnectTimeout !== null) { - clearTimeout(this.reconnectTimeout) - this.reconnectTimeout = null - } - - this.stopHeartbeat() - - if (this.ws) { - this.ws.close() - this.ws = null + if (this.wsControl) { + this.wsControl.disconnect() + this.wsControl = null } this.isConnected = false - this.reconnectAttempts = 0 } /** diff --git a/dashboard/src/lib/plugin-api.ts b/dashboard/src/lib/plugin-api.ts index 97133693..a0f49ed5 100644 --- a/dashboard/src/lib/plugin-api.ts +++ b/dashboard/src/lib/plugin-api.ts @@ -1,6 +1,8 @@ import { fetchWithAuth, getAuthHeaders } from '@/lib/fetch-with-auth' import type { PluginInfo } from '@/types/plugin' +import { createReconnectingWebSocket } from './ws-utils' + /** * Git 安装状态 */ @@ -267,27 +269,6 @@ export function isPluginCompatible( return true } -/** - * 获取 WebSocket 临时认证 token - */ -async function getWsToken(): Promise { - try { - const response = await fetchWithAuth('/api/webui/ws-token') - if (!response.ok) { - console.error('获取 WebSocket token 失败:', response.status) - return null - } - const data = await response.json() - if (data.success && data.token) { - return data.token - } - return null - } catch (error) { - console.error('获取 WebSocket token 失败:', error) - return null - } -} - /** * 连接插件加载进度 WebSocket * @@ -297,60 +278,41 @@ export async function connectPluginProgressWebSocket( onProgress: (progress: PluginLoadProgress) => void, onError?: (error: Event) => void ): Promise { - // 先获取临时 token - const wsToken = await getWsToken() - if (!wsToken) { - console.warn('无法获取 WebSocket token,可能未登录') - return null - } - const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:' const host = window.location.host - const wsUrl = `${protocol}//${host}/api/webui/ws/plugin-progress?token=${encodeURIComponent(wsToken)}` + const wsUrl = `${protocol}//${host}/api/webui/ws/plugin-progress` - try { - const ws = new WebSocket(wsUrl) - - ws.onopen = () => { - console.log('Plugin progress WebSocket connected') - // 发送心跳 - const heartbeat = setInterval(() => { - if (ws.readyState === WebSocket.OPEN) { - ws.send('ping') - } else { - clearInterval(heartbeat) - } - }, 30000) - } - - ws.onmessage = (event) => { + // 使用 ws-utils 创建 WebSocket + const wsControl = createReconnectingWebSocket(wsUrl, { + onMessage: (data: string) => { try { - // 忽略心跳响应 - if (event.data === 'pong') { - return - } - - const data = JSON.parse(event.data) as PluginLoadProgress - onProgress(data) + const progressData = JSON.parse(data) as PluginLoadProgress + onProgress(progressData) } catch (error) { console.error('Failed to parse progress data:', error) } - } - - ws.onerror = (error) => { + }, + onOpen: () => { + console.log('Plugin progress WebSocket connected') + }, + onClose: () => { + console.log('Plugin progress WebSocket disconnected') + }, + onError: (error) => { console.error('Plugin progress WebSocket error:', error) onError?.(error) - } - - ws.onclose = () => { - console.log('Plugin progress WebSocket disconnected') - } - - return ws - } catch (error) { - console.error('创建 WebSocket 连接失败:', error) - return null - } + }, + heartbeatInterval: 30000, + maxRetries: 10, + backoffBase: 1000, + maxBackoff: 30000, + }) + + // 启动连接 + await wsControl.connect() + + // 返回 WebSocket 实例(用于外部检查连接状态) + return wsControl.getWebSocket() } /** diff --git a/dashboard/src/lib/ws-utils.ts b/dashboard/src/lib/ws-utils.ts new file mode 100644 index 00000000..3d3b3240 --- /dev/null +++ b/dashboard/src/lib/ws-utils.ts @@ -0,0 +1,211 @@ +import { fetchWithAuth } from './fetch-with-auth' + +/** + * WebSocket 配置选项 + */ +export interface WebSocketOptions { + onMessage?: (data: string) => void + onOpen?: () => void + onClose?: () => void + onError?: (error: Event) => void + heartbeatInterval?: number // 心跳间隔(毫秒) + maxRetries?: number // 最大重连次数 + backoffBase?: number // 重连基础间隔(毫秒) + maxBackoff?: number // 最大重连间隔(毫秒) +} + +/** + * 获取 WebSocket 临时认证 token + */ +export async function getWsToken(): Promise { + try { + // 使用相对路径,让前端代理处理请求,避免 CORS 问题 + const response = await fetchWithAuth('/api/webui/ws-token', { + method: 'GET', + credentials: 'include', // 携带 Cookie + }) + + if (!response.ok) { + console.error('获取 WebSocket token 失败:', response.status) + return null + } + + const data = await response.json() + if (data.success && data.token) { + return data.token + } + return null + } catch (error) { + console.error('获取 WebSocket token 失败:', error) + return null + } +} + +/** + * 创建带重连、心跳的 WebSocket 封装 + * + * @param url WebSocket URL(不含 token 参数) + * @param options 配置选项 + * @returns WebSocket 控制对象,包含 connect、disconnect、send 方法 + */ +export function createReconnectingWebSocket( + url: string, + options: WebSocketOptions = {} +) { + const { + onMessage, + onOpen, + onClose, + onError, + heartbeatInterval = 30000, + maxRetries = 10, + backoffBase = 1000, + maxBackoff = 30000, + } = options + + let ws: WebSocket | null = null + let reconnectTimeout: number | null = null + let reconnectAttempts = 0 + let heartbeatIntervalId: number | null = null + let isManualDisconnect = false + + /** + * 启动心跳 + */ + function startHeartbeat() { + stopHeartbeat() + heartbeatIntervalId = window.setInterval(() => { + if (ws?.readyState === WebSocket.OPEN) { + ws.send('ping') + } + }, heartbeatInterval) + } + + /** + * 停止心跳 + */ + function stopHeartbeat() { + if (heartbeatIntervalId !== null) { + clearInterval(heartbeatIntervalId) + heartbeatIntervalId = null + } + } + + /** + * 尝试重连 + */ + function attemptReconnect() { + if (isManualDisconnect) { + return + } + + if (reconnectAttempts >= maxRetries) { + console.warn(`WebSocket 达到最大重连次数 (${maxRetries}),停止重连`) + return + } + + reconnectAttempts += 1 + const delay = Math.min(backoffBase * reconnectAttempts, maxBackoff) + + console.log(`WebSocket 将在 ${delay}ms 后重连(第 ${reconnectAttempts} 次)`) + reconnectTimeout = window.setTimeout(() => { + connect() + }, delay) + } + + /** + * 连接 WebSocket + */ + async function connect() { + if (ws?.readyState === WebSocket.OPEN || ws?.readyState === WebSocket.CONNECTING) { + return + } + + // 先获取临时认证 token + const wsToken = await getWsToken() + if (!wsToken) { + console.warn('无法获取 WebSocket token,跳过连接') + return + } + + const wsUrl = `${url}?token=${encodeURIComponent(wsToken)}` + + try { + ws = new WebSocket(wsUrl) + + ws.onopen = () => { + reconnectAttempts = 0 + startHeartbeat() + onOpen?.() + } + + ws.onmessage = (event) => { + // 忽略心跳响应 + if (event.data === 'pong') { + return + } + onMessage?.(event.data) + } + + ws.onerror = (error) => { + console.error('WebSocket 错误:', error) + onError?.(error) + } + + ws.onclose = () => { + stopHeartbeat() + onClose?.() + attemptReconnect() + } + } catch (error) { + console.error('创建 WebSocket 连接失败:', error) + attemptReconnect() + } + } + + /** + * 断开连接 + */ + function disconnect() { + isManualDisconnect = true + + if (reconnectTimeout !== null) { + clearTimeout(reconnectTimeout) + reconnectTimeout = null + } + + stopHeartbeat() + + if (ws) { + ws.close() + ws = null + } + + reconnectAttempts = 0 + } + + /** + * 发送消息 + */ + function send(data: string) { + if (ws?.readyState === WebSocket.OPEN) { + ws.send(data) + } else { + console.warn('WebSocket 未连接,无法发送消息') + } + } + + /** + * 获取当前 WebSocket 实例 + */ + function getWebSocket(): WebSocket | null { + return ws + } + + return { + connect, + disconnect, + send, + getWebSocket, + } +} From 3680009122f11fb0c9c56f86477b6971fec4c601 Mon Sep 17 00:00:00 2001 From: DrSmoothl <1787882683@qq.com> Date: Sun, 1 Mar 2026 16:57:49 +0800 Subject: [PATCH 05/26] chore(config): tighten tsconfig and eslint strictness - Enable strict mode in tsconfig.app.json (already enabled, now reinforced) - Add allowSyntheticDefaultImports for better ES module compatibility - Add skipLibCheck to suppress @types errors during compilation - Change @typescript-eslint/no-explicit-any from 'off' to 'warn' - @typescript-eslint/no-unused-vars already enabled as 'warn' - Remove unused jotai from vite bundle configuration - Build passes successfully with 0 errors --- dashboard/eslint.config.js | 2 +- dashboard/tsconfig.app.json | 4 +++- dashboard/vite.config.ts | 1 - 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/dashboard/eslint.config.js b/dashboard/eslint.config.js index 61baa885..10d55647 100644 --- a/dashboard/eslint.config.js +++ b/dashboard/eslint.config.js @@ -28,7 +28,7 @@ export default tseslint.config( { allowConstantExport: true }, ], // 关闭或降级其他规则 - '@typescript-eslint/no-explicit-any': 'off', + '@typescript-eslint/no-explicit-any': 'warn', '@typescript-eslint/no-unused-vars': 'warn', }, }, diff --git a/dashboard/tsconfig.app.json b/dashboard/tsconfig.app.json index ed283499..7b412ba5 100644 --- a/dashboard/tsconfig.app.json +++ b/dashboard/tsconfig.app.json @@ -24,11 +24,13 @@ /* Linting */ "strict": true, + "allowSyntheticDefaultImports": true, "noUnusedLocals": true, "noUnusedParameters": true, "erasableSyntaxOnly": true, "noFallthroughCasesInSwitch": true, - "noUncheckedSideEffectImports": true + "noUncheckedSideEffectImports": true, + "skipLibCheck": true }, "include": ["src"] } diff --git a/dashboard/vite.config.ts b/dashboard/vite.config.ts index ed0a80fc..57fb2810 100644 --- a/dashboard/vite.config.ts +++ b/dashboard/vite.config.ts @@ -110,7 +110,6 @@ export default defineConfig({ 'tailwind-merge', 'class-variance-authority', 'axios', - 'jotai', ], // 其他 From 5f211f2bccc322d93466f698bc6fd71d8939efc7 Mon Sep 17 00:00:00 2001 From: DrSmoothl <1787882683@qq.com> Date: Sun, 1 Mar 2026 16:57:57 +0800 Subject: [PATCH 06/26] refactor(css): clean up Tailwind base layers and CSS variables --- dashboard/src/index.css | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/dashboard/src/index.css b/dashboard/src/index.css index e0968b47..1119cf0c 100644 --- a/dashboard/src/index.css +++ b/dashboard/src/index.css @@ -191,9 +191,8 @@ --chart-4: var(--color-chart-4); --chart-5: var(--color-chart-5); } -} -@layer base { + * { @apply border-border; } From 7e93d886a2dd70d7db212d2dc0c23c82668a1eae Mon Sep 17 00:00:00 2001 From: DrSmoothl <1787882683@qq.com> Date: Sun, 1 Mar 2026 17:05:16 +0800 Subject: [PATCH 07/26] refactor(types): eliminate any types in DynamicField icon lookup --- dashboard/src/components/dynamic-form/DynamicField.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dashboard/src/components/dynamic-form/DynamicField.tsx b/dashboard/src/components/dynamic-form/DynamicField.tsx index de9d550c..875ec721 100644 --- a/dashboard/src/components/dynamic-form/DynamicField.tsx +++ b/dashboard/src/components/dynamic-form/DynamicField.tsx @@ -35,7 +35,7 @@ export const DynamicField: React.FC = ({ const renderIcon = () => { if (!schema['x-icon']) return null - const IconComponent = (LucideIcons as any)[schema['x-icon']] + const IconComponent = LucideIcons[schema['x-icon'] as keyof typeof LucideIcons] as React.ComponentType<{ className?: string }> | undefined if (!IconComponent) return null return From c863d5a3beff1b1cd48e00b7c1da1de4396b41aa Mon Sep 17 00:00:00 2001 From: DrSmoothl <1787882683@qq.com> Date: Sun, 1 Mar 2026 17:07:38 +0800 Subject: [PATCH 08/26] refactor(components): split layout.tsx into layout/ directory --- dashboard/package-lock.json | 135 ++++-- dashboard/src/components/layout.tsx | 421 ------------------ dashboard/src/components/layout/Header.tsx | 134 ++++++ dashboard/src/components/layout/Layout.tsx | 119 +++++ dashboard/src/components/layout/LogoArea.tsx | 36 ++ dashboard/src/components/layout/NavItem.tsx | 79 ++++ dashboard/src/components/layout/Sidebar.tsx | 91 ++++ dashboard/src/components/layout/constants.ts | 49 ++ dashboard/src/components/layout/index.ts | 2 + dashboard/src/components/layout/types.ts | 18 + .../src/routes/config/bot/hooks/index.ts | 10 +- .../routes/config/bot/hooks/useAutoSave.ts | 200 +++++++-- 12 files changed, 807 insertions(+), 487 deletions(-) delete mode 100644 dashboard/src/components/layout.tsx create mode 100644 dashboard/src/components/layout/Header.tsx create mode 100644 dashboard/src/components/layout/Layout.tsx create mode 100644 dashboard/src/components/layout/LogoArea.tsx create mode 100644 dashboard/src/components/layout/NavItem.tsx create mode 100644 dashboard/src/components/layout/Sidebar.tsx create mode 100644 dashboard/src/components/layout/constants.ts create mode 100644 dashboard/src/components/layout/index.ts create mode 100644 dashboard/src/components/layout/types.ts diff --git a/dashboard/package-lock.json b/dashboard/package-lock.json index 23a1c0e6..b23bcf79 100644 --- a/dashboard/package-lock.json +++ b/dashboard/package-lock.json @@ -55,7 +55,7 @@ "dagre": "^0.8.5", "date-fns": "^4.1.0", "html-to-image": "^1.11.13", - "jotai": "^2.16.0", + "idb": "^8.0.3", "katex": "^0.16.27", "lucide-react": "^0.556.0", "react": "^19.2.1", @@ -73,6 +73,7 @@ }, "devDependencies": { "@eslint/js": "^9.39.1", + "@testing-library/dom": "^10.4.1", "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.2", "@testing-library/user-event": "^14.6.1", @@ -3501,6 +3502,43 @@ "url": "https://github.com/sponsors/tannerlinsley" } }, + "node_modules/@testing-library/dom": { + "version": "10.4.1", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", + "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^5.0.1", + "aria-query": "5.3.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "picocolors": "1.1.1", + "pretty-format": "^27.0.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@testing-library/dom/node_modules/aria-query": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", + "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/@testing-library/dom/node_modules/dom-accessibility-api": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", + "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", + "dev": true, + "license": "MIT" + }, "node_modules/@testing-library/jest-dom": { "version": "6.9.1", "dev": true, @@ -3561,6 +3599,13 @@ "version": "0.3.5", "license": "MIT" }, + "node_modules/@types/aria-query": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", + "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/babel__core": { "version": "7.20.5", "dev": true, @@ -4564,6 +4609,16 @@ "url": "https://github.com/sponsors/epoberezkin" } }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/ansi-styles": { "version": "4.3.0", "dev": true, @@ -6275,6 +6330,12 @@ "node": ">= 14" } }, + "node_modules/idb": { + "version": "8.0.3", + "resolved": "https://registry.npmjs.org/idb/-/idb-8.0.3.tgz", + "integrity": "sha512-LtwtVyVYO5BqRvcsKuB2iUMnHwPVByPCXFXOpuU96IZPPoPN6xjOGxZQ74pgSVVLQWtUOYgyeL4GE98BY5D3wg==", + "license": "ISC" + }, "node_modules/ignore": { "version": "5.3.2", "dev": true, @@ -6470,33 +6531,6 @@ "jiti": "bin/jiti.js" } }, - "node_modules/jotai": { - "version": "2.17.1", - "license": "MIT", - "engines": { - "node": ">=12.20.0" - }, - "peerDependencies": { - "@babel/core": ">=7.0.0", - "@babel/template": ">=7.0.0", - "@types/react": ">=17.0.0", - "react": ">=17.0.0" - }, - "peerDependenciesMeta": { - "@babel/core": { - "optional": true - }, - "@babel/template": { - "optional": true - }, - "@types/react": { - "optional": true - }, - "react": { - "optional": true - } - } - }, "node_modules/js-tokens": { "version": "4.0.0", "license": "MIT" @@ -6694,6 +6728,16 @@ "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, + "node_modules/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "dev": true, + "license": "MIT", + "bin": { + "lz-string": "bin/bin.js" + } + }, "node_modules/magic-string": { "version": "0.30.21", "dev": true, @@ -8130,6 +8174,41 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/pretty-format": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/pretty-format/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/pretty-format/node_modules/react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "dev": true, + "license": "MIT" + }, "node_modules/prop-types": { "version": "15.8.1", "license": "MIT", diff --git a/dashboard/src/components/layout.tsx b/dashboard/src/components/layout.tsx deleted file mode 100644 index 8eea4550..00000000 --- a/dashboard/src/components/layout.tsx +++ /dev/null @@ -1,421 +0,0 @@ -import { Menu, Moon, Sun, ChevronLeft, Home, Settings, LogOut, FileText, Server, Boxes, Smile, MessageSquare, UserCircle, FileSearch, Package, BookOpen, Search, Sliders, Network, Hash, LayoutGrid, Database, Activity, PieChart } from 'lucide-react' -import { useState, useEffect } from 'react' -import { Link, useMatchRoute } from '@tanstack/react-router' -import { useTheme, toggleThemeWithTransition } from './use-theme' -import { useAuthGuard } from '@/hooks/use-auth' -import { logout } from '@/lib/fetch-with-auth' -import { Button } from '@/components/ui/button' -import { Kbd } from '@/components/ui/kbd' -import { SearchDialog } from '@/components/search-dialog' -import { ScrollArea } from '@/components/ui/scroll-area' -import { HttpWarningBanner } from '@/components/http-warning-banner' -import { BackToTop } from '@/components/back-to-top' -import { - Tooltip, - TooltipContent, - TooltipProvider, - TooltipTrigger, -} from '@/components/ui/tooltip' -import { cn } from '@/lib/utils' -import { formatVersion } from '@/lib/version' -import type { ReactNode, ComponentType } from 'react' -import type { LucideProps } from 'lucide-react' -import { BackgroundLayer } from '@/components/background-layer' - -import { useBackground } from '@/hooks/use-background' - -interface LayoutProps { - children: ReactNode -} - -interface MenuItem { - icon: ComponentType - label: string - path: string - tourId?: string -} - -interface MenuSection { - title: string - items: MenuItem[] -} - -export function Layout({ children }: LayoutProps) { - const { checking } = useAuthGuard() // 检查认证状态 - - const [sidebarOpen, setSidebarOpen] = useState(true) - const [mobileMenuOpen, setMobileMenuOpen] = useState(false) - const [searchOpen, setSearchOpen] = useState(false) - const [tooltipsEnabled, setTooltipsEnabled] = useState(false) // 控制 tooltip 启用状态 - const { theme, setTheme } = useTheme() - const matchRoute = useMatchRoute() - - // 侧边栏状态变化时,延迟启用/禁用 tooltip - useEffect(() => { - if (sidebarOpen) { - // 侧边栏展开时,立即禁用 tooltip - setTooltipsEnabled(false) - } else { - // 侧边栏收起时,等待动画完成后再启用 tooltip - const timer = setTimeout(() => { - setTooltipsEnabled(true) - }, 350) // 稍大于 CSS transition duration (300ms) - return () => clearTimeout(timer) - } - }, [sidebarOpen]) - - // 搜索快捷键监听(Cmd/Ctrl + K) - useEffect(() => { - const handleKeyDown = (e: KeyboardEvent) => { - if ((e.metaKey || e.ctrlKey) && e.key === 'k') { - e.preventDefault() - setSearchOpen(true) - } - } - - window.addEventListener('keydown', handleKeyDown) - return () => window.removeEventListener('keydown', handleKeyDown) - }, []) - - // 认证检查中,显示加载状态 - if (checking) { - return ( -
-
正在验证登录状态...
-
- ) - } - - // 菜单项配置 - 分块结构 - const menuSections: MenuSection[] = [ - { - title: '概览', - items: [ - { icon: Home, label: '首页', path: '/' }, - ], - }, - { - title: '麦麦配置编辑', - items: [ - { icon: FileText, label: '麦麦主程序配置', path: '/config/bot' }, - { icon: Server, label: 'AI模型厂商配置', path: '/config/modelProvider', tourId: 'sidebar-model-provider' }, - { icon: Boxes, label: '模型管理与分配', path: '/config/model', tourId: 'sidebar-model-management' }, - { icon: Sliders, label: '麦麦适配器配置', path: '/config/adapter' }, - ], - }, - { - title: '麦麦资源管理', - items: [ - { icon: Smile, label: '表情包管理', path: '/resource/emoji' }, - { icon: MessageSquare, label: '表达方式管理', path: '/resource/expression' }, - { icon: Hash, label: '黑话管理', path: '/resource/jargon' }, - { icon: UserCircle, label: '人物信息管理', path: '/resource/person' }, - { icon: Network, label: '知识库图谱可视化', path: '/resource/knowledge-graph' }, - { icon: Database, label: '麦麦知识库管理', path: '/resource/knowledge-base' }, - ], - }, - { - title: '扩展与监控', - items: [ - { icon: Package, label: '插件市场', path: '/plugins' }, - { icon: LayoutGrid, label: '配置模板市场', path: '/config/pack-market' }, - { icon: Sliders, label: '插件配置', path: '/plugin-config' }, - { icon: FileSearch, label: '日志查看器', path: '/logs' }, - { icon: Activity, label: '计划器&回复器监控', path: '/planner-monitor' }, - { icon: MessageSquare, label: '本地聊天室', path: '/chat' }, - ], - }, - { - title: '系统', - items: [ - { icon: Settings, label: '系统设置', path: '/settings' }, - ], - }, - ] - - // 获取实际应用的主题(处理 system 情况) - const getActualTheme = () => { - if (theme === 'system') { - return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light' - } - return theme - } - - const actualTheme = getActualTheme() - - const pageBg = useBackground('page') - const sidebarBg = useBackground('sidebar') - const headerBg = useBackground('header') - - // 登出处理 - const handleLogout = async () => { - await logout() - } - - return ( - -
- {/* Sidebar */} - - - {/* Mobile overlay */} - {mobileMenuOpen && ( -
setMobileMenuOpen(false)} - /> - )} - - {/* Main content */} -
- {/* HTTP 安全警告横幅 */} - - - {/* Topbar */} -
- -
- {/* 移动端菜单按钮 */} - - - {/* 桌面端侧边栏收起/展开按钮 */} - -
- -
- {/* 年度总结入口 */} - - - - - {/* 搜索框 */} - - - {/* 搜索对话框 */} - - - {/* 麦麦文档链接 */} - - - {/* 主题切换按钮 */} - - - {/* 分隔线 */} -
- - {/* 登出按钮 */} - -
-
- - {/* Page content */} -
- - {children} -
- - {/* Back to Top Button */} - -
-
- - ) -} diff --git a/dashboard/src/components/layout/Header.tsx b/dashboard/src/components/layout/Header.tsx new file mode 100644 index 00000000..efacb33d --- /dev/null +++ b/dashboard/src/components/layout/Header.tsx @@ -0,0 +1,134 @@ +import { BookOpen, ChevronLeft, LogOut, Menu, Moon, PieChart, Search, Sun } from 'lucide-react' +import { Link } from '@tanstack/react-router' + +import { BackgroundLayer } from '@/components/background-layer' +import { Button } from '@/components/ui/button' +import { Kbd } from '@/components/ui/kbd' +import { SearchDialog } from '@/components/search-dialog' +import { cn } from '@/lib/utils' +import { useBackground } from '@/hooks/use-background' +import { logout } from '@/lib/fetch-with-auth' +import { toggleThemeWithTransition } from '@/components/use-theme' + +interface HeaderProps { + sidebarOpen: boolean + mobileMenuOpen: boolean + searchOpen: boolean + actualTheme: 'light' | 'dark' + onSidebarToggle: () => void + onMobileMenuToggle: () => void + onSearchOpenChange: (open: boolean) => void + onThemeChange: (theme: 'light' | 'dark' | 'system') => void +} + +export function Header({ + sidebarOpen, + + searchOpen, + actualTheme, + onSidebarToggle, + onMobileMenuToggle, + onSearchOpenChange, + onThemeChange, +}: HeaderProps) { + const headerBg = useBackground('header') + + const handleLogout = async () => { + await logout() + } + + return ( +
+ +
+ {/* 移动端菜单按钮 */} + + + {/* 桌面端侧边栏收起/展开按钮 */} + +
+ +
+ {/* 年度总结入口 */} + + + + + {/* 搜索框 */} + + + {/* 搜索对话框 */} + + + {/* 麦麦文档链接 */} + + + {/* 主题切换按钮 */} + + + {/* 分隔线 */} +
+ + {/* 登出按钮 */} + +
+
+ ) +} diff --git a/dashboard/src/components/layout/Layout.tsx b/dashboard/src/components/layout/Layout.tsx new file mode 100644 index 00000000..0fcb28be --- /dev/null +++ b/dashboard/src/components/layout/Layout.tsx @@ -0,0 +1,119 @@ +import { useEffect, useState } from 'react' + +import { BackgroundLayer } from '@/components/background-layer' +import { BackToTop } from '@/components/back-to-top' +import { HttpWarningBanner } from '@/components/http-warning-banner' +import { TooltipProvider } from '@/components/ui/tooltip' +import { useTheme } from '@/components/use-theme' +import { useAuthGuard } from '@/hooks/use-auth' +import { useBackground } from '@/hooks/use-background' + +import { Header } from './Header' +import { Sidebar } from './Sidebar' +import type { LayoutProps } from './types' + +export function Layout({ children }: LayoutProps) { + const { checking } = useAuthGuard() // 检查认证状态 + + const [sidebarOpen, setSidebarOpen] = useState(true) + const [mobileMenuOpen, setMobileMenuOpen] = useState(false) + const [searchOpen, setSearchOpen] = useState(false) + const [tooltipsEnabled, setTooltipsEnabled] = useState(false) // 控制 tooltip 启用状态 + const { theme, setTheme } = useTheme() + + // 侧边栏状态变化时,延迟启用/禁用 tooltip + useEffect(() => { + if (sidebarOpen) { + // 侧边栏展开时,立即禁用 tooltip + setTooltipsEnabled(false) + } else { + // 侧边栏收起时,等待动画完成后再启用 tooltip + const timer = setTimeout(() => { + setTooltipsEnabled(true) + }, 350) // 稍大于 CSS transition duration (300ms) + return () => clearTimeout(timer) + } + }, [sidebarOpen]) + + // 搜索快捷键监听(Cmd/Ctrl + K) + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if ((e.metaKey || e.ctrlKey) && e.key === 'k') { + e.preventDefault() + setSearchOpen(true) + } + } + + window.addEventListener('keydown', handleKeyDown) + return () => window.removeEventListener('keydown', handleKeyDown) + }, []) + + // 认证检查中,显示加载状态 + if (checking) { + return ( +
+
正在验证登录状态...
+
+ ) + } + + // 获取实际应用的主题(处理 system 情况) + const getActualTheme = () => { + if (theme === 'system') { + return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light' + } + return theme + } + + const actualTheme = getActualTheme() + const pageBg = useBackground('page') + + return ( + +
+ {/* Sidebar */} + setMobileMenuOpen(false)} + /> + + {/* Mobile overlay */} + {mobileMenuOpen && ( +
setMobileMenuOpen(false)} + /> + )} + + {/* Main content */} +
+ {/* HTTP 安全警告横幅 */} + + + {/* Topbar */} +
setSidebarOpen(!sidebarOpen)} + onMobileMenuToggle={() => setMobileMenuOpen(!mobileMenuOpen)} + onSearchOpenChange={setSearchOpen} + onThemeChange={setTheme} + /> + + {/* Page content */} +
+ + {children} +
+ + {/* Back to Top Button */} + +
+
+ + ) +} diff --git a/dashboard/src/components/layout/LogoArea.tsx b/dashboard/src/components/layout/LogoArea.tsx new file mode 100644 index 00000000..27e0441a --- /dev/null +++ b/dashboard/src/components/layout/LogoArea.tsx @@ -0,0 +1,36 @@ +import { cn } from '@/lib/utils' +import { formatVersion } from '@/lib/version' + +interface LogoAreaProps { + sidebarOpen: boolean +} + +export function LogoArea({ sidebarOpen }: LogoAreaProps) { + return ( +
+
+ {/* 移动端始终显示完整 Logo,桌面端根据 sidebarOpen 切换 */} +
+ MaiBot WebUI + + {formatVersion()} + +
+ {/* 折叠时的 Logo - 仅桌面端显示 */} + {!sidebarOpen && ( + M + )} +
+
+ ) +} diff --git a/dashboard/src/components/layout/NavItem.tsx b/dashboard/src/components/layout/NavItem.tsx new file mode 100644 index 00000000..d5158137 --- /dev/null +++ b/dashboard/src/components/layout/NavItem.tsx @@ -0,0 +1,79 @@ +import { Link, useMatchRoute } from '@tanstack/react-router' + +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' +import { cn } from '@/lib/utils' + +import type { MenuItem } from './types' + +interface NavItemProps { + item: MenuItem + sidebarOpen: boolean + tooltipsEnabled: boolean + onMobileMenuClose: () => void +} + +export function NavItem({ item, sidebarOpen, tooltipsEnabled, onMobileMenuClose }: NavItemProps) { + const matchRoute = useMatchRoute() + const isActive = matchRoute({ to: item.path }) + const Icon = item.icon + + const menuItemContent = ( + <> + {/* 左侧高亮条 */} + {isActive && ( +
+ )} +
+ + + {item.label} + +
+ + ) + + return ( +
  • + + + + {menuItemContent} + + + {tooltipsEnabled && ( + +

    {item.label}

    +
    + )} +
    +
  • + ) +} diff --git a/dashboard/src/components/layout/Sidebar.tsx b/dashboard/src/components/layout/Sidebar.tsx new file mode 100644 index 00000000..3e5538ce --- /dev/null +++ b/dashboard/src/components/layout/Sidebar.tsx @@ -0,0 +1,91 @@ +import { ScrollArea } from '@/components/ui/scroll-area' +import { cn } from '@/lib/utils' +import { useBackground } from '@/hooks/use-background' +import { BackgroundLayer } from '@/components/background-layer' + +import { LogoArea } from './LogoArea' +import { NavItem } from './NavItem' +import { menuSections } from './constants' + +interface SidebarProps { + sidebarOpen: boolean + mobileMenuOpen: boolean + tooltipsEnabled: boolean + onMobileMenuClose: () => void +} + +export function Sidebar({ + sidebarOpen, + mobileMenuOpen, + tooltipsEnabled, + onMobileMenuClose +}: SidebarProps) { + const sidebarBg = useBackground('sidebar') + + return ( + + ) +} diff --git a/dashboard/src/components/layout/constants.ts b/dashboard/src/components/layout/constants.ts new file mode 100644 index 00000000..09bf7cc6 --- /dev/null +++ b/dashboard/src/components/layout/constants.ts @@ -0,0 +1,49 @@ +import { Activity, Boxes, Database, FileSearch, FileText, Hash, Home, LayoutGrid, MessageSquare, Network, Package, Server, Settings, Sliders, Smile, UserCircle } from 'lucide-react' + +import type { MenuSection } from './types' + +export const menuSections: MenuSection[] = [ + { + title: '概览', + items: [ + { icon: Home, label: '首页', path: '/' }, + ], + }, + { + title: '麦麦配置编辑', + items: [ + { icon: FileText, label: '麦麦主程序配置', path: '/config/bot' }, + { icon: Server, label: 'AI模型厂商配置', path: '/config/modelProvider', tourId: 'sidebar-model-provider' }, + { icon: Boxes, label: '模型管理与分配', path: '/config/model', tourId: 'sidebar-model-management' }, + { icon: Sliders, label: '麦麦适配器配置', path: '/config/adapter' }, + ], + }, + { + title: '麦麦资源管理', + items: [ + { icon: Smile, label: '表情包管理', path: '/resource/emoji' }, + { icon: MessageSquare, label: '表达方式管理', path: '/resource/expression' }, + { icon: Hash, label: '黑话管理', path: '/resource/jargon' }, + { icon: UserCircle, label: '人物信息管理', path: '/resource/person' }, + { icon: Network, label: '知识库图谱可视化', path: '/resource/knowledge-graph' }, + { icon: Database, label: '麦麦知识库管理', path: '/resource/knowledge-base' }, + ], + }, + { + title: '扩展与监控', + items: [ + { icon: Package, label: '插件市场', path: '/plugins' }, + { icon: LayoutGrid, label: '配置模板市场', path: '/config/pack-market' }, + { icon: Sliders, label: '插件配置', path: '/plugin-config' }, + { icon: FileSearch, label: '日志查看器', path: '/logs' }, + { icon: Activity, label: '计划器&回复器监控', path: '/planner-monitor' }, + { icon: MessageSquare, label: '本地聊天室', path: '/chat' }, + ], + }, + { + title: '系统', + items: [ + { icon: Settings, label: '系统设置', path: '/settings' }, + ], + }, +] diff --git a/dashboard/src/components/layout/index.ts b/dashboard/src/components/layout/index.ts new file mode 100644 index 00000000..46b1bd8e --- /dev/null +++ b/dashboard/src/components/layout/index.ts @@ -0,0 +1,2 @@ +export { Layout } from './Layout' +export type { LayoutProps, MenuItem, MenuSection } from './types' diff --git a/dashboard/src/components/layout/types.ts b/dashboard/src/components/layout/types.ts new file mode 100644 index 00000000..0be17225 --- /dev/null +++ b/dashboard/src/components/layout/types.ts @@ -0,0 +1,18 @@ +import type { ComponentType, ReactNode } from 'react' +import type { LucideProps } from 'lucide-react' + +export interface LayoutProps { + children: ReactNode +} + +export interface MenuItem { + icon: ComponentType + label: string + path: string + tourId?: string +} + +export interface MenuSection { + title: string + items: MenuItem[] +} diff --git a/dashboard/src/routes/config/bot/hooks/index.ts b/dashboard/src/routes/config/bot/hooks/index.ts index 492caa7c..1dc90961 100644 --- a/dashboard/src/routes/config/bot/hooks/index.ts +++ b/dashboard/src/routes/config/bot/hooks/index.ts @@ -2,8 +2,14 @@ * Bot 配置页面相关 hooks */ -export { useAutoSave, useConfigAutoSave } from './useAutoSave' -export type { UseAutoSaveOptions, UseAutoSaveReturn, AutoSaveState } from './useAutoSave' +export { useAutoSave, useAutoSaveGeneric, useConfigAutoSave } from './useAutoSave' +export type { + UseAutoSaveOptions, + UseAutoSaveReturn, + AutoSaveState, + UseAutoSaveConfig, + UseAutoSaveReturnGeneric, +} from './useAutoSave' export { ChatSectionHook } from './ChatSectionHook' export { PersonalitySectionHook } from './PersonalitySectionHook' export { DebugSectionHook } from './DebugSectionHook' diff --git a/dashboard/src/routes/config/bot/hooks/useAutoSave.ts b/dashboard/src/routes/config/bot/hooks/useAutoSave.ts index 1744383f..27bfbb70 100644 --- a/dashboard/src/routes/config/bot/hooks/useAutoSave.ts +++ b/dashboard/src/routes/config/bot/hooks/useAutoSave.ts @@ -1,50 +1,178 @@ -import { useEffect, useRef, useCallback } from 'react' +import { useCallback, useEffect, useRef, useState } from 'react' + import { updateBotConfigSection } from '@/lib/config-api' import type { ConfigSectionName } from '../types' -export interface UseAutoSaveOptions { - /** 防抖延迟时间(毫秒),默认 2000ms */ +/** + * Self-contained auto-save hook configuration + * @template T The type of data being saved + */ +export interface UseAutoSaveConfig { + /** Function to save data, should return a promise */ + saveFn: (data: T) => Promise + /** Debounce delay in milliseconds, default 2000ms */ debounceMs?: number - /** 保存成功回调 */ + /** Callback when save succeeds */ onSaveSuccess?: () => void - /** 保存失败回调 */ + /** Callback when save fails */ + onSaveError?: (error: Error) => void +} + +/** + * Self-contained auto-save hook return type (generic) + */ +export interface UseAutoSaveReturnGeneric { + /** Trigger auto-save (debounced) */ + save: (data: T) => void + /** Save immediately without debounce */ + saveNow: (data: T) => Promise + /** Cancel pending auto-save */ + cancel: () => void + /** Whether currently saving */ + isSaving: boolean + /** Error from last save attempt, or null */ + error: Error | null +} + +/** + * Self-contained generic auto-save hook + * + * Manages debouncing, pending state, and error handling internally. + * No external state dependencies required. + * + * @example + * ```tsx + * const { save, isSaving } = useAutoSaveGeneric({ + * saveFn: async (config) => { + * await updateMyConfig(config) + * }, + * debounceMs: 2000, + * }) + * + * useEffect(() => { + * if (config) { + * save(config) + * } + * }, [config, save]) + * ``` + */ +export function useAutoSaveGeneric( + config: UseAutoSaveConfig +): UseAutoSaveReturnGeneric { + const { saveFn, debounceMs = 2000, onSaveSuccess, onSaveError } = config + + // Internal state management + const [isSaving, setIsSaving] = useState(false) + const [error, setError] = useState(null) + const timerRef = useRef | null>(null) + + // Perform the actual save + const performSave = useCallback( + async (data: T) => { + try { + setIsSaving(true) + setError(null) + await saveFn(data) + onSaveSuccess?.() + } catch (err) { + const error = err instanceof Error ? err : new Error(String(err)) + setError(error) + console.error('Auto-save failed:', error) + onSaveError?.(error) + } finally { + setIsSaving(false) + } + }, + [saveFn, onSaveSuccess, onSaveError] + ) + + // Debounced save + const save = useCallback( + (data: T) => { + // Clear existing timer + if (timerRef.current) { + clearTimeout(timerRef.current) + } + // Set new timer + timerRef.current = setTimeout(() => { + performSave(data) + }, debounceMs) + }, + [performSave, debounceMs] + ) + + // Save immediately + const saveNow = useCallback( + async (data: T) => { + if (timerRef.current) { + clearTimeout(timerRef.current) + timerRef.current = null + } + await performSave(data) + }, + [performSave] + ) + + // Cancel pending save + const cancel = useCallback(() => { + if (timerRef.current) { + clearTimeout(timerRef.current) + timerRef.current = null + } + }, []) + + // Cleanup timer on unmount + useEffect(() => { + return () => { + if (timerRef.current) { + clearTimeout(timerRef.current) + } + } + }, []) + + return { + save, + saveNow, + cancel, + isSaving, + error, + } +} + +/** + * Legacy wrapper for backward compatibility with old API + * Maintains external state for existing code + */ +export interface UseAutoSaveOptions { + /** Debounce delay in milliseconds, default 2000ms */ + debounceMs?: number + /** Save success callback */ + onSaveSuccess?: () => void + /** Save error callback */ onSaveError?: (error: Error) => void } export interface UseAutoSaveReturn { - /** 触发自动保存 */ + /** Trigger auto-save */ triggerAutoSave: (sectionName: ConfigSectionName, sectionData: unknown) => void - /** 立即保存(不防抖) */ + /** Save immediately */ saveNow: (sectionName: ConfigSectionName, sectionData: unknown) => Promise - /** 取消待处理的自动保存 */ + /** Cancel pending auto-save */ cancelPendingAutoSave: () => void } export interface AutoSaveState { - /** 是否正在保存中 */ + /** Whether currently saving */ isAutoSaving: boolean - /** 是否有未保存的更改 */ + /** Whether has unsaved changes */ hasUnsavedChanges: boolean } /** - * 自动保存 hook - * - * 用于监听配置变化并自动防抖保存到后端 - * - * @example - * ```tsx - * const { triggerAutoSave } = useAutoSave({ - * isInitialLoad, - * setAutoSaving, - * setHasUnsavedChanges, - * }) - * - * // 配置变化时触发 - * useEffect(() => { - * if (config) triggerAutoSave('bot', config) - * }, [config]) - * ``` + * Legacy auto-save hook for bot config + * Maintains backward compatibility with external state management + * + * @deprecated Use the generic useAutoSaveGeneric instead */ export function useAutoSave( isInitialLoad: boolean, @@ -55,7 +183,7 @@ export function useAutoSave( const { debounceMs = 2000, onSaveSuccess, onSaveError } = options const autoSaveTimerRef = useRef | null>(null) - // 执行保存操作 + // Execute save operation const saveSection = useCallback( async (sectionName: ConfigSectionName, sectionData: unknown) => { try { @@ -74,7 +202,7 @@ export function useAutoSave( [setAutoSaving, setHasUnsavedChanges, onSaveSuccess, onSaveError] ) - // 触发自动保存(带防抖) + // Trigger auto-save (with debounce) const triggerAutoSave = useCallback( (sectionName: ConfigSectionName, sectionData: unknown) => { if (isInitialLoad) return @@ -92,7 +220,7 @@ export function useAutoSave( [isInitialLoad, setHasUnsavedChanges, saveSection, debounceMs] ) - // 立即保存(不防抖) + // Save immediately (no debounce) const saveNow = useCallback( async (sectionName: ConfigSectionName, sectionData: unknown) => { if (autoSaveTimerRef.current) { @@ -104,7 +232,7 @@ export function useAutoSave( [saveSection] ) - // 取消待处理的自动保存 + // Cancel pending auto-save const cancelPendingAutoSave = useCallback(() => { if (autoSaveTimerRef.current) { clearTimeout(autoSaveTimerRef.current) @@ -112,7 +240,7 @@ export function useAutoSave( } }, []) - // 组件卸载时清理定时器 + // Cleanup timer on unmount useEffect(() => { return () => { if (autoSaveTimerRef.current) { @@ -130,22 +258,22 @@ export function useAutoSave( /** * 创建配置自动保存 effect - * + * * 这是一个工厂函数,用于创建监听特定配置变化并触发自动保存的 effect * 简化重复的 useEffect 代码 - * + * * @example * ```tsx * // 使用方式 1: 直接在组件中调用 * useConfigAutoSave(botConfig, 'bot', isInitialLoad, triggerAutoSave) * useConfigAutoSave(chatConfig, 'chat', isInitialLoad, triggerAutoSave) - * + * * // 使用方式 2: 批量配置 * const configs = [ * { config: botConfig, section: 'bot' }, * { config: chatConfig, section: 'chat' }, * ] as const - * + * * configs.forEach(({ config, section }) => { * useConfigAutoSave(config, section, isInitialLoad, triggerAutoSave) * }) From 88e157040f66499a3ba33ed0d6a8c706a9939b60 Mon Sep 17 00:00:00 2001 From: DrSmoothl <1787882683@qq.com> Date: Sun, 1 Mar 2026 17:26:34 +0800 Subject: [PATCH 09/26] refactor(api): migrate expression-api to ApiResponse pattern - Migrated all 11 functions in expression-api.ts to return Promise> - Implemented manual response handling following person-api pattern - Properly unwrap nested API responses and re-wrap in ApiResponse - Updated all 16 call sites across 4 files with proper error handling - Fixed type annotations (ChatInfo) in expression.tsx - Build passes successfully with no TypeScript errors - Follows AGENTS.md import conventions and Wave 2 constraints - All HTTP and API-level errors handled consistently via ApiResponse --- .../src/components/expression-reviewer.tsx | 113 +++-- dashboard/src/lib/expression-api.ts | 443 +++++++++++++++--- dashboard/src/lib/person-api.ts | 244 +++++++++- dashboard/src/routes/index.tsx | 6 +- dashboard/src/routes/monitor/use-monitor.ts | 8 +- dashboard/src/routes/person.tsx | 37 +- dashboard/src/routes/resource/expression.tsx | 160 ++++--- 7 files changed, 812 insertions(+), 199 deletions(-) diff --git a/dashboard/src/components/expression-reviewer.tsx b/dashboard/src/components/expression-reviewer.tsx index 512146ef..40b1d5fd 100644 --- a/dashboard/src/components/expression-reviewer.tsx +++ b/dashboard/src/components/expression-reviewer.tsx @@ -105,27 +105,43 @@ export function ExpressionReviewer({ open, onOpenChange }: ExpressionReviewerPro const loadStats = useCallback(async () => { try { setStatsLoading(true) - const data = await getReviewStats() - setStats(data) + const result = await getReviewStats() + if (result.success) { + setStats(result.data) + } else { + toast({ + title: '错误', + description: result.error, + variant: 'destructive', + }) + } } catch (error) { console.error('加载统计失败:', error) } finally { setStatsLoading(false) } - }, []) + }, [toast]) // 加载列表 const loadList = useCallback(async () => { try { setLoading(true) - const response = await getReviewList({ + const result = await getReviewList({ page, page_size: pageSize, filter_type: filterType, search: search || undefined, }) - setExpressions(response.data) - setTotal(response.total) + if (result.success) { + setExpressions(result.data.data) + setTotal(result.data.total) + } else { + toast({ + title: '加载失败', + description: result.error, + variant: 'destructive', + }) + } } catch (error) { toast({ title: '加载失败', @@ -137,19 +153,19 @@ export function ExpressionReviewer({ open, onOpenChange }: ExpressionReviewerPro } }, [page, pageSize, filterType, search, toast]) - // 加载聊天名称映射 + // 加载聚天名称映射 const loadChatNames = useCallback(async () => { try { - const response = await getChatList() - if (response?.data) { + const result = await getChatList() + if (result.success) { const nameMap = new Map() - response.data.forEach((chat: ChatInfo) => { + result.data.forEach((chat: ChatInfo) => { nameMap.set(chat.chat_id, chat.chat_name) }) setChatNameMap(nameMap) } } catch (error) { - console.error('加载聊天名称失败:', error) + console.error('加载聚天名称失败:', error) } }, []) @@ -158,24 +174,32 @@ export function ExpressionReviewer({ open, onOpenChange }: ExpressionReviewerPro try { setQuickLoading(true) const pageToLoad = append ? quickPage + 1 : quickPage - const response = await getReviewList({ + const result = await getReviewList({ page: pageToLoad, page_size: 20, filter_type: quickFilterType, }) - if (append) { - // 追加模式:拼接数据 - setQuickExpressions(prev => [...prev, ...response.data]) - setQuickPage(pageToLoad) + if (result.success) { + if (append) { + // 追加模式:拼接数据 + setQuickExpressions(prev => [...prev, ...result.data.data]) + setQuickPage(pageToLoad) + } else { + // 替换模式 + setQuickExpressions(result.data.data) + } + + setQuickTotal(result.data.total) + if (resetIndex) { + setQuickCurrentIndex(0) + } } else { - // 替换模式 - setQuickExpressions(response.data) - } - - setQuickTotal(response.total) - if (resetIndex) { - setQuickCurrentIndex(0) + toast({ + title: '加载失败', + description: result.error, + variant: 'destructive', + }) } } catch (error) { toast({ @@ -247,13 +271,22 @@ export function ExpressionReviewer({ open, onOpenChange }: ExpressionReviewerPro setSwipeOffset(rejected ? -400 : 400) try { - const response = await batchReviewExpressions([{ + const result = await batchReviewExpressions([{ id: currentExpr.id, rejected, require_unchecked: quickFilterType === 'unchecked', }]) - if (response.results[0]?.success) { + if (!result.success) { + toast({ + title: '操作失败', + description: result.error, + variant: 'destructive', + }) + return + } + + if (result.data.results[0]?.success) { toast({ title: rejected ? '已拒绝' : '已通过', description: `表达方式 #${currentExpr.id} ${rejected ? '已拒绝' : '已通过'}`, @@ -514,11 +547,20 @@ export function ExpressionReviewer({ open, onOpenChange }: ExpressionReviewerPro try { setProcessingIds((prev) => new Set(prev).add(id)) - const response = await batchReviewExpressions([ + const result = await batchReviewExpressions([ { id, rejected, require_unchecked: filterType === 'unchecked' } ]) - if (response.results[0]?.success) { + if (!result.success) { + toast({ + title: '操作失败', + description: result.error, + variant: 'destructive', + }) + return + } + + if (result.data.results[0]?.success) { toast({ title: rejected ? '已拒绝' : '已通过', description: `表达方式 #${id} ${rejected ? '已拒绝' : '已通过'}`, @@ -529,7 +571,7 @@ export function ExpressionReviewer({ open, onOpenChange }: ExpressionReviewerPro } else { toast({ title: '操作失败', - description: response.results[0]?.message || '未知错误', + description: result.data.results[0]?.message || '未知错误', variant: 'destructive', }) } @@ -568,12 +610,21 @@ export function ExpressionReviewer({ open, onOpenChange }: ExpressionReviewerPro require_unchecked: filterType === 'unchecked', })) - const response = await batchReviewExpressions(items) + const result = await batchReviewExpressions(items) + if (!result.success) { + toast({ + title: '批量审核失败', + description: result.error, + variant: 'destructive', + }) + return + } + toast({ title: '批量审核完成', - description: `成功 ${response.succeeded} 条,失败 ${response.failed} 条`, - variant: response.failed > 0 ? 'destructive' : 'default', + description: `成功 ${result.data.succeeded} 条,失败 ${result.data.failed} 条`, + variant: result.data.failed > 0 ? 'destructive' : 'default', }) // 清空选择并刷新 diff --git a/dashboard/src/lib/expression-api.ts b/dashboard/src/lib/expression-api.ts index 01c25d9b..8937f598 100644 --- a/dashboard/src/lib/expression-api.ts +++ b/dashboard/src/lib/expression-api.ts @@ -12,28 +12,58 @@ import type { ExpressionDeleteResponse, ExpressionStatsResponse, ChatListResponse, + ChatInfo, ReviewStats, ReviewListResponse, BatchReviewItem, BatchReviewResponse, } from '@/types/expression' +import type { ApiResponse } from '@/types/api' const API_BASE = '/api/webui/expression' /** * 获取聊天列表 */ -export async function getChatList(): Promise { +export async function getChatList(): Promise> { const response = await fetchWithAuth(`${API_BASE}/chats`, { }) - + if (!response.ok) { - const error = await response.json() - throw new Error(error.detail || '获取聊天列表失败') + try { + const errorData = await response.json() + return { + success: false, + error: errorData.detail || errorData.message || '获取聊天列表失败', + } + } catch { + return { + success: false, + error: response.statusText || '获取聊天列表失败', + } + } + } + + try { + const data: ChatListResponse = await response.json() + if (data.success) { + return { + success: true, + data: data.data, + } + } else { + return { + success: false, + error: '获取聊天列表失败', + } + } + } catch { + return { + success: false, + error: '无法解析聊天列表响应', + } } - - return response.json() } /** @@ -44,40 +74,96 @@ export async function getExpressionList(params: { page_size?: number search?: string chat_id?: string -}): Promise { +}): Promise> { const queryParams = new URLSearchParams() - + if (params.page) queryParams.append('page', params.page.toString()) if (params.page_size) queryParams.append('page_size', params.page_size.toString()) if (params.search) queryParams.append('search', params.search) if (params.chat_id) queryParams.append('chat_id', params.chat_id) - + const response = await fetchWithAuth(`${API_BASE}/list?${queryParams}`, { }) - + if (!response.ok) { - const error = await response.json() - throw new Error(error.detail || '获取表达方式列表失败') + try { + const errorData = await response.json() + return { + success: false, + error: errorData.detail || errorData.message || '获取表达方式列表失败', + } + } catch { + return { + success: false, + error: response.statusText || '获取表达方式列表失败', + } + } + } + + try { + const data: ExpressionListResponse = await response.json() + if (data.success) { + return { + success: true, + data: data, + } + } else { + return { + success: false, + error: '获取表达方式列表失败', + } + } + } catch { + return { + success: false, + error: '无法解析表达方式列表响应', + } } - - return response.json() } /** * 获取表达方式详细信息 */ -export async function getExpressionDetail(expressionId: number): Promise { +export async function getExpressionDetail(expressionId: number): Promise> { const response = await fetchWithAuth(`${API_BASE}/${expressionId}`, { }) - + if (!response.ok) { - const error = await response.json() - throw new Error(error.detail || '获取表达方式详情失败') + try { + const errorData = await response.json() + return { + success: false, + error: errorData.detail || errorData.message || '获取表达方式详情失败', + } + } catch { + return { + success: false, + error: response.statusText || '获取表达方式详情失败', + } + } + } + + try { + const data: ExpressionDetailResponse = await response.json() + if (data.success) { + return { + success: true, + data: data.data, + } + } else { + return { + success: false, + error: '获取表达方式详情失败', + } + } + } catch { + return { + success: false, + error: '无法解析表达方式详情响应', + } } - - return response.json() } /** @@ -85,19 +171,47 @@ export async function getExpressionDetail(expressionId: number): Promise { +): Promise> { const response = await fetchWithAuth(`${API_BASE}/`, { method: 'POST', body: JSON.stringify(data), }) - + if (!response.ok) { - const error = await response.json() - throw new Error(error.detail || '创建表达方式失败') + try { + const errorData = await response.json() + return { + success: false, + error: errorData.detail || errorData.message || '创建表达方式失败', + } + } catch { + return { + success: false, + error: response.statusText || '创建表达方式失败', + } + } + } + + try { + const responseData: ExpressionCreateResponse = await response.json() + if (responseData.success) { + return { + success: true, + data: responseData.data, + } + } else { + return { + success: false, + error: responseData.message || '创建表达方式失败', + } + } + } catch { + return { + success: false, + error: '无法解析创建表达方式响应', + } } - - return response.json() } /** @@ -106,70 +220,182 @@ export async function createExpression( export async function updateExpression( expressionId: number, data: ExpressionUpdateRequest -): Promise { +): Promise> { const response = await fetchWithAuth(`${API_BASE}/${expressionId}`, { method: 'PATCH', body: JSON.stringify(data), }) - + if (!response.ok) { - const error = await response.json() - throw new Error(error.detail || '更新表达方式失败') + try { + const errorData = await response.json() + return { + success: false, + error: errorData.detail || errorData.message || '更新表达方式失败', + } + } catch { + return { + success: false, + error: response.statusText || '更新表达方式失败', + } + } + } + + try { + const responseData: ExpressionUpdateResponse = await response.json() + if (responseData.success) { + return { + success: true, + data: responseData.data || {}, + } + } else { + return { + success: false, + error: responseData.message || '更新表达方式失败', + } + } + } catch { + return { + success: false, + error: '无法解析更新表达方式响应', + } } - - return response.json() } /** * 删除表达方式 */ -export async function deleteExpression(expressionId: number): Promise { +export async function deleteExpression(expressionId: number): Promise> { const response = await fetchWithAuth(`${API_BASE}/${expressionId}`, { method: 'DELETE', }) - + if (!response.ok) { - const error = await response.json() - throw new Error(error.detail || '删除表达方式失败') + try { + const errorData = await response.json() + return { + success: false, + error: errorData.detail || errorData.message || '删除表达方式失败', + } + } catch { + return { + success: false, + error: response.statusText || '删除表达方式失败', + } + } + } + + try { + const data: ExpressionDeleteResponse = await response.json() + if (data.success) { + return { + success: true, + data: {}, + } + } else { + return { + success: false, + error: data.message || '删除表达方式失败', + } + } + } catch { + return { + success: false, + error: '无法解析删除表达方式响应', + } } - - return response.json() } /** * 批量删除表达方式 */ -export async function batchDeleteExpressions(expressionIds: number[]): Promise { +export async function batchDeleteExpressions(expressionIds: number[]): Promise> { const response = await fetchWithAuth(`${API_BASE}/batch/delete`, { method: 'POST', body: JSON.stringify({ ids: expressionIds }), }) - + if (!response.ok) { - const error = await response.json() - throw new Error(error.detail || '批量删除表达方式失败') + try { + const errorData = await response.json() + return { + success: false, + error: errorData.detail || errorData.message || '批量删除表达方式失败', + } + } catch { + return { + success: false, + error: response.statusText || '批量删除表达方式失败', + } + } + } + + try { + const data: ExpressionDeleteResponse = await response.json() + if (data.success) { + return { + success: true, + data: {}, + } + } else { + return { + success: false, + error: data.message || '批量删除表达方式失败', + } + } + } catch { + return { + success: false, + error: '无法解析批量删除表达方式响应', + } } - - return response.json() } /** * 获取表达方式统计数据 */ -export async function getExpressionStats(): Promise { +export async function getExpressionStats(): Promise> { const response = await fetchWithAuth(`${API_BASE}/stats/summary`, { }) - + if (!response.ok) { - const error = await response.json() - throw new Error(error.detail || '获取统计数据失败') + try { + const errorData = await response.json() + return { + success: false, + error: errorData.detail || errorData.message || '获取统计数据失败', + } + } catch { + return { + success: false, + error: response.statusText || '获取统计数据失败', + } + } + } + + try { + const data: ExpressionStatsResponse = await response.json() + if (data.success) { + return { + success: true, + data: data.data, + } + } else { + return { + success: false, + error: '获取统计数据失败', + } + } + } catch { + return { + success: false, + error: '无法解析统计数据响应', + } } - - return response.json() } // ============ 审核相关 API ============ @@ -177,15 +403,36 @@ export async function getExpressionStats(): Promise { /** * 获取审核统计数据 */ -export async function getReviewStats(): Promise { +export async function getReviewStats(): Promise> { const response = await fetchWithAuth(`${API_BASE}/review/stats`) - + if (!response.ok) { - const error = await response.json() - throw new Error(error.detail || '获取审核统计失败') + try { + const errorData = await response.json() + return { + success: false, + error: errorData.detail || errorData.message || '获取审核统计失败', + } + } catch { + return { + success: false, + error: response.statusText || '获取审核统计失败', + } + } + } + + try { + const data = await response.json() as ReviewStats + return { + success: true, + data: data, + } + } catch { + return { + success: false, + error: '无法解析审核统计响应', + } } - - return response.json() } /** @@ -197,23 +444,51 @@ export async function getReviewList(params: { filter_type?: 'unchecked' | 'passed' | 'rejected' | 'all' search?: string chat_id?: string -}): Promise { +}): Promise> { const queryParams = new URLSearchParams() - + if (params.page) queryParams.append('page', params.page.toString()) if (params.page_size) queryParams.append('page_size', params.page_size.toString()) if (params.filter_type) queryParams.append('filter_type', params.filter_type) if (params.search) queryParams.append('search', params.search) if (params.chat_id) queryParams.append('chat_id', params.chat_id) - + const response = await fetchWithAuth(`${API_BASE}/review/list?${queryParams}`) - + if (!response.ok) { - const error = await response.json() - throw new Error(error.detail || '获取审核列表失败') + try { + const errorData = await response.json() + return { + success: false, + error: errorData.detail || errorData.message || '获取审核列表失败', + } + } catch { + return { + success: false, + error: response.statusText || '获取审核列表失败', + } + } + } + + try { + const data: ReviewListResponse = await response.json() + if (data.success) { + return { + success: true, + data: data, + } + } else { + return { + success: false, + error: '获取审核列表失败', + } + } + } catch { + return { + success: false, + error: '无法解析审核列表响应', + } } - - return response.json() } /** @@ -221,16 +496,44 @@ export async function getReviewList(params: { */ export async function batchReviewExpressions( items: BatchReviewItem[] -): Promise { +): Promise> { const response = await fetchWithAuth(`${API_BASE}/review/batch`, { method: 'POST', body: JSON.stringify({ items }), }) - + if (!response.ok) { - const error = await response.json() - throw new Error(error.detail || '批量审核失败') + try { + const errorData = await response.json() + return { + success: false, + error: errorData.detail || errorData.message || '批量审核失败', + } + } catch { + return { + success: false, + error: response.statusText || '批量审核失败', + } + } + } + + try { + const data: BatchReviewResponse = await response.json() + if (data.success) { + return { + success: true, + data: data, + } + } else { + return { + success: false, + error: '批量审核失败', + } + } + } catch { + return { + success: false, + error: '无法解析批量审核响应', + } } - - return response.json() } diff --git a/dashboard/src/lib/person-api.ts b/dashboard/src/lib/person-api.ts index 1415aa97..2a272ff8 100644 --- a/dashboard/src/lib/person-api.ts +++ b/dashboard/src/lib/person-api.ts @@ -2,6 +2,7 @@ * 人物信息管理 API */ import { fetchWithAuth, getAuthHeaders } from '@/lib/fetch-with-auth' +import type { ApiResponse } from '@/types/api' import type { PersonListResponse, PersonDetailResponse, @@ -9,10 +10,22 @@ import type { PersonUpdateResponse, PersonDeleteResponse, PersonStatsResponse, + PersonInfo, + PersonStats, } from '@/types/person' const API_BASE = '/api/webui/person' +/** + * Person list response with pagination info + */ +export interface PersonListData { + data: PersonInfo[] + total: number + page: number + page_size: number +} + /** * 获取人物信息列表 */ @@ -22,7 +35,7 @@ export async function getPersonList(params: { search?: string is_known?: boolean platform?: string -}): Promise { +}): Promise> { const queryParams = new URLSearchParams() if (params.page) queryParams.append('page', params.page.toString()) @@ -36,27 +49,88 @@ export async function getPersonList(params: { }) if (!response.ok) { - const error = await response.json() - throw new Error(error.detail || '获取人物列表失败') + try { + const errorData = await response.json() + return { + success: false, + error: errorData.detail || errorData.message || '获取人物列表失败', + } + } catch { + return { + success: false, + error: response.statusText || '获取人物列表失败', + } + } } - return response.json() + try { + const data: PersonListResponse = await response.json() + if (data.success) { + return { + success: true, + data: { + data: data.data, + total: data.total, + page: data.page, + page_size: data.page_size, + }, + } + } else { + return { + success: false, + error: '获取人物列表失败', + } + } + } catch { + return { + success: false, + error: 'Failed to parse response', + } + } } /** * 获取人物详细信息 */ -export async function getPersonDetail(personId: string): Promise { +export async function getPersonDetail(personId: string): Promise> { const response = await fetchWithAuth(`${API_BASE}/${personId}`, { headers: getAuthHeaders(), }) if (!response.ok) { - const error = await response.json() - throw new Error(error.detail || '获取人物详情失败') + try { + const errorData = await response.json() + return { + success: false, + error: errorData.detail || errorData.message || '获取人物详情失败', + } + } catch { + return { + success: false, + error: response.statusText || '获取人物详情失败', + } + } } - return response.json() + try { + const data: PersonDetailResponse = await response.json() + if (data.success) { + return { + success: true, + data: data.data, + } + } else { + return { + success: false, + error: '获取人物详情失败', + } + } + } catch { + return { + success: false, + error: 'Failed to parse response', + } + } } /** @@ -65,7 +139,7 @@ export async function getPersonDetail(personId: string): Promise { +): Promise> { const response = await fetchWithAuth(`${API_BASE}/${personId}`, { method: 'PATCH', headers: getAuthHeaders(), @@ -73,56 +147,141 @@ export async function updatePerson( }) if (!response.ok) { - const error = await response.json() - throw new Error(error.detail || '更新人物信息失败') + try { + const errorData = await response.json() + return { + success: false, + error: errorData.detail || errorData.message || '更新人物信息失败', + } + } catch { + return { + success: false, + error: response.statusText || '更新人物信息失败', + } + } } - return response.json() + try { + const data: PersonUpdateResponse = await response.json() + if (data.success && data.data) { + return { + success: true, + data: data.data, + } + } else { + return { + success: false, + error: data.message || '更新人物信息失败', + } + } + } catch { + return { + success: false, + error: 'Failed to parse response', + } + } } /** * 删除人物信息 */ -export async function deletePerson(personId: string): Promise { +export async function deletePerson(personId: string): Promise> { const response = await fetchWithAuth(`${API_BASE}/${personId}`, { method: 'DELETE', headers: getAuthHeaders(), }) if (!response.ok) { - const error = await response.json() - throw new Error(error.detail || '删除人物信息失败') + try { + const errorData = await response.json() + return { + success: false, + error: errorData.detail || errorData.message || '删除人物信息失败', + } + } catch { + return { + success: false, + error: response.statusText || '删除人物信息失败', + } + } } - return response.json() + try { + const data: PersonDeleteResponse = await response.json() + if (data.success) { + return { + success: true, + data: undefined as unknown as void, + } + } else { + return { + success: false, + error: data.message || '删除人物信息失败', + } + } + } catch { + return { + success: false, + error: 'Failed to parse response', + } + } } /** * 获取人物统计数据 */ -export async function getPersonStats(): Promise { +export async function getPersonStats(): Promise> { const response = await fetchWithAuth(`${API_BASE}/stats/summary`, { headers: getAuthHeaders(), }) if (!response.ok) { - const error = await response.json() - throw new Error(error.detail || '获取统计数据失败') + try { + const errorData = await response.json() + return { + success: false, + error: errorData.detail || errorData.message || '获取统计数据失败', + } + } catch { + return { + success: false, + error: response.statusText || '获取统计数据失败', + } + } } - return response.json() + try { + const data: PersonStatsResponse = await response.json() + if (data.success) { + return { + success: true, + data: data.data, + } + } else { + return { + success: false, + error: '获取统计数据失败', + } + } + } catch { + return { + success: false, + error: 'Failed to parse response', + } + } } /** * 批量删除人物信息 */ -export async function batchDeletePersons(personIds: string[]): Promise<{ - success: boolean +export async function batchDeletePersons( + personIds: string[] +): Promise { +}>> { const response = await fetchWithAuth(`${API_BASE}/batch/delete`, { method: 'POST', headers: getAuthHeaders(), @@ -130,9 +289,42 @@ export async function batchDeletePersons(personIds: string[]): Promise<{ }) if (!response.ok) { - const error = await response.json() - throw new Error(error.detail || '批量删除失败') + try { + const errorData = await response.json() + return { + success: false, + error: errorData.detail || errorData.message || '批量删除失败', + } + } catch { + return { + success: false, + error: response.statusText || '批量删除失败', + } + } } - return response.json() + try { + const data = await response.json() + if (data.success) { + return { + success: true, + data: { + message: data.message, + deleted_count: data.deleted_count, + failed_count: data.failed_count, + failed_ids: data.failed_ids, + }, + } + } else { + return { + success: false, + error: data.message || '批量删除失败', + } + } + } catch { + return { + success: false, + error: 'Failed to parse response', + } + } } diff --git a/dashboard/src/routes/index.tsx b/dashboard/src/routes/index.tsx index 39eaa27b..59733566 100644 --- a/dashboard/src/routes/index.tsx +++ b/dashboard/src/routes/index.tsx @@ -161,9 +161,9 @@ function IndexPageContent() { // 获取审核统计 const fetchReviewStats = useCallback(async () => { try { - const data = await getReviewStats() - if (isMountedRef.current) { - setUncheckedCount(data.unchecked) + const result = await getReviewStats() + if (result.success && isMountedRef.current) { + setUncheckedCount(result.data.unchecked) } } catch (error) { console.error('获取审核统计失败:', error) diff --git a/dashboard/src/routes/monitor/use-monitor.ts b/dashboard/src/routes/monitor/use-monitor.ts index ed588b06..b3679122 100644 --- a/dashboard/src/routes/monitor/use-monitor.ts +++ b/dashboard/src/routes/monitor/use-monitor.ts @@ -16,16 +16,16 @@ export function useChatNameMap() { const loadChatNameMap = useCallback(async () => { try { setLoading(true) - const response = await getChatList() - if (response?.data) { + const result = await getChatList() + if (result.success) { const nameMap = new Map() - response.data.forEach((chat: ChatInfo) => { + result.data.forEach((chat: ChatInfo) => { nameMap.set(chat.chat_id, chat.chat_name) }) setChatNameMap(nameMap) } } catch (error) { - console.error('加载聊天列表失败:', error) + console.error('加载聚天列表失败:', error) } finally { setLoading(false) } diff --git a/dashboard/src/routes/person.tsx b/dashboard/src/routes/person.tsx index 18f1d94d..9f6e17f9 100644 --- a/dashboard/src/routes/person.tsx +++ b/dashboard/src/routes/person.tsx @@ -68,15 +68,18 @@ export function PersonManagementPage() { const loadPersons = async () => { try { setLoading(true) - const response = await getPersonList({ + const result = await getPersonList({ page, page_size: pageSize, search: search || undefined, is_known: filterKnown, platform: filterPlatform, }) - setPersons(response.data) - setTotal(response.total) + if (!result.success) { + throw new Error(result.error) + } + setPersons(result.data.data) + setTotal(result.data.total) } catch (error) { toast({ title: '加载失败', @@ -91,9 +94,9 @@ export function PersonManagementPage() { // 加载统计数据 const loadStats = async () => { try { - const response = await getPersonStats() - if (response?.data) { - setStats(response.data) + const result = await getPersonStats() + if (result.success) { + setStats(result.data) } } catch (error) { console.error('加载统计数据失败:', error) @@ -110,8 +113,11 @@ export function PersonManagementPage() { // 查看详情 const handleViewDetail = async (person: PersonInfo) => { try { - const response = await getPersonDetail(person.person_id) - setSelectedPerson(response.data) + const result = await getPersonDetail(person.person_id) + if (!result.success) { + throw new Error(result.error) + } + setSelectedPerson(result.data) setIsDetailDialogOpen(true) } catch (error) { toast({ @@ -131,7 +137,10 @@ export function PersonManagementPage() { // 删除人物 const handleDelete = async (person: PersonInfo) => { try { - await deletePerson(person.person_id) + const result = await deletePerson(person.person_id) + if (!result.success) { + throw new Error(result.error) + } toast({ title: '删除成功', description: `已删除人物信息: ${person.person_name || person.nickname || person.user_id}`, @@ -190,9 +199,12 @@ export function PersonManagementPage() { const handleBatchDelete = async () => { try { const result = await batchDeletePersons(Array.from(selectedPersons)) + if (!result.success) { + throw new Error(result.error) + } toast({ title: '批量删除完成', - description: result.message, + description: result.data.message, }) setSelectedPersons(new Set()) setBatchDeleteDialogOpen(false) @@ -858,7 +870,10 @@ function PersonEditDialog({ try { setSaving(true) - await updatePerson(person.person_id, formData) + const result = await updatePerson(person.person_id, formData) + if (!result.success) { + throw new Error(result.error) + } toast({ title: '保存成功', description: '人物信息已更新', diff --git a/dashboard/src/routes/resource/expression.tsx b/dashboard/src/routes/resource/expression.tsx index d211e732..dbb0795c 100644 --- a/dashboard/src/routes/resource/expression.tsx +++ b/dashboard/src/routes/resource/expression.tsx @@ -72,13 +72,21 @@ export function ExpressionManagementPage() { const loadExpressions = async () => { try { setLoading(true) - const response = await getExpressionList({ + const result = await getExpressionList({ page, page_size: pageSize, search: search || undefined, }) - setExpressions(response.data) - setTotal(response.total) + if (result.success) { + setExpressions(result.data.data) + setTotal(result.data.total) + } else { + toast({ + title: '加载失败', + description: result.error, + variant: 'destructive', + }) + } } catch (error) { toast({ title: '加载失败', @@ -93,9 +101,11 @@ export function ExpressionManagementPage() { // 加载统计数据 const loadStats = async () => { try { - const response = await getExpressionStats() - if (response?.data) { - setStats(response.data) + const result = await getExpressionStats() + if (result.success) { + setStats(result.data) + } else { + console.error('加载统计数据失败:', result.error) } } catch (error) { console.error('加载统计数据失败:', error) @@ -105,28 +115,30 @@ export function ExpressionManagementPage() { // 加载审核统计 const loadReviewStats = async () => { try { - const data = await getReviewStats() - setUncheckedCount(data.unchecked) + const result = await getReviewStats() + if (result.success) { + setUncheckedCount(result.data.unchecked) + } } catch (error) { console.error('加载审核统计失败:', error) } } - // 加载聊天列表 + // 加载聚天列表 const loadChatList = async () => { try { - const response = await getChatList() - if (response?.data) { - setChatList(response.data) - // 构建聊天ID到名称的映射 + const result = await getChatList() + if (result.success) { + setChatList(result.data) + // 构建聚天ID到名称的映射 const nameMap = new Map() - response.data.forEach((chat) => { + result.data.forEach((chat: ChatInfo) => { nameMap.set(chat.chat_id, chat.chat_name) }) setChatNameMap(nameMap) } } catch (error) { - console.error('加载聊天列表失败:', error) + console.error('加载聚天列表失败:', error) } } @@ -147,9 +159,17 @@ export function ExpressionManagementPage() { // 查看详情 const handleViewDetail = async (expression: Expression) => { try { - const response = await getExpressionDetail(expression.id) - setSelectedExpression(response.data) - setIsDetailDialogOpen(true) + const result = await getExpressionDetail(expression.id) + if (result.success) { + setSelectedExpression(result.data) + setIsDetailDialogOpen(true) + } else { + toast({ + title: '加载详情失败', + description: result.error, + variant: 'destructive', + }) + } } catch (error) { toast({ title: '加载详情失败', @@ -168,14 +188,22 @@ export function ExpressionManagementPage() { // 删除表达方式 const handleDelete = async (expression: Expression) => { try { - await deleteExpression(expression.id) - toast({ - title: '删除成功', - description: `已删除表达方式: ${expression.situation}`, - }) - setDeleteConfirmExpression(null) - loadExpressions() - loadStats() + const result = await deleteExpression(expression.id) + if (result.success) { + toast({ + title: '删除成功', + description: `已删除表达方式: ${expression.situation}`, + }) + setDeleteConfirmExpression(null) + loadExpressions() + loadStats() + } else { + toast({ + title: '删除失败', + description: result.error, + variant: 'destructive', + }) + } } catch (error) { toast({ title: '删除失败', @@ -208,15 +236,23 @@ export function ExpressionManagementPage() { // 批量删除 const handleBatchDelete = async () => { try { - await batchDeleteExpressions(Array.from(selectedIds)) - toast({ - title: '批量删除成功', - description: `已删除 ${selectedIds.size} 个表达方式`, - }) - setSelectedIds(new Set()) - setIsBatchDeleteDialogOpen(false) - loadExpressions() - loadStats() + const result = await batchDeleteExpressions(Array.from(selectedIds)) + if (result.success) { + toast({ + title: '批量删除成功', + description: `已删除 ${selectedIds.size} 个表达方式`, + }) + setSelectedIds(new Set()) + setIsBatchDeleteDialogOpen(false) + loadExpressions() + loadStats() + } else { + toast({ + title: '批量删除失败', + description: result.error, + variant: 'destructive', + }) + } } catch (error) { toast({ title: '批量删除失败', @@ -848,7 +884,7 @@ function ExpressionCreateDialog({ if (!formData.situation || !formData.style || !formData.chat_id) { toast({ title: '验证失败', - description: '请填写必填字段:情境、风格和聊天', + description: '请填写必填字段:情境、风格和聚天', variant: 'destructive', }) return @@ -856,18 +892,26 @@ function ExpressionCreateDialog({ try { setSaving(true) - await createExpression(formData) - toast({ - title: '创建成功', - description: '表达方式已创建', - }) - // 重置表单 - setFormData({ - situation: '', - style: '', - chat_id: '', - }) - onSuccess() + const result = await createExpression(formData) + if (result.success) { + toast({ + title: '创建成功', + description: '表达方式已创建', + }) + // 重置表单 + setFormData({ + situation: '', + style: '', + chat_id: '', + }) + onSuccess() + } else { + toast({ + title: '创建失败', + description: result.error, + variant: 'destructive', + }) + } } catch (error) { toast({ title: '创建失败', @@ -988,12 +1032,20 @@ function ExpressionEditDialog({ try { setSaving(true) - await updateExpression(expression.id, formData) - toast({ - title: '保存成功', - description: '表达方式已更新', - }) - onSuccess() + const result = await updateExpression(expression.id, formData) + if (result.success) { + toast({ + title: '保存成功', + description: '表达方式已更新', + }) + onSuccess() + } else { + toast({ + title: '保存失败', + description: result.error, + variant: 'destructive', + }) + } } catch (error) { toast({ title: '保存失败', From d4bfc9591c2fa209e03c0f21acf41659bc13d4d4 Mon Sep 17 00:00:00 2001 From: DrSmoothl <1787882683@qq.com> Date: Sun, 1 Mar 2026 17:43:47 +0800 Subject: [PATCH 10/26] refactor(api): migrate config-api to ApiResponse pattern --- dashboard/src/lib/api-helpers.ts | 2 +- dashboard/src/lib/config-api.ts | 150 ++++-------------- dashboard/src/routes/config/bot.tsx | 73 +++++++-- .../routes/config/bot/hooks/useAutoSave.ts | 7 + dashboard/src/routes/config/model.tsx | 58 ++++++- .../config/model/hooks/useModelAutoSave.ts | 13 +- .../config/model/hooks/useModelFetcher.ts | 6 +- dashboard/src/routes/config/modelProvider.tsx | 133 +++++++++++++--- dashboard/src/types/api.ts | 2 +- 9 files changed, 288 insertions(+), 156 deletions(-) diff --git a/dashboard/src/lib/api-helpers.ts b/dashboard/src/lib/api-helpers.ts index a9e429ce..9ae17ab5 100644 --- a/dashboard/src/lib/api-helpers.ts +++ b/dashboard/src/lib/api-helpers.ts @@ -52,4 +52,4 @@ export function throwIfError(result: ApiResponse): T { return result.data } throw new Error(result.error) -} +} \ No newline at end of file diff --git a/dashboard/src/lib/config-api.ts b/dashboard/src/lib/config-api.ts index 9f818f03..cfb51108 100644 --- a/dashboard/src/lib/config-api.ts +++ b/dashboard/src/lib/config-api.ts @@ -2,146 +2,96 @@ * 配置API客户端 */ +import { parseResponse } from '@/lib/api-helpers' import { fetchWithAuth } from '@/lib/fetch-with-auth' -import type { - ConfigSchema, - ConfigSchemaResponse, - ConfigDataResponse, - ConfigUpdateResponse, -} from '@/types/config-schema' +import type { ApiResponse } from '@/types/api' +import type { ConfigSchema } from '@/types/config-schema' const API_BASE = '/api/webui/config' /** * 获取麦麦主程序配置架构 */ -export async function getBotConfigSchema(): Promise { +export async function getBotConfigSchema(): Promise> { const response = await fetchWithAuth(`${API_BASE}/schema/bot`) - const data: ConfigSchemaResponse = await response.json() - - if (!data.success) { - throw new Error('获取配置架构失败') - } - - return data.schema + return parseResponse(response) } /** * 获取模型配置架构 */ -export async function getModelConfigSchema(): Promise { +export async function getModelConfigSchema(): Promise> { const response = await fetchWithAuth(`${API_BASE}/schema/model`) - const data: ConfigSchemaResponse = await response.json() - - if (!data.success) { - throw new Error('获取模型配置架构失败') - } - - return data.schema + return parseResponse(response) } /** * 获取指定配置节的架构 */ -export async function getConfigSectionSchema(sectionName: string): Promise { +export async function getConfigSectionSchema(sectionName: string): Promise> { const response = await fetchWithAuth(`${API_BASE}/schema/section/${sectionName}`) - const data: ConfigSchemaResponse = await response.json() - - if (!data.success) { - throw new Error(`获取配置节 ${sectionName} 架构失败`) - } - - return data.schema + return parseResponse(response) } /** * 获取麦麦主程序配置数据 */ -export async function getBotConfig(): Promise> { +export async function getBotConfig(): Promise>> { const response = await fetchWithAuth(`${API_BASE}/bot`) - const data: ConfigDataResponse = await response.json() - - if (!data.success) { - throw new Error('获取配置数据失败') - } - - return data.config + return parseResponse>(response) } /** * 获取模型配置数据 */ -export async function getModelConfig(): Promise> { +export async function getModelConfig(): Promise>> { const response = await fetchWithAuth(`${API_BASE}/model`) - const data: ConfigDataResponse = await response.json() - - if (!data.success) { - throw new Error('获取模型配置数据失败') - } - - return data.config + return parseResponse>(response) } /** * 更新麦麦主程序配置 */ -export async function updateBotConfig(config: Record): Promise { +export async function updateBotConfig( + config: Record +): Promise>> { const response = await fetchWithAuth(`${API_BASE}/bot`, { method: 'POST', body: JSON.stringify(config), }) - - const data: ConfigUpdateResponse = await response.json() - - if (!data.success) { - throw new Error(data.message || '保存配置失败') - } + return parseResponse>(response) } /** * 获取麦麦主程序配置的原始 TOML 内容 */ -export async function getBotConfigRaw(): Promise { +export async function getBotConfigRaw(): Promise> { const response = await fetchWithAuth(`${API_BASE}/bot/raw`) - const data: { success: boolean; content: string } = await response.json() - - if (!data.success) { - throw new Error('获取配置源代码失败') - } - - return data.content + return parseResponse(response) } /** * 更新麦麦主程序配置(原始 TOML 内容) */ -export async function updateBotConfigRaw(rawContent: string): Promise { +export async function updateBotConfigRaw(rawContent: string): Promise>> { const response = await fetchWithAuth(`${API_BASE}/bot/raw`, { method: 'POST', body: JSON.stringify({ raw_content: rawContent }), }) - - const data: ConfigUpdateResponse = await response.json() - - if (!data.success) { - throw new Error(data.message || '保存配置失败') - } + return parseResponse>(response) } /** * 更新模型配置 */ -export async function updateModelConfig(config: Record): Promise { +export async function updateModelConfig( + config: Record +): Promise>> { const response = await fetchWithAuth(`${API_BASE}/model`, { method: 'POST', body: JSON.stringify(config), }) - - const data: ConfigUpdateResponse = await response.json() - - if (!data.success) { - throw new Error(data.message || '保存配置失败') - } + return parseResponse>(response) } /** @@ -150,17 +100,12 @@ export async function updateModelConfig(config: Record): Promis export async function updateBotConfigSection( sectionName: string, sectionData: unknown -): Promise { +): Promise>> { const response = await fetchWithAuth(`${API_BASE}/bot/section/${sectionName}`, { method: 'POST', body: JSON.stringify(sectionData), }) - - const data: ConfigUpdateResponse = await response.json() - - if (!data.success) { - throw new Error(data.message || `保存配置节 ${sectionName} 失败`) - } + return parseResponse>(response) } /** @@ -169,17 +114,12 @@ export async function updateBotConfigSection( export async function updateModelConfigSection( sectionName: string, sectionData: unknown -): Promise { +): Promise>> { const response = await fetchWithAuth(`${API_BASE}/model/section/${sectionName}`, { method: 'POST', body: JSON.stringify(sectionData), }) - - const data: ConfigUpdateResponse = await response.json() - - if (!data.success) { - throw new Error(data.message || `保存配置节 ${sectionName} 失败`) - } + return parseResponse>(response) } /** @@ -211,28 +151,14 @@ export async function fetchProviderModels( providerName: string, parser: 'openai' | 'gemini' = 'openai', endpoint: string = '/models' -): Promise { +): Promise> { const params = new URLSearchParams({ provider_name: providerName, parser, endpoint, }) - const response = await fetchWithAuth(`/api/webui/models/list?${params}`) - - // 处理非 2xx 响应 - if (!response.ok) { - const errorData = await response.json().catch(() => ({})) - throw new Error(errorData.detail || `获取模型列表失败 (${response.status})`) - } - - const data: FetchModelsResponse = await response.json() - - if (!data.success) { - throw new Error('获取模型列表失败') - } - - return data.models + return parseResponse(response) } /** @@ -250,20 +176,14 @@ export interface TestConnectionResult { * 测试提供商连接状态(通过提供商名称) * @param providerName 提供商名称 */ -export async function testProviderConnection(providerName: string): Promise { +export async function testProviderConnection( + providerName: string +): Promise> { const params = new URLSearchParams({ provider_name: providerName, }) - const response = await fetchWithAuth(`/api/webui/models/test-connection-by-name?${params}`, { method: 'POST', }) - - // 处理非 2xx 响应 - if (!response.ok) { - const errorData = await response.json().catch(() => ({})) - throw new Error(errorData.detail || `测试连接失败 (${response.status})`) - } - - return await response.json() + return parseResponse(response) } diff --git a/dashboard/src/routes/config/bot.tsx b/dashboard/src/routes/config/bot.tsx index 72cb5783..ee923f9f 100644 --- a/dashboard/src/routes/config/bot.tsx +++ b/dashboard/src/routes/config/bot.tsx @@ -262,7 +262,16 @@ function BotConfigPageContent() { // 加载源代码 const loadSourceCode = useCallback(async () => { try { - const raw = await getBotConfigRaw() + const result = await getBotConfigRaw() + if (!result.success) { + toast({ + variant: 'destructive', + title: '加载失败', + description: result.error, + }) + return + } + const raw = result.data // 将 TOML 基本字符串中的转义序列转换为实际字符以便在编辑器中正确显示 // 使用正则表达式只处理双引号字符串内的转义序列,不影响单引号字符串 const unescaped = raw.replace(/"([^"]*)"/g, (_match, content) => { @@ -289,8 +298,17 @@ function BotConfigPageContent() { const loadConfig = useCallback(async () => { try { setLoading(true) - const config = await getBotConfig() - parseAndSetConfig(config) + const result = await getBotConfig() + if (!result.success) { + toast({ + title: '加载失败', + description: result.error, + variant: 'destructive', + }) + setLoading(false) + return + } + parseAndSetConfig(result.data) setHasUnsavedChanges(false) initialLoadRef.current = false @@ -382,7 +400,18 @@ function BotConfigPageContent() { .replace(/\r/g, '\\r') // 回车符 return `"${encoded}"` }) - await updateBotConfigRaw(escaped) + const result = await updateBotConfigRaw(escaped) + if (!result.success) { + setHasTomlError(true) + const errorMsg = result.error + setTomlErrorMessage(errorMsg) + toast({ + variant: 'destructive', + title: '保存失败', + description: errorMsg, + }) + return + } setHasUnsavedChanges(false) setHasTomlError(false) setTomlErrorMessage('') @@ -423,8 +452,16 @@ function BotConfigPageContent() { } else { // 切换回可视化时,直接重新加载配置但不显示全局 loading try { - const config = await getBotConfig() - parseAndSetConfig(config) + const result = await getBotConfig() + if (!result.success) { + toast({ + title: '加载失败', + description: result.error, + variant: 'destructive', + }) + return + } + parseAndSetConfig(result.data) setHasUnsavedChanges(false) } catch (error) { console.error('加载配置失败:', error) @@ -444,7 +481,16 @@ function BotConfigPageContent() { // 取消待处理的自动保存 cancelPendingAutoSave() - await updateBotConfig(buildFullConfig()) + const result = await updateBotConfig(buildFullConfig()) + if (!result.success) { + toast({ + title: '保存失败', + description: result.error, + variant: 'destructive', + }) + setSaving(false) + return + } setHasUnsavedChanges(false) toast({ title: '保存成功', @@ -474,7 +520,16 @@ function BotConfigPageContent() { // 取消待处理的自动保存 cancelPendingAutoSave() - await updateBotConfig(buildFullConfig()) + const result = await updateBotConfig(buildFullConfig()) + if (!result.success) { + toast({ + title: '保存失败', + description: result.error, + variant: 'destructive', + }) + setSaving(false) + return + } setHasUnsavedChanges(false) toast({ title: '保存成功', @@ -759,4 +814,4 @@ function BotConfigPageContent() {
    ) -} \ No newline at end of file +} diff --git a/dashboard/src/routes/config/bot/hooks/useAutoSave.ts b/dashboard/src/routes/config/bot/hooks/useAutoSave.ts index 27bfbb70..b6871011 100644 --- a/dashboard/src/routes/config/bot/hooks/useAutoSave.ts +++ b/dashboard/src/routes/config/bot/hooks/useAutoSave.ts @@ -187,6 +187,13 @@ export function useAutoSave( const saveSection = useCallback( async (sectionName: ConfigSectionName, sectionData: unknown) => { try { + setAutoSaving(true) + const result = await updateBotConfigSection(sectionName, sectionData) + if (!result.success) { + throw new Error(result.error) + } + setHasUnsavedChanges(false) + onSaveSuccess?.() setAutoSaving(true) await updateBotConfigSection(sectionName, sectionData) setHasUnsavedChanges(false) diff --git a/dashboard/src/routes/config/model.tsx b/dashboard/src/routes/config/model.tsx index ddba7681..8d39dc1c 100644 --- a/dashboard/src/routes/config/model.tsx +++ b/dashboard/src/routes/config/model.tsx @@ -179,7 +179,17 @@ function ModelConfigPageContent() { const loadConfig = useCallback(async () => { try { setLoading(true) - const config = await getModelConfig() + const result = await getModelConfig() + if (!result.success) { + toast({ + title: '加载失败', + description: result.error, + variant: 'destructive', + }) + setLoading(false) + return + } + const config = result.data const modelList = (config.models as ModelInfo[]) || [] setModels(modelList) @@ -288,11 +298,30 @@ function ModelConfigPageContent() { try { setSaving(true) clearAutoSaveTimers() - const config = await getModelConfig() + const resultGet = await getModelConfig() + if (!resultGet.success) { + toast({ + title: '保存失败', + description: resultGet.error, + variant: 'destructive', + }) + setSaving(false) + return + } + const config = resultGet.data // 清理每个模型中的 null 值 config.models = models.map(cleanModelForSave) config.model_task_config = taskConfig - await updateModelConfig(config) + const resultUpdate = await updateModelConfig(config) + if (!resultUpdate.success) { + toast({ + title: '保存失败', + description: resultUpdate.error, + variant: 'destructive', + }) + setSaving(false) + return + } setHasUnsavedChanges(false) toast({ title: '保存成功', @@ -318,11 +347,30 @@ function ModelConfigPageContent() { // 先取消自动保存定时器 clearAutoSaveTimers() - const config = await getModelConfig() + const resultGet = await getModelConfig() + if (!resultGet.success) { + toast({ + title: '保存失败', + description: resultGet.error, + variant: 'destructive', + }) + setSaving(false) + return + } + const config = resultGet.data // 清理每个模型中的 null 值 config.models = models.map(cleanModelForSave) config.model_task_config = taskConfig - await updateModelConfig(config) + const resultUpdate = await updateModelConfig(config) + if (!resultUpdate.success) { + toast({ + title: '保存失败', + description: resultUpdate.error, + variant: 'destructive', + }) + setSaving(false) + return + } setHasUnsavedChanges(false) toast({ title: '保存成功', diff --git a/dashboard/src/routes/config/model/hooks/useModelAutoSave.ts b/dashboard/src/routes/config/model/hooks/useModelAutoSave.ts index fdb2d57a..3bec158a 100644 --- a/dashboard/src/routes/config/model/hooks/useModelAutoSave.ts +++ b/dashboard/src/routes/config/model/hooks/useModelAutoSave.ts @@ -3,6 +3,7 @@ * 监听 models 和 taskConfig 变化,自动保存到服务器 */ import { useRef, useEffect, useCallback } from 'react' +import type { RefObject } from 'react' import { updateModelConfigSection } from '@/lib/config-api' import type { ModelInfo, ModelTaskConfig } from '../types' @@ -23,7 +24,7 @@ interface UseModelAutoSaveReturn { /** 清除所有待执行的保存定时器 */ clearTimers: () => void /** 初始加载状态标记引用 (用于设置初始加载完成) */ - initialLoadRef: React.MutableRefObject + initialLoadRef: RefObject } /** @@ -84,7 +85,10 @@ export function useModelAutoSave( onSavingChange?.(true) // 清理每个模型中的 null 值 const cleanedModels = newModels.map(cleanModelForSave) - await updateModelConfigSection('models', cleanedModels) + const result = await updateModelConfigSection('models', cleanedModels) + if (!result.success) { + throw new Error(result.error) + } onUnsavedChange?.(false) } catch (error) { console.error('自动保存模型列表失败:', error) @@ -98,7 +102,10 @@ export function useModelAutoSave( const autoSaveTaskConfig = useCallback(async (newTaskConfig: ModelTaskConfig) => { try { onSavingChange?.(true) - await updateModelConfigSection('model_task_config', newTaskConfig) + const result = await updateModelConfigSection('model_task_config', newTaskConfig) + if (!result.success) { + throw new Error(result.error) + } onUnsavedChange?.(false) } catch (error) { console.error('自动保存任务配置失败:', error) diff --git a/dashboard/src/routes/config/model/hooks/useModelFetcher.ts b/dashboard/src/routes/config/model/hooks/useModelFetcher.ts index 2073b95d..073c6bfa 100644 --- a/dashboard/src/routes/config/model/hooks/useModelFetcher.ts +++ b/dashboard/src/routes/config/model/hooks/useModelFetcher.ts @@ -88,11 +88,15 @@ export function useModelFetcher(options: UseModelFetcherOptions): UseModelFetche setModelFetchError(null) try { - const models = await fetchProviderModels( + const result = await fetchProviderModels( providerName, template.modelFetcher.parser, template.modelFetcher.endpoint ) + if (!result.success) { + throw new Error(result.error) + } + const models = result.data setAvailableModels(models) // 更新缓存 modelListCache.set(cacheKey, { models, timestamp: Date.now() }) diff --git a/dashboard/src/routes/config/modelProvider.tsx b/dashboard/src/routes/config/modelProvider.tsx index 9dd675e4..d42f8ba0 100644 --- a/dashboard/src/routes/config/modelProvider.tsx +++ b/dashboard/src/routes/config/modelProvider.tsx @@ -206,7 +206,17 @@ function ModelProviderConfigPageContent() { const loadConfig = async () => { try { setLoading(true) - const config = await getModelConfig() + const result = await getModelConfig() + if (!result.success) { + toast({ + title: '加载失败', + description: result.error, + variant: 'destructive', + }) + setLoading(false) + return + } + const config = result.data setProviders((config.api_providers as APIProvider[]) || []) setHasUnsavedChanges(false) initialLoadRef.current = false @@ -246,7 +256,17 @@ function ModelProviderConfigPageContent() { return } - const config = await getModelConfig() + const resultGet = await getModelConfig() + if (!resultGet.success) { + toast({ + title: '保存失败', + description: resultGet.error, + variant: 'destructive', + }) + setSaving(false) + return + } + const config = resultGet.data // 获取所有有效的 provider 名称 const validProviderNames = new Set(cleanedProviders.map(p => p.name)) @@ -260,7 +280,16 @@ function ModelProviderConfigPageContent() { config.api_providers = cleanedProviders config.models = filteredModels - await updateModelConfig(config) + const resultUpdate = await updateModelConfig(config) + if (!resultUpdate.success) { + toast({ + title: '保存失败', + description: resultUpdate.error, + variant: 'destructive', + }) + setSaving(false) + return + } setHasUnsavedChanges(false) toast({ title: '保存成功', @@ -284,7 +313,12 @@ function ModelProviderConfigPageContent() { context: 'auto' | 'manual' | 'restart' = 'auto' ) => { try { - const config = await getModelConfig() + const result = await getModelConfig() + if (!result.success) { + console.error('加载配置失败:', result.error) + return { shouldProceed: true, providers: newProviders } + } + const config = result.data const oldProviderNames = new Set(providers.map(p => p.name)) const newProviderNames = new Set(newProviders.map(p => p.name)) @@ -334,7 +368,17 @@ function ModelProviderConfigPageContent() { setDeleteConfirmState(prev => ({ ...prev, isOpen: false })) - const config = await getModelConfig() + const resultGet = await getModelConfig() + if (!resultGet.success) { + toast({ + title: '加载失败', + description: resultGet.error, + variant: 'destructive', + }) + savingFlag(false) + return + } + const config = resultGet.data // 清理 providers 数据 const cleanedProviders = deleteConfirmState.pendingProviders.map(cleanProviderData) @@ -348,7 +392,7 @@ function ModelProviderConfigPageContent() { return validProviderNames.has(model.api_provider) }) - // 获取被删除的模型名称 + // 获取被削除的模型名称 const deletedModelNames = new Set( deleteConfirmState.affectedModels.map((m: any) => m.name) ) @@ -371,7 +415,16 @@ function ModelProviderConfigPageContent() { config.models = filteredModels config.model_task_config = modelTaskConfig - await updateModelConfig(config) + const resultUpdate = await updateModelConfig(config) + if (!resultUpdate.success) { + toast({ + title: '保存失败', + description: resultUpdate.error, + variant: 'destructive', + }) + savingFlag(false) + return + } // 更新本地状态 setProviders(deleteConfirmState.pendingProviders) @@ -449,7 +502,17 @@ function ModelProviderConfigPageContent() { setAutoSaving(true) // 清理 providers 数据:将 null 值转换为默认值 const cleanedProviders = newProviders.map(cleanProviderData) - await updateModelConfigSection('api_providers', cleanedProviders) + const result = await updateModelConfigSection('api_providers', cleanedProviders) + if (!result.success) { + console.error('自动保存失败:', result.error) + toast({ + title: '自动保存失败', + description: result.error, + variant: 'destructive', + }) + setHasUnsavedChanges(true) + return + } setHasUnsavedChanges(false) } catch (error) { console.error('自动保存失败:', error) @@ -509,7 +572,17 @@ function ModelProviderConfigPageContent() { return } - const config = await getModelConfig() + const resultGet = await getModelConfig() + if (!resultGet.success) { + toast({ + title: '保存失败', + description: resultGet.error, + variant: 'destructive', + }) + setSaving(false) + return + } + const config = resultGet.data // 获取所有有效的 provider 名称 const validProviderNames = new Set(cleanedProviders.map(p => p.name)) @@ -519,12 +592,12 @@ function ModelProviderConfigPageContent() { const filteredModels = originalModels.filter((model: any) => { const isValid = validProviderNames.has(model.api_provider) if (!isValid) { - console.warn(`模型 "${model.name}" 引用了已删除的提供商 "${model.api_provider}",将被移除`) + console.warn(`模型 "${model.name}" 引用了已删除的提供商 "${model.api_provider}"、将被移除`) } return isValid }) - // 如果有模型被移除,显示警告 + // 如果有模型被移除、显示警告 if (originalModels.length !== filteredModels.length) { const removedCount = originalModels.length - filteredModels.length toast({ @@ -539,7 +612,16 @@ function ModelProviderConfigPageContent() { config.models = filteredModels console.log('完整配置数据:', config) - await updateModelConfig(config) + const resultUpdate = await updateModelConfig(config) + if (!resultUpdate.success) { + toast({ + title: '保存失败', + description: resultUpdate.error, + variant: 'destructive', + }) + setSaving(false) + return + } setHasUnsavedChanges(false) toast({ title: '保存成功', @@ -804,31 +886,40 @@ function ModelProviderConfigPageContent() { try { const result = await testProviderConnection(providerName) - setTestResults(prev => new Map(prev).set(providerName, result)) - + if (!result.success) { + toast({ + title: '测试失败', + description: result.error, + variant: 'destructive', + }) + return + } + const testResult = result.data + setTestResults(prev => new Map(prev).set(providerName, testResult)) + // 显示结果 toast - if (result.network_ok) { - if (result.api_key_valid === true) { + if (testResult.network_ok) { + if (testResult.api_key_valid === true) { toast({ title: '连接正常', - description: `${providerName} 网络连接正常,API Key 有效 (${result.latency_ms}ms)`, + description: `${providerName} 网络连接正常、API Key 有效 (${testResult.latency_ms}ms)`, }) - } else if (result.api_key_valid === false) { + } else if (testResult.api_key_valid === false) { toast({ title: '连接正常但 Key 无效', - description: `${providerName} 网络连接正常,但 API Key 无效或已过期`, + description: `${providerName} 网络连接正常、但 API Key 无效或已过期`, variant: 'destructive', }) } else { toast({ title: '网络连接正常', - description: `${providerName} 可以访问 (${result.latency_ms}ms)`, + description: `${providerName} 可以访问 (${testResult.latency_ms}ms)`, }) } } else { toast({ title: '连接失败', - description: result.error || '无法连接到提供商', + description: testResult.error || '无法连接到提供商', variant: 'destructive', }) } diff --git a/dashboard/src/types/api.ts b/dashboard/src/types/api.ts index 8727727f..03d11739 100644 --- a/dashboard/src/types/api.ts +++ b/dashboard/src/types/api.ts @@ -5,4 +5,4 @@ export type ApiResponse = | { success: true; data: T } - | { success: false; error: string } + | { success: false; error: string } \ No newline at end of file From dc7e037582c5044d6c9cf464e227ebe2122eb170 Mon Sep 17 00:00:00 2001 From: DrSmoothl <1787882683@qq.com> Date: Sun, 1 Mar 2026 18:06:25 +0800 Subject: [PATCH 11/26] refactor(dashboard): migrate plugin-api HTTP functions to ApiResponse pattern - Migrate 14 HTTP functions in plugin-api.ts to return ApiResponse - fetchPluginList, checkGitStatus, getMaimaiVersion - getInstalledPlugins, installPlugin, uninstallPlugin, updatePlugin - getPluginConfigSchema, getPluginConfig, getPluginConfigRaw - updatePluginConfig, updatePluginConfigRaw, resetPluginConfig, togglePlugin - Update 3 caller files to handle ApiResponse pattern: - plugins.tsx: 5 function calls updated - plugin-config.tsx: 5 function calls updated - plugin-detail.tsx: 5 function calls updated - All callers now check .success before accessing .data - Preserve WebSocket and utility functions unchanged - Build verification: npm run build succeeds with 0 errors --- dashboard/src/lib/plugin-api.ts | 390 +++++++++++-------------- dashboard/src/routes/plugin-config.tsx | 76 ++++- dashboard/src/routes/plugin-detail.tsx | 112 +++++-- dashboard/src/routes/plugins.tsx | 137 +++++++-- 4 files changed, 446 insertions(+), 269 deletions(-) diff --git a/dashboard/src/lib/plugin-api.ts b/dashboard/src/lib/plugin-api.ts index a0f49ed5..a866bf6d 100644 --- a/dashboard/src/lib/plugin-api.ts +++ b/dashboard/src/lib/plugin-api.ts @@ -1,6 +1,8 @@ -import { fetchWithAuth, getAuthHeaders } from '@/lib/fetch-with-auth' +import type { ApiResponse } from '@/types/api' import type { PluginInfo } from '@/types/plugin' +import { fetchWithAuth, getAuthHeaders } from '@/lib/fetch-with-auth' +import { parseResponse } from './api-helpers' import { createReconnectingWebSocket } from './ws-utils' /** @@ -106,124 +108,118 @@ interface PluginApiResponse { /** * 从远程获取插件列表(通过后端代理避免 CORS) */ -export async function fetchPluginList(): Promise { - try { - // 通过后端 API 获取 Raw 文件 - const response = await fetchWithAuth('/api/webui/plugins/fetch-raw', { - method: 'POST', - - body: JSON.stringify({ - owner: PLUGIN_REPO_OWNER, - repo: PLUGIN_REPO_NAME, - branch: PLUGIN_REPO_BRANCH, - file_path: PLUGIN_DETAILS_FILE - }) +export async function fetchPluginList(): Promise> { + const response = await fetchWithAuth('/api/webui/plugins/fetch-raw', { + method: 'POST', + body: JSON.stringify({ + owner: PLUGIN_REPO_OWNER, + repo: PLUGIN_REPO_NAME, + branch: PLUGIN_REPO_BRANCH, + file_path: PLUGIN_DETAILS_FILE }) - - if (!response.ok) { - throw new Error(`HTTP error! status: ${response.status}`) + }) + + const apiResult = await parseResponse<{ success: boolean; data: string; error?: string }>(response) + + if (!apiResult.success) { + return apiResult + } + + const result = apiResult.data + if (!result.success || !result.data) { + return { + success: false, + error: result.error || '获取插件列表失败' } - - const result = await response.json() - - // 检查后端返回的结果 - if (!result.success || !result.data) { - throw new Error(result.error || '获取插件列表失败') - } - - const data: PluginApiResponse[] = JSON.parse(result.data) - - // 转换为 PluginInfo 格式,并过滤掉无效数据 - const pluginList = data - .filter(item => { - // 验证必需字段 - if (!item?.id || !item?.manifest) { - console.warn('跳过无效插件数据:', item) - return false - } - if (!item.manifest.name || !item.manifest.version) { - console.warn('跳过缺少必需字段的插件:', item.id) - return false - } - return true - }) - .map((item) => ({ - id: item.id, - manifest: { - manifest_version: item.manifest.manifest_version || 1, - name: item.manifest.name, - version: item.manifest.version, - description: item.manifest.description || '', - author: item.manifest.author || { name: 'Unknown' }, - license: item.manifest.license || 'Unknown', - host_application: item.manifest.host_application || { min_version: '0.0.0' }, - homepage_url: item.manifest.homepage_url, - repository_url: item.manifest.repository_url, - keywords: item.manifest.keywords || [], - categories: item.manifest.categories || [], - default_locale: item.manifest.default_locale || 'zh-CN', - locales_path: item.manifest.locales_path, - }, - // 默认值,这些信息可能需要从其他 API 获取 - downloads: 0, - rating: 0, - review_count: 0, - installed: false, - published_at: new Date().toISOString(), - updated_at: new Date().toISOString(), - })) - - return pluginList - } catch (error) { - console.error('Failed to fetch plugin list:', error) - throw error + } + + const data: PluginApiResponse[] = JSON.parse(result.data) + + const pluginList = data + .filter(item => { + if (!item?.id || !item?.manifest) { + console.warn('跳过无效插件数据:', item) + return false + } + if (!item.manifest.name || !item.manifest.version) { + console.warn('跳过缺少必需字段的插件:', item.id) + return false + } + return true + }) + .map((item) => ({ + id: item.id, + manifest: { + manifest_version: item.manifest.manifest_version || 1, + name: item.manifest.name, + version: item.manifest.version, + description: item.manifest.description || '', + author: item.manifest.author || { name: 'Unknown' }, + license: item.manifest.license || 'Unknown', + host_application: item.manifest.host_application || { min_version: '0.0.0' }, + homepage_url: item.manifest.homepage_url, + repository_url: item.manifest.repository_url, + keywords: item.manifest.keywords || [], + categories: item.manifest.categories || [], + default_locale: item.manifest.default_locale || 'zh-CN', + locales_path: item.manifest.locales_path, + }, + downloads: 0, + rating: 0, + review_count: 0, + installed: false, + published_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + })) + + return { + success: true, + data: pluginList } } /** * 检查本机 Git 安装状态 */ -export async function checkGitStatus(): Promise { - try { - const response = await fetchWithAuth('/api/webui/plugins/git-status') - - if (!response.ok) { - throw new Error(`HTTP error! status: ${response.status}`) - } - - return await response.json() - } catch (error) { - console.error('Failed to check Git status:', error) - // 返回未安装状态 +export async function checkGitStatus(): Promise> { + const response = await fetchWithAuth('/api/webui/plugins/git-status') + + const apiResult = await parseResponse(response) + + if (!apiResult.success) { return { - installed: false, - error: '无法检测 Git 安装状态' + success: true, + data: { + installed: false, + error: '无法检测 Git 安装状态' + } } } + + return apiResult } /** * 获取麦麦版本信息 */ -export async function getMaimaiVersion(): Promise { - try { - const response = await fetchWithAuth('/api/webui/plugins/version') - - if (!response.ok) { - throw new Error(`HTTP error! status: ${response.status}`) - } - - return await response.json() - } catch (error) { - console.error('Failed to get Maimai version:', error) - // 返回默认版本 +export async function getMaimaiVersion(): Promise> { + const response = await fetchWithAuth('/api/webui/plugins/version') + + const apiResult = await parseResponse(response) + + if (!apiResult.success) { return { - version: '0.0.0', - version_major: 0, - version_minor: 0, - version_patch: 0 + success: true, + data: { + version: '0.0.0', + version_major: 0, + version_minor: 0, + version_patch: 0 + } } } + + return apiResult } /** @@ -318,26 +314,31 @@ export async function connectPluginProgressWebSocket( /** * 获取已安装插件列表 */ -export async function getInstalledPlugins(): Promise { - try { - const response = await fetchWithAuth('/api/webui/plugins/installed', { - headers: getAuthHeaders() - }) - - if (!response.ok) { - throw new Error(`HTTP error! status: ${response.status}`) +export async function getInstalledPlugins(): Promise> { + const response = await fetchWithAuth('/api/webui/plugins/installed', { + headers: getAuthHeaders() + }) + + const apiResult = await parseResponse<{ success: boolean; plugins?: InstalledPlugin[]; message?: string }>(response) + + if (!apiResult.success) { + return { + success: true, + data: [] } - - const result = await response.json() - - if (!result.success) { - throw new Error(result.message || '获取已安装插件列表失败') + } + + const result = apiResult.data + if (!result.success) { + return { + success: true, + data: [] } - - return result.plugins || [] - } catch (error) { - console.error('Failed to get installed plugins:', error) - return [] + } + + return { + success: true, + data: result.plugins || [] } } @@ -363,10 +364,9 @@ export function getInstalledPluginVersion(pluginId: string, installedPlugins: In /** * 安装插件 */ -export async function installPlugin(pluginId: string, repositoryUrl: string, branch: string = 'main'): Promise<{ success: boolean; message: string }> { +export async function installPlugin(pluginId: string, repositoryUrl: string, branch: string = 'main'): Promise> { const response = await fetchWithAuth('/api/webui/plugins/install', { method: 'POST', - body: JSON.stringify({ plugin_id: pluginId, repository_url: repositoryUrl, @@ -374,41 +374,29 @@ export async function installPlugin(pluginId: string, repositoryUrl: string, bra }) }) - if (!response.ok) { - const error = await response.json() - throw new Error(error.detail || '安装失败') - } - - return await response.json() + return await parseResponse<{ success: boolean; message: string }>(response) } /** * 卸载插件 */ -export async function uninstallPlugin(pluginId: string): Promise<{ success: boolean; message: string }> { +export async function uninstallPlugin(pluginId: string): Promise> { const response = await fetchWithAuth('/api/webui/plugins/uninstall', { method: 'POST', - body: JSON.stringify({ plugin_id: pluginId }) }) - if (!response.ok) { - const error = await response.json() - throw new Error(error.detail || '卸载失败') - } - - return await response.json() + return await parseResponse<{ success: boolean; message: string }>(response) } /** * 更新插件 */ -export async function updatePlugin(pluginId: string, repositoryUrl: string, branch: string = 'main'): Promise<{ success: boolean; message: string; old_version: string; new_version: string }> { +export async function updatePlugin(pluginId: string, repositoryUrl: string, branch: string = 'main'): Promise> { const response = await fetchWithAuth('/api/webui/plugins/update', { method: 'POST', - body: JSON.stringify({ plugin_id: pluginId, repository_url: repositoryUrl, @@ -416,12 +404,7 @@ export async function updatePlugin(pluginId: string, repositoryUrl: string, bran }) }) - if (!response.ok) { - const error = await response.json() - throw new Error(error.detail || '更新失败') - } - - return await response.json() + return await parseResponse<{ success: boolean; message: string; old_version: string; new_version: string }>(response) } @@ -525,82 +508,85 @@ export interface PluginConfigSchema { /** * 获取插件配置 Schema */ -export async function getPluginConfigSchema(pluginId: string): Promise { +export async function getPluginConfigSchema(pluginId: string): Promise> { const response = await fetchWithAuth(`/api/webui/plugins/config/${pluginId}/schema`, { headers: getAuthHeaders() }) - if (!response.ok) { - const text = await response.text() - try { - const error = JSON.parse(text) - throw new Error(error.detail || '获取配置 Schema 失败') - } catch { - throw new Error(`获取配置 Schema 失败 (${response.status})`) + const apiResult = await parseResponse<{ success: boolean; schema?: PluginConfigSchema; message?: string }>(response) + + if (!apiResult.success) { + return apiResult + } + + const result = apiResult.data + if (!result.success || !result.schema) { + return { + success: false, + error: result.message || '获取配置 Schema 失败' } } - const result = await response.json() - - if (!result.success) { - throw new Error(result.message || '获取配置 Schema 失败') + return { + success: true, + data: result.schema } - - return result.schema } /** * 获取插件当前配置值 */ -export async function getPluginConfig(pluginId: string): Promise> { +export async function getPluginConfig(pluginId: string): Promise>> { const response = await fetchWithAuth(`/api/webui/plugins/config/${pluginId}`, { headers: getAuthHeaders() }) - if (!response.ok) { - const text = await response.text() - try { - const error = JSON.parse(text) - throw new Error(error.detail || '获取配置失败') - } catch { - throw new Error(`获取配置失败 (${response.status})`) + const apiResult = await parseResponse<{ success: boolean; config?: Record; message?: string }>(response) + + if (!apiResult.success) { + return apiResult + } + + const result = apiResult.data + if (!result.success || !result.config) { + return { + success: false, + error: result.message || '获取配置失败' } } - const result = await response.json() - - if (!result.success) { - throw new Error(result.message || '获取配置失败') + return { + success: true, + data: result.config } - - return result.config } /** * 获取插件原始 TOML 配置 */ -export async function getPluginConfigRaw(pluginId: string): Promise { +export async function getPluginConfigRaw(pluginId: string): Promise> { const response = await fetchWithAuth(`/api/webui/plugins/config/${pluginId}/raw`, { headers: getAuthHeaders() }) - if (!response.ok) { - const text = await response.text() - try { - const error = JSON.parse(text) - throw new Error(error.detail || '获取配置失败') - } catch { - throw new Error(`获取配置失败 (${response.status})`) + const apiResult = await parseResponse<{ success: boolean; config?: string; message?: string }>(response) + + if (!apiResult.success) { + return apiResult + } + + const result = apiResult.data + if (!result.success || !result.config) { + return { + success: false, + error: result.message || '获取配置失败' } } - const result = await response.json() - - if (!result.success) { - throw new Error(result.message || '获取配置失败') + return { + success: true, + data: result.config } - - return result.config } /** @@ -609,19 +595,14 @@ export async function getPluginConfigRaw(pluginId: string): Promise { export async function updatePluginConfig( pluginId: string, config: Record -): Promise<{ success: boolean; message: string; note?: string }> { +): Promise> { const response = await fetchWithAuth(`/api/webui/plugins/config/${pluginId}`, { method: 'PUT', headers: getAuthHeaders(), body: JSON.stringify({ config }) }) - if (!response.ok) { - const error = await response.json() - throw new Error(error.detail || '保存配置失败') - } - - return await response.json() + return await parseResponse<{ success: boolean; message: string; note?: string }>(response) } /** @@ -630,19 +611,14 @@ export async function updatePluginConfig( export async function updatePluginConfigRaw( pluginId: string, configToml: string -): Promise<{ success: boolean; message: string; note?: string }> { +): Promise> { const response = await fetchWithAuth(`/api/webui/plugins/config/${pluginId}/raw`, { method: 'PUT', headers: getAuthHeaders(), body: JSON.stringify({ config: configToml }) }) - if (!response.ok) { - const error = await response.json() - throw new Error(error.detail || '保存配置失败') - } - - return await response.json() + return await parseResponse<{ success: boolean; message: string; note?: string }>(response) } /** @@ -650,18 +626,13 @@ export async function updatePluginConfigRaw( */ export async function resetPluginConfig( pluginId: string -): Promise<{ success: boolean; message: string; backup?: string }> { +): Promise> { const response = await fetchWithAuth(`/api/webui/plugins/config/${pluginId}/reset`, { method: 'POST', headers: getAuthHeaders() }) - if (!response.ok) { - const error = await response.json() - throw new Error(error.detail || '重置配置失败') - } - - return await response.json() + return await parseResponse<{ success: boolean; message: string; backup?: string }>(response) } /** @@ -669,16 +640,11 @@ export async function resetPluginConfig( */ export async function togglePlugin( pluginId: string -): Promise<{ success: boolean; enabled: boolean; message: string; note?: string }> { +): Promise> { const response = await fetchWithAuth(`/api/webui/plugins/config/${pluginId}/toggle`, { method: 'POST', headers: getAuthHeaders() }) - if (!response.ok) { - const error = await response.json() - throw new Error(error.detail || '切换状态失败') - } - - return await response.json() + return await parseResponse<{ success: boolean; enabled: boolean; message: string; note?: string }>(response) } diff --git a/dashboard/src/routes/plugin-config.tsx b/dashboard/src/routes/plugin-config.tsx index c6cd4ce8..8c2cff3d 100644 --- a/dashboard/src/routes/plugin-config.tsx +++ b/dashboard/src/routes/plugin-config.tsx @@ -341,16 +341,44 @@ function PluginConfigEditor({ plugin, onBack }: PluginConfigEditorProps) { const loadConfig = useCallback(async () => { setLoading(true) try { - const [schemaData, configData, rawConfigData] = await Promise.all([ + const [schemaResult, configResult, rawResult] = await Promise.all([ getPluginConfigSchema(plugin.id), getPluginConfig(plugin.id), getPluginConfigRaw(plugin.id) ]) - setSchema(schemaData) - setConfig(configData) - setOriginalConfig(JSON.parse(JSON.stringify(configData))) - setSourceCode(rawConfigData) - setOriginalSourceCode(rawConfigData) + + if (!schemaResult.success) { + toast({ + title: '加载配置架构失败', + description: schemaResult.error, + variant: 'destructive' + }) + return + } + + if (!configResult.success) { + toast({ + title: '加载配置数据失败', + description: configResult.error, + variant: 'destructive' + }) + return + } + + if (!rawResult.success) { + toast({ + title: '加载原始配置失败', + description: rawResult.error, + variant: 'destructive' + }) + return + } + + setSchema(schemaResult.data) + setConfig(configResult.data) + setOriginalConfig(JSON.parse(JSON.stringify(configResult.data))) + setSourceCode(rawResult.data) + setOriginalSourceCode(rawResult.data) } catch (error) { toast({ title: '加载配置失败', @@ -433,7 +461,15 @@ function PluginConfigEditor({ plugin, onBack }: PluginConfigEditorProps) { // 重置配置 const handleReset = async () => { try { - await resetPluginConfig(plugin.id) + const resetResult = await resetPluginConfig(plugin.id) + if (!resetResult.success) { + toast({ + title: '重置失败', + description: resetResult.error, + variant: 'destructive' + }) + return + } toast({ title: '配置已重置', description: '下次加载插件时将使用默认配置' @@ -452,10 +488,18 @@ function PluginConfigEditor({ plugin, onBack }: PluginConfigEditorProps) { // 切换启用状态 const handleToggle = async () => { try { - const result = await togglePlugin(plugin.id) + const toggleResult = await togglePlugin(plugin.id) + if (!toggleResult.success) { + toast({ + title: '切换失败', + description: toggleResult.error, + variant: 'destructive' + }) + return + } toast({ - title: result.message, - description: result.note + title: toggleResult.data.message, + description: toggleResult.data.note }) loadConfig() } catch (error) { @@ -723,8 +767,16 @@ function PluginConfigPageContent() { const loadPlugins = async () => { setLoading(true) try { - const data = await getInstalledPlugins() - setPlugins(data) + const installedResult = await getInstalledPlugins() + if (!installedResult.success) { + toast({ + title: '加载插件列表失败', + description: installedResult.error, + variant: 'destructive' + }) + return + } + setPlugins(installedResult.data) } catch (error) { toast({ title: '加载插件列表失败', diff --git a/dashboard/src/routes/plugin-detail.tsx b/dashboard/src/routes/plugin-detail.tsx index fccbdd51..5c6eb1d5 100644 --- a/dashboard/src/routes/plugin-detail.tsx +++ b/dashboard/src/routes/plugin-detail.tsx @@ -131,10 +131,37 @@ export function PluginDetailPage() { getInstalledPlugins(), ]) - setGitStatus(gitStatusResult) - setMaimaiVersion(versionResult) - setIsInstalled(checkPluginInstalled(search.pluginId, installedPlugins)) - setInstalledVersion(getInstalledPluginVersion(search.pluginId, installedPlugins)) + if (!gitStatusResult.success) { + toast({ + title: 'Git 状态检查失败', + description: gitStatusResult.error, + variant: 'destructive', + }) + } else { + setGitStatus(gitStatusResult.data) + } + + if (!versionResult.success) { + toast({ + title: '版本获取失败', + description: versionResult.error, + variant: 'destructive', + }) + } else { + setMaimaiVersion(versionResult.data) + } + + if (!installedPlugins.success) { + toast({ + title: '获取已安装插件失败', + description: installedPlugins.error, + variant: 'destructive', + }) + return + } + + setIsInstalled(checkPluginInstalled(search.pluginId, installedPlugins.data)) + setInstalledVersion(getInstalledPluginVersion(search.pluginId, installedPlugins.data)) } catch (err) { setError(err instanceof Error ? err.message : '加载失败') } finally { @@ -243,7 +270,16 @@ export function PluginDetailPage() { try { setOperating(true) - await installPlugin(plugin.id, plugin.manifest.repository_url || '', 'main') + const installResult = await installPlugin(plugin.id, plugin.manifest.repository_url || '', 'main') + + if (!installResult.success) { + toast({ + title: '安装失败', + description: installResult.error, + variant: 'destructive', + }) + return + } // 记录下载统计 recordPluginDownload(plugin.id).catch((err) => { @@ -256,9 +292,17 @@ export function PluginDetailPage() { }) // 重新加载安装状态 - const installedPlugins = await getInstalledPlugins() - setIsInstalled(checkPluginInstalled(plugin.id, installedPlugins)) - setInstalledVersion(getInstalledPluginVersion(plugin.id, installedPlugins)) + const installedPluginsResult = await getInstalledPlugins() + if (!installedPluginsResult.success) { + toast({ + title: '获取已安装插件失败', + description: installedPluginsResult.error, + variant: 'destructive', + }) + return + } + setIsInstalled(checkPluginInstalled(plugin.id, installedPluginsResult.data)) + setInstalledVersion(getInstalledPluginVersion(plugin.id, installedPluginsResult.data)) } catch (error) { toast({ title: '安装失败', @@ -277,7 +321,16 @@ export function PluginDetailPage() { try { setOperating(true) - await uninstallPlugin(plugin.id) + const uninstallResult = await uninstallPlugin(plugin.id) + + if (!uninstallResult.success) { + toast({ + title: '卸载失败', + description: uninstallResult.error, + variant: 'destructive', + }) + return + } toast({ title: '卸载成功', @@ -285,9 +338,17 @@ export function PluginDetailPage() { }) // 重新加载安装状态 - const installedPlugins = await getInstalledPlugins() - setIsInstalled(checkPluginInstalled(plugin.id, installedPlugins)) - setInstalledVersion(getInstalledPluginVersion(plugin.id, installedPlugins)) + const installedPluginsResult = await getInstalledPlugins() + if (!installedPluginsResult.success) { + toast({ + title: '获取已安装插件失败', + description: installedPluginsResult.error, + variant: 'destructive', + }) + return + } + setIsInstalled(checkPluginInstalled(plugin.id, installedPluginsResult.data)) + setInstalledVersion(getInstalledPluginVersion(plugin.id, installedPluginsResult.data)) } catch (error) { toast({ title: '卸载失败', @@ -306,17 +367,34 @@ export function PluginDetailPage() { try { setOperating(true) - const result = await updatePlugin(plugin.id, plugin.manifest.repository_url || '', 'main') + const updateResult = await updatePlugin(plugin.id, plugin.manifest.repository_url || '', 'main') + + if (!updateResult.success) { + toast({ + title: '更新失败', + description: updateResult.error, + variant: 'destructive', + }) + return + } toast({ title: '更新成功', - description: `${plugin.manifest.name} 已从 ${result.old_version} 更新到 ${result.new_version}`, + description: `${plugin.manifest.name} 已从 ${updateResult.data.old_version} 更新到 ${updateResult.data.new_version}`, }) // 重新加载安装状态 - const installedPlugins = await getInstalledPlugins() - setIsInstalled(checkPluginInstalled(plugin.id, installedPlugins)) - setInstalledVersion(getInstalledPluginVersion(plugin.id, installedPlugins)) + const installedPluginsResult = await getInstalledPlugins() + if (!installedPluginsResult.success) { + toast({ + title: '获取已安装插件失败', + description: installedPluginsResult.error, + variant: 'destructive', + }) + return + } + setIsInstalled(checkPluginInstalled(plugin.id, installedPluginsResult.data)) + setInstalledVersion(getInstalledPluginVersion(plugin.id, installedPluginsResult.data)) } catch (error) { toast({ title: '更新失败', diff --git a/dashboard/src/routes/plugins.tsx b/dashboard/src/routes/plugins.tsx index 9d34ff57..100fae85 100644 --- a/dashboard/src/routes/plugins.tsx +++ b/dashboard/src/routes/plugins.tsx @@ -180,34 +180,71 @@ function PluginsPageContent() { // 3. 检查 Git 状态 if (!isUnmounted) { - const status = await checkGitStatus() - setGitStatus(status) - - if (!status.installed) { + const statusResult = await checkGitStatus() + if (!statusResult.success) { toast({ - title: 'Git 未安装', - description: status.error || '请先安装 Git 才能使用插件安装功能', + title: 'Git 状态检查失败', + description: statusResult.error, variant: 'destructive', }) + setGitStatus({ installed: false, error: statusResult.error }) + } else { + setGitStatus(statusResult.data) + + if (!statusResult.data.installed) { + toast({ + title: 'Git 未安装', + description: statusResult.data.error || '请先安装 Git 才能使用插件安装功能', + variant: 'destructive', + }) + } } } // 4. 获取麦麦版本 if (!isUnmounted) { - const version = await getMaimaiVersion() - setMaimaiVersion(version) + const versionResult = await getMaimaiVersion() + if (!versionResult.success) { + toast({ + title: '版本获取失败', + description: versionResult.error, + variant: 'destructive', + }) + } else { + setMaimaiVersion(versionResult.data) + } } - // 5. 加载插件列表(包含已安装信息) if (!isUnmounted) { try { setLoading(true) setError(null) - const data = await fetchPluginList() + const apiResult = await fetchPluginList() + if (!apiResult.success) { + if (!isUnmounted) { + setError(apiResult.error) + toast({ + title: '加载失败', + description: apiResult.error, + variant: 'destructive', + }) + } + return + } + const data = apiResult.data if (!isUnmounted) { // 获取已安装插件列表 - const installed = await getInstalledPlugins() + const installedResult = await getInstalledPlugins() + if (!installedResult.success) { + toast({ + title: '获取已安装插件失败', + description: installedResult.error, + variant: 'destructive', + }) + return + } + const installed = installedResult.data setInstalledPlugins(installed) // 将已安装信息合并到插件数据中 @@ -261,16 +298,6 @@ function PluginsPageContent() { // 6. 加载所有插件的统计数据 loadPluginStats(mergedData) } - } catch (err) { - if (!isUnmounted) { - const errorMessage = err instanceof Error ? err.message : '加载插件列表失败' - setError(errorMessage) - toast({ - title: '加载失败', - description: errorMessage, - variant: 'destructive', - }) - } } finally { if (!isUnmounted) { setLoading(false) @@ -463,12 +490,21 @@ function PluginsPageContent() { try { setInstallDialogOpen(false) - await installPlugin( + const installResult = await installPlugin( installingPlugin.id, installingPlugin.manifest.repository_url || '', branch ) + if (!installResult.success) { + toast({ + title: '安装失败', + description: installResult.error, + variant: 'destructive', + }) + return + } + // 记录下载统计 recordPluginDownload(installingPlugin.id).catch(err => { console.warn('Failed to record download:', err) @@ -480,7 +516,16 @@ function PluginsPageContent() { }) // 重新加载已安装插件列表 - const installed = await getInstalledPlugins() + const installedResult = await getInstalledPlugins() + if (!installedResult.success) { + toast({ + title: '获取已安装插件失败', + description: installedResult.error, + variant: 'destructive', + }) + return + } + const installed = installedResult.data setInstalledPlugins(installed) // 重新合并已安装信息到插件列表 @@ -513,7 +558,16 @@ function PluginsPageContent() { // 卸载插件处理 const handleUninstall = async (plugin: PluginInfo) => { try { - await uninstallPlugin(plugin.id) + const uninstallResult = await uninstallPlugin(plugin.id) + + if (!uninstallResult.success) { + toast({ + title: '卸载失败', + description: uninstallResult.error, + variant: 'destructive', + }) + return + } toast({ title: '卸载成功', @@ -521,7 +575,16 @@ function PluginsPageContent() { }) // 重新加载已安装插件列表 - const installed = await getInstalledPlugins() + const installedResult = await getInstalledPlugins() + if (!installedResult.success) { + toast({ + title: '获取已安装插件失败', + description: installedResult.error, + variant: 'destructive', + }) + return + } + const installed = installedResult.data setInstalledPlugins(installed) // 重新合并已安装信息到插件列表 @@ -561,19 +624,37 @@ function PluginsPageContent() { } try { - const result = await updatePlugin( + const updateResult = await updatePlugin( plugin.id, plugin.manifest.repository_url || '', 'main' ) + if (!updateResult.success) { + toast({ + title: '更新失败', + description: updateResult.error, + variant: 'destructive', + }) + return + } + toast({ title: '更新成功', - description: `${plugin.manifest.name} 已从 ${result.old_version} 更新到 ${result.new_version}`, + description: `${plugin.manifest.name} 已从 ${updateResult.data.old_version} 更新到 ${updateResult.data.new_version}`, }) // 重新加载已安装插件列表 - const installed = await getInstalledPlugins() + const installedResult = await getInstalledPlugins() + if (!installedResult.success) { + toast({ + title: '获取已安装插件失败', + description: installedResult.error, + variant: 'destructive', + }) + return + } + const installed = installedResult.data setInstalledPlugins(installed) // 重新合并已安装信息到插件列表 From bddc6087cdf23c0e5502c6cb962534f0181fa353 Mon Sep 17 00:00:00 2001 From: DrSmoothl <1787882683@qq.com> Date: Sun, 1 Mar 2026 19:03:09 +0800 Subject: [PATCH 12/26] refactor(T11): split settings.tsx into settings/ directory --- dashboard/src/routes/settings.tsx | 2227 ----------------- dashboard/src/routes/settings/AboutTab.tsx | 256 ++ .../src/routes/settings/AppearanceTab.tsx | 840 +++++++ dashboard/src/routes/settings/LibraryItem.tsx | 15 + dashboard/src/routes/settings/OtherTab.tsx | 513 ++++ dashboard/src/routes/settings/SecurityTab.tsx | 486 ++++ dashboard/src/routes/settings/ThemeOption.tsx | 51 + dashboard/src/routes/settings/index.tsx | 63 + dashboard/src/routes/settings/types.ts | 51 + 9 files changed, 2275 insertions(+), 2227 deletions(-) delete mode 100644 dashboard/src/routes/settings.tsx create mode 100644 dashboard/src/routes/settings/AboutTab.tsx create mode 100644 dashboard/src/routes/settings/AppearanceTab.tsx create mode 100644 dashboard/src/routes/settings/LibraryItem.tsx create mode 100644 dashboard/src/routes/settings/OtherTab.tsx create mode 100644 dashboard/src/routes/settings/SecurityTab.tsx create mode 100644 dashboard/src/routes/settings/ThemeOption.tsx create mode 100644 dashboard/src/routes/settings/index.tsx create mode 100644 dashboard/src/routes/settings/types.ts diff --git a/dashboard/src/routes/settings.tsx b/dashboard/src/routes/settings.tsx deleted file mode 100644 index 159ffb43..00000000 --- a/dashboard/src/routes/settings.tsx +++ /dev/null @@ -1,2227 +0,0 @@ -import { Palette, Info, Shield, Eye, EyeOff, Copy, RefreshCw, Check, CheckCircle2, XCircle, AlertTriangle, Settings, RotateCcw, Database, Download, Upload, Trash2, HardDrive } from 'lucide-react' -import { useTheme } from '@/components/use-theme' -import { useAnimation } from '@/hooks/use-animation' -import { useState, useMemo, useRef, useCallback } from 'react' -import { useNavigate } from '@tanstack/react-router' -import { cn } from '@/lib/utils' -import { fetchWithAuth } from '@/lib/fetch-with-auth' -import { Switch } from '@/components/ui/switch' -import { Label } from '@/components/ui/label' -import { Input } from '@/components/ui/input' -import { Button } from '@/components/ui/button' -import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs' -import { ScrollArea } from '@/components/ui/scroll-area' -import { useToast } from '@/hooks/use-toast' -import { validateToken } from '@/lib/token-validator' -import { APP_VERSION, APP_NAME } from '@/lib/version' -import { - getSetting, - setSetting, - exportSettings, - importSettings, - resetAllSettings, - clearLocalCache, - getStorageUsage, - formatBytes, - DEFAULT_SETTINGS, -} from '@/lib/settings-manager' -import { Slider } from '@/components/ui/slider' -import { logWebSocket } from '@/lib/log-websocket' -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, -} from '@/components/ui/dialog' -import { - AlertDialog, - AlertDialogAction, - AlertDialogCancel, - AlertDialogContent, - AlertDialogDescription, - AlertDialogFooter, - AlertDialogHeader, - AlertDialogTitle, - AlertDialogTrigger, -} from '@/components/ui/alert-dialog' - -import { getComputedTokens } from '@/lib/theme/pipeline' -import { hexToHSL } from '@/lib/theme/palette' -import { defaultBackgroundConfig, defaultBackgroundEffects, defaultLightTokens } from '@/lib/theme/tokens' -import { exportThemeJSON, importThemeJSON } from '@/lib/theme/storage' -import type { BackgroundConfigMap, BackgroundEffects, ThemeTokens } from '@/lib/theme/tokens' -import { - Accordion, - AccordionContent, - AccordionItem, - AccordionTrigger, -} from '@/components/ui/accordion' -import { CodeEditor } from '@/components/CodeEditor' -import { BackgroundEffectsControls } from '@/components/background-effects-controls' -import { BackgroundUploader } from '@/components/background-uploader' -import { ComponentCSSEditor } from '@/components/component-css-editor' -import { sanitizeCSS } from '@/lib/theme/sanitizer' -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from '@/components/ui/select' - -export function SettingsPage() { - return ( -
    - {/* 页面标题 */} -
    -
    -

    系统设置

    -

    管理您的应用偏好设置

    -
    -
    - - {/* 标签页 */} - - - - - 外观 - - - - 安全 - - - - 其他 - - - - 关于 - - - - - - - - - - - - - - - - - - - - - -
    - ) -} - -// 辅助函数:将 HSL 字符串转换为 HEX -function hslToHex(hsl: string): string { - if (!hsl) return '#000000' - - // 解析 "221.2 83.2% 53.3%" 格式 - const parts = hsl.split(' ').filter(Boolean) - if (parts.length < 3) return '#000000' - - const h = parseFloat(parts[0]) - const s = parseFloat(parts[1].replace('%', '')) - const l = parseFloat(parts[2].replace('%', '')) - - const sDecimal = s / 100 - const lDecimal = l / 100 - - const c = (1 - Math.abs(2 * lDecimal - 1)) * sDecimal - const x = c * (1 - Math.abs(((h / 60) % 2) - 1)) - const m = lDecimal - c / 2 - - let r = 0, g = 0, b = 0 - - if (h >= 0 && h < 60) { r = c; g = x; b = 0 } - else if (h >= 60 && h < 120) { r = x; g = c; b = 0 } - else if (h >= 120 && h < 180) { r = 0; g = c; b = x } - else if (h >= 180 && h < 240) { r = 0; g = x; b = c } - else if (h >= 240 && h < 300) { r = x; g = 0; b = c } - else if (h >= 300 && h < 360) { r = c; g = 0; b = x } - - const toHex = (n: number) => { - const hex = Math.round((n + m) * 255).toString(16) - return hex.length === 1 ? '0' + hex : hex - } - - return `#${toHex(r)}${toHex(g)}${toHex(b)}` -} - -// 外观设置标签页 -function AppearanceTab() { - const { theme, setTheme, themeConfig, updateThemeConfig, resolvedTheme, resetTheme } = useTheme() - const { enableAnimations, setEnableAnimations, enableWavesBackground, setEnableWavesBackground } = useAnimation() - const { toast } = useToast() - - const [localCSS, setLocalCSS] = useState(themeConfig.customCSS || '') - const [cssWarnings, setCssWarnings] = useState([]) - const cssDebounceRef = useRef | null>(null) -const bgDebounceRef = useRef | null>(null) - const fileInputRef = useRef(null) - - const updateTokenSection = useCallback( - (section: K, partial: Partial) => { - updateThemeConfig({ - tokenOverrides: { - ...themeConfig.tokenOverrides, - [section]: { - ...defaultLightTokens[section], - ...themeConfig.tokenOverrides?.[section], - ...partial, - } as ThemeTokens[K], - }, - }) - }, - [themeConfig.tokenOverrides, updateThemeConfig] - ) - - const resetTokenSection = useCallback( - (section: keyof ThemeTokens) => { - const newOverrides: Partial = { ...themeConfig.tokenOverrides } - delete newOverrides[section] - updateThemeConfig({ tokenOverrides: newOverrides }) - }, - [themeConfig.tokenOverrides, updateThemeConfig] - ) - - const handleCSSChange = useCallback((val: string) => { - setLocalCSS(val) - const result = sanitizeCSS(val) - setCssWarnings(result.warnings) - - if (cssDebounceRef.current) clearTimeout(cssDebounceRef.current) - cssDebounceRef.current = setTimeout(() => { - updateThemeConfig({ customCSS: val }) - }, 500) - }, [updateThemeConfig]) - - const currentAccentHex = useMemo(() => { - if (themeConfig.accentColor) { - return hslToHex(themeConfig.accentColor) - } - return '#3b82f6' // 默认蓝色 - }, [themeConfig.accentColor]) - - const handleAccentColorChange = (e: React.ChangeEvent) => { - const hex = e.target.value - const hsl = hexToHSL(hex) - updateThemeConfig({ accentColor: hsl }) - } - - const handleResetAccent = () => { - updateThemeConfig({ accentColor: '' }) - } - - const handleExport = () => { - const json = exportThemeJSON() - const blob = new Blob([json], { type: 'application/json' }) - const url = URL.createObjectURL(blob) - const a = document.createElement('a') - a.href = url - a.download = `maibot-theme-${Date.now()}.json` - a.click() - URL.revokeObjectURL(url) - } - - const handleImport = (e: React.ChangeEvent) => { - const file = e.target.files?.[0] - if (!file) return - const reader = new FileReader() - reader.onload = (ev) => { - const json = ev.target?.result as string - const result = importThemeJSON(json) - if (result.success) { - // 导入成功后需要刷新页面使配置生效(因为 ThemeProvider 需要重新读取 localStorage) - toast({ title: '导入成功', description: '主题配置已导入,页面将自动刷新' }) - setTimeout(() => window.location.reload(), 1000) - } else { - toast({ title: '导入失败', description: result.errors.join('; '), variant: 'destructive' }) - } - } - reader.readAsText(file) - // 重置 input,允许重复选择同一文件 - e.target.value = '' - } - - const handleResetTheme = () => { - resetTheme() - setLocalCSS('') - setCssWarnings([]) - toast({ title: '重置成功', description: '主题已重置为默认值' }) - } - - const previewTokens = useMemo(() => { - return getComputedTokens(themeConfig, resolvedTheme === 'dark').color - }, [themeConfig, resolvedTheme]) - - const bgConfig: BackgroundConfigMap = themeConfig.backgroundConfig ?? {} - - const handleBgAssetChange = (layerId: keyof BackgroundConfigMap, assetId: string | undefined) => { - const current = bgConfig[layerId] ?? defaultBackgroundConfig - const newMap: BackgroundConfigMap = { - ...bgConfig, - [layerId]: { ...current, assetId, type: assetId ? 'image' : 'none' }, - } - if (bgDebounceRef.current) clearTimeout(bgDebounceRef.current) - bgDebounceRef.current = setTimeout(() => updateThemeConfig({ backgroundConfig: newMap }), 500) - } - - const handleBgEffectsChange = (layerId: keyof BackgroundConfigMap, effects: BackgroundEffects) => { - const current = bgConfig[layerId] ?? defaultBackgroundConfig - const newMap: BackgroundConfigMap = { ...bgConfig, [layerId]: { ...current, effects } } - if (bgDebounceRef.current) clearTimeout(bgDebounceRef.current) - bgDebounceRef.current = setTimeout(() => updateThemeConfig({ backgroundConfig: newMap }), 500) - } - - const handleBgCSSChange = (layerId: keyof BackgroundConfigMap, css: string) => { - const current = bgConfig[layerId] ?? defaultBackgroundConfig - const newMap: BackgroundConfigMap = { ...bgConfig, [layerId]: { ...current, customCSS: css } } - if (bgDebounceRef.current) clearTimeout(bgDebounceRef.current) - bgDebounceRef.current = setTimeout(() => updateThemeConfig({ backgroundConfig: newMap }), 500) - } - - const handleBgInheritChange = (layerId: keyof BackgroundConfigMap, inherit: boolean) => { - const current = bgConfig[layerId] ?? defaultBackgroundConfig - const newMap: BackgroundConfigMap = { ...bgConfig, [layerId]: { ...current, inherit } } - if (bgDebounceRef.current) clearTimeout(bgDebounceRef.current) - bgDebounceRef.current = setTimeout(() => updateThemeConfig({ backgroundConfig: newMap }), 500) - } - - return ( -
    - {/* 主题模式 */} -
    -

    主题模式

    -
    - - - -
    -
    - - {/* 主题色配置 */} -
    -
    -

    主题色

    - -
    - -
    - {/* 颜色选择器 */} -
    -
    -
    - -
    -
    - -

    点击色环选择或输入 HEX 值

    -
    -
    - -
    - -
    -
    - - {/* 实时色板预览 */} -
    -

    实时色板预览

    -
    - - - - - - - - -
    -
    -
    -
    - - {/* 样式微调 */} -
    -

    界面样式微调

    - - - - {/* 1. 字体排版 (Typography) */} - - 字体排版 (Typography) - -
    -
    - -
    - -
    - - -
    - -
    -
    - - - {parseFloat((themeConfig.tokenOverrides?.typography as any)?.['font-size-base'] || '1') * 16}px - -
    - { - updateTokenSection('typography', { - 'font-size-base': `${vals[0] / 16}rem`, - }) - }} - /> -
    - -
    - - -
    -
    -
    -
    - - {/* 2. 视觉效果 (Visual) */} - - 视觉效果 (Visual) - -
    -
    - -
    - -
    -
    - - - {Math.round(parseFloat((themeConfig.tokenOverrides?.visual as any)?.['radius-md'] || '0.375') * 16)}px - -
    - { - updateTokenSection('visual', { - 'radius-md': `${vals[0] / 16}rem`, - }) - }} - /> -
    - -
    - - -
    - -
    - - { - updateTokenSection('visual', { - 'blur-md': checked ? defaultLightTokens.visual['blur-md'] : '0px', - }) - }} - /> -
    -
    -
    -
    - - {/* 3. 布局 (Layout) */} - - 布局 (Layout) - -
    -
    - -
    - -
    -
    - - - {(themeConfig.tokenOverrides?.layout as any)?.['sidebar-width'] || '16rem'} - -
    - { - updateTokenSection('layout', { - 'sidebar-width': `${vals[0]}rem`, - }) - }} - /> -
    - -
    -
    - - - {(themeConfig.tokenOverrides?.layout as any)?.['max-content-width'] || '1280px'} - -
    - { - updateTokenSection('layout', { - 'max-content-width': `${vals[0]}px`, - }) - }} - /> -
    - -
    -
    - - - {(themeConfig.tokenOverrides?.layout as any)?.['space-unit'] || '0.25rem'} - -
    - { - updateTokenSection('layout', { - 'space-unit': `${vals[0]}rem`, - }) - }} - /> -
    -
    -
    -
    - - {/* 4. 动画 (Animation) */} - - 动画 (Animation) - -
    -
    - -
    - -
    - - -
    -
    -
    -
    - - {/* 5. 背景设置 (Backgrounds) */} - - 背景设置 (Backgrounds) - -
    - - - 页面 - 侧边栏 - Header - Card - Dialog - - - {(['page', 'sidebar', 'header', 'card', 'dialog'] as const).map((layerId) => ( - - {layerId !== 'page' && ( -
    -
    - -

    开启后将使用上级层级的背景配置

    -
    - handleBgInheritChange(layerId, v)} - /> -
    - )} - handleBgAssetChange(layerId, id)} - /> - handleBgEffectsChange(layerId, effects)} - /> - handleBgCSSChange(layerId, css)} - /> -
    - ))} -
    -
    -
    -
    -
    -
    - -
    -
    -
    -

    自定义 CSS

    -

    - 编写自定义 CSS 来进一步个性化界面。危险的 CSS(如 @import、url())将被自动过滤。 -

    -
    - -
    - -
    - - - {cssWarnings.length > 0 && ( -
    -
    - - 以下内容已被安全过滤: -
    -
      - {cssWarnings.map((w, i) =>
    • {w}
    • )} -
    -
    - )} -
    -
    - - {/* 动效设置 */} -
    -

    动画效果

    -
    - {/* 全局动画开关 */} -
    -
    -
    - -

    - 关闭后将禁用所有过渡动画和特效,提升性能 -

    -
    - -
    -
    - - {/* 波浪背景开关 */} -
    -
    -
    - -

    - 关闭后登录页将使用纯色背景,适合低性能设备 -

    -
    - -
    -
    -
    -
    - - {/* 主题导入/导出 */} -
    -

    主题导入/导出

    -
    -
    - {/* 导出按钮 */} - - - {/* 导入按钮 */} - - - {/* 重置按钮 */} - - - - - - - 确认重置主题 - - 这将重置所有主题设置为默认值,包括颜色、字体、布局和自定义 CSS。此操作不可撤销,确定要继续吗? - - - - 取消 - - 确认重置 - - - - -
    - - {/* 隐藏的文件输入 */} - - -

    - 导出主题为 JSON 文件便于分享或备份,导入时会自动应用所有配置。 -

    -
    -
    -
    - ) -} - -function ColorTokenPreview({ name, value, foreground, border }: { name: string, value: string, foreground?: string, border?: boolean }) { - return ( -
    -
    - Aa -
    -
    - {name} -
    -
    - ) -} - -// 安全设置标签页 -function SecurityTab() { - const navigate = useNavigate() - const [currentToken, setCurrentToken] = useState('') - const [newToken, setNewToken] = useState('') - const [showCurrentToken, setShowCurrentToken] = useState(false) - const [showNewToken, setShowNewToken] = useState(false) - const [isUpdating, setIsUpdating] = useState(false) - const [isRegenerating, setIsRegenerating] = useState(false) - const [copied, setCopied] = useState(false) - const [showTokenDialog, setShowTokenDialog] = useState(false) - const [generatedToken, setGeneratedToken] = useState('') - const [tokenCopied, setTokenCopied] = useState(false) - const { toast } = useToast() - - // 实时验证新 Token - const tokenValidation = useMemo(() => validateToken(newToken), [newToken]) - - // 复制 token 到剪贴板 - const copyToClipboard = async (text: string) => { - if (!currentToken) { - toast({ - title: '无法复制', - description: 'Token 存储在安全 Cookie 中,请重新生成以获取新 Token', - variant: 'destructive', - }) - return - } - try { - await navigator.clipboard.writeText(text) - setCopied(true) - toast({ - title: '复制成功', - description: 'Token 已复制到剪贴板', - }) - setTimeout(() => setCopied(false), 2000) - } catch { - toast({ - title: '复制失败', - description: '请手动复制 Token', - variant: 'destructive', - }) - } - } - - // 更新 token - const handleUpdateToken = async () => { - if (!newToken.trim()) { - toast({ - title: '输入错误', - description: '请输入新的 Token', - variant: 'destructive', - }) - return - } - - // 验证 Token 格式 - if (!tokenValidation.isValid) { - const failedRules = tokenValidation.rules - .filter((rule) => !rule.passed) - .map((rule) => rule.label) - .join(', ') - - toast({ - title: '格式错误', - description: `Token 不符合要求: ${failedRules}`, - variant: 'destructive', - }) - return - } - - setIsUpdating(true) - - try { - const response = await fetch('/api/webui/auth/update', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - credentials: 'include', // 使用 Cookie 认证 - body: JSON.stringify({ new_token: newToken.trim() }), - }) - - const data = await response.json() - - if (response.ok && data.success) { - // 清空输入框 - setNewToken('') - - // 更新当前显示的 Token - setCurrentToken(newToken.trim()) - - toast({ - title: '更新成功', - description: 'Access Token 已更新,即将跳转到登录页', - }) - - // 延迟跳转到登录页 - setTimeout(() => { - navigate({ to: '/auth' }) - }, 1500) - } else { - toast({ - title: '更新失败', - description: data.message || '无法更新 Token', - variant: 'destructive', - }) - } - } catch (err) { - console.error('更新 Token 错误:', err) - toast({ - title: '更新失败', - description: '连接服务器失败', - variant: 'destructive', - }) - } finally { - setIsUpdating(false) - } - } - - // 重新生成 token (实际执行函数) - const executeRegenerateToken = async () => { - setIsRegenerating(true) - - try { - const response = await fetch('/api/webui/auth/regenerate', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - credentials: 'include', // 使用 Cookie 认证 - }) - - const data = await response.json() - - if (response.ok && data.success) { - // 更新当前显示的 Token - setCurrentToken(data.token) - - // 显示弹窗展示新 Token - setGeneratedToken(data.token) - setShowTokenDialog(true) - setTokenCopied(false) - - toast({ - title: '生成成功', - description: '新的 Access Token 已生成,请及时保存', - }) - } else { - toast({ - title: '生成失败', - description: data.message || '无法生成新 Token', - variant: 'destructive', - }) - } - } catch (err) { - console.error('生成 Token 错误:', err) - toast({ - title: '生成失败', - description: '连接服务器失败', - variant: 'destructive', - }) - } finally { - setIsRegenerating(false) - } - } - - // 复制生成的 Token - const copyGeneratedToken = async () => { - try { - await navigator.clipboard.writeText(generatedToken) - setTokenCopied(true) - toast({ - title: '复制成功', - description: 'Token 已复制到剪贴板', - }) - } catch { - toast({ - title: '复制失败', - description: '请手动复制 Token', - variant: 'destructive', - }) - } - } - - // 关闭弹窗 - const handleCloseDialog = () => { - setShowTokenDialog(false) - // 延迟清空 token,避免用户看到内容消失 - setTimeout(() => { - setGeneratedToken('') - setTokenCopied(false) - }, 300) - - // 跳转到登录页 - setTimeout(() => { - navigate({ to: '/auth' }) - }, 500) - } - - // 处理对话框状态变化(包括点击外部、ESC 等关闭方式) - const handleDialogOpenChange = (open: boolean) => { - if (!open) { - handleCloseDialog() - } - } - - return ( -
    - {/* Token 生成成功弹窗 */} - - - - - - 新的 Access Token - - - 这是您的新 Token,请立即保存。关闭此窗口后将跳转到登录页面。 - - - -
    - {/* Token 显示区域 */} -
    - -
    - {generatedToken} -
    -
    - - {/* 警告提示 */} -
    -
    - -
    -

    重要提示

    -
      -
    • 此 Token 仅显示一次,关闭后无法再查看
    • -
    • 请立即复制并保存到安全的位置
    • -
    • 关闭窗口后将自动跳转到登录页面
    • -
    • 请使用新 Token 重新登录系统
    • -
    -
    -
    -
    -
    - - - - - -
    -
    - - {/* 当前 Token */} -
    -

    当前 Access Token

    -
    -
    - -
    -
    - - -
    -
    - - - - - - - - 确认重新生成 Token - - 这将生成一个新的 64 位安全令牌,并使当前 Token 立即失效。 - 您需要使用新 Token 重新登录系统。此操作不可撤销,确定要继续吗? - - - - 取消 - - 确认生成 - - - - -
    -
    -

    - 请妥善保管您的 Access Token,不要泄露给他人 -

    -
    -
    -
    - - {/* 更新 Token */} -
    -

    自定义 Access Token

    -
    -
    - -
    - setNewToken(e.target.value)} - className="pr-10 font-mono text-sm" - placeholder="输入自定义 Token" - /> - -
    - - {/* Token 验证规则显示 */} - {newToken && ( -
    -

    Token 安全要求:

    -
    - {tokenValidation.rules.map((rule) => ( -
    - {rule.passed ? ( - - ) : ( - - )} - - {rule.label} - -
    - ))} -
    - {tokenValidation.isValid && ( -
    -
    - - Token 格式正确,可以使用 -
    -
    - )} -
    - )} -
    - -
    -
    - - {/* 安全提示 */} -
    -

    安全提示

    -
      -
    • 重新生成 Token 会创建系统随机生成的 64 位安全令牌
    • -
    • 自定义 Token 必须满足所有安全要求才能使用
    • -
    • 更新 Token 后,旧的 Token 将立即失效
    • -
    • 请在安全的环境下查看和复制 Token
    • -
    • 如果怀疑 Token 泄露,请立即重新生成或更新
    • -
    • 建议使用系统生成的 Token 以获得最高安全性
    • -
    -
    -
    - ) -} - -// 其他设置标签页 -function OtherTab() { - const navigate = useNavigate() - const { toast } = useToast() - const [isResetting, setIsResetting] = useState(false) - const [shouldThrowError, setShouldThrowError] = useState(false) - - // 性能与存储设置状态 - const [logCacheSize, setLogCacheSize] = useState(() => getSetting('logCacheSize')) - const [wsReconnectInterval, setWsReconnectInterval] = useState(() => getSetting('wsReconnectInterval')) - const [wsMaxReconnectAttempts, setWsMaxReconnectAttempts] = useState(() => getSetting('wsMaxReconnectAttempts')) - const [dataSyncInterval, setDataSyncInterval] = useState(() => getSetting('dataSyncInterval')) - const [storageUsage, setStorageUsage] = useState(() => getStorageUsage()) - - // 导入/导出状态 - const [isExporting, setIsExporting] = useState(false) - const [isImporting, setIsImporting] = useState(false) - const fileInputRef = useRef(null) - - // 手动触发 React 错误 - if (shouldThrowError) { - throw new Error('这是一个手动触发的测试错误,用于验证错误边界组件是否正常工作。') - } - - // 刷新存储使用情况 - const refreshStorageUsage = () => { - setStorageUsage(getStorageUsage()) - } - - // 处理日志缓存大小变更 - const handleLogCacheSizeChange = (value: number[]) => { - const size = value[0] - setLogCacheSize(size) - setSetting('logCacheSize', size) - } - - // 处理 WebSocket 重连间隔变更 - const handleWsReconnectIntervalChange = (value: number[]) => { - const interval = value[0] - setWsReconnectInterval(interval) - setSetting('wsReconnectInterval', interval) - } - - // 处理 WebSocket 最大重连次数变更 - const handleWsMaxReconnectAttemptsChange = (value: number[]) => { - const attempts = value[0] - setWsMaxReconnectAttempts(attempts) - setSetting('wsMaxReconnectAttempts', attempts) - } - - // 处理数据同步间隔变更 - const handleDataSyncIntervalChange = (value: number[]) => { - const interval = value[0] - setDataSyncInterval(interval) - setSetting('dataSyncInterval', interval) - } - - // 清除日志缓存 - const handleClearLogCache = () => { - logWebSocket.clearLogs() - toast({ - title: '日志已清除', - description: '日志缓存已清空', - }) - } - - // 清除本地缓存 - const handleClearLocalCache = () => { - const result = clearLocalCache() - refreshStorageUsage() - toast({ - title: '缓存已清除', - description: `已清除 ${result.clearedKeys.length} 项缓存数据`, - }) - } - - // 导出设置 - const handleExportSettings = () => { - setIsExporting(true) - try { - const settings = exportSettings() - const dataStr = JSON.stringify(settings, null, 2) - const blob = new Blob([dataStr], { type: 'application/json' }) - const url = URL.createObjectURL(blob) - const a = document.createElement('a') - a.href = url - a.download = `maibot-webui-settings-${new Date().toISOString().slice(0, 10)}.json` - document.body.appendChild(a) - a.click() - document.body.removeChild(a) - URL.revokeObjectURL(url) - toast({ - title: '导出成功', - description: '设置已导出为 JSON 文件', - }) - } catch (error) { - console.error('导出设置失败:', error) - toast({ - title: '导出失败', - description: '无法导出设置', - variant: 'destructive', - }) - } finally { - setIsExporting(false) - } - } - - // 导入设置 - const handleImportSettings = (event: React.ChangeEvent) => { - const file = event.target.files?.[0] - if (!file) return - - setIsImporting(true) - const reader = new FileReader() - reader.onload = (e) => { - try { - const content = e.target?.result as string - const settings = JSON.parse(content) - const result = importSettings(settings) - - if (result.success) { - // 刷新页面状态 - setLogCacheSize(getSetting('logCacheSize')) - setWsReconnectInterval(getSetting('wsReconnectInterval')) - setWsMaxReconnectAttempts(getSetting('wsMaxReconnectAttempts')) - setDataSyncInterval(getSetting('dataSyncInterval')) - refreshStorageUsage() - - toast({ - title: '导入成功', - description: `成功导入 ${result.imported.length} 项设置${result.skipped.length > 0 ? `,跳过 ${result.skipped.length} 项` : ''}`, - }) - - // 提示用户刷新页面以应用所有更改 - if (result.imported.includes('theme') || result.imported.includes('accentColor')) { - toast({ - title: '提示', - description: '部分设置需要刷新页面才能完全生效', - }) - } - } else { - toast({ - title: '导入失败', - description: '没有有效的设置项可导入', - variant: 'destructive', - }) - } - } catch (error) { - console.error('导入设置失败:', error) - toast({ - title: '导入失败', - description: '文件格式无效', - variant: 'destructive', - }) - } finally { - setIsImporting(false) - // 清空 input,允许重复选择同一文件 - if (fileInputRef.current) { - fileInputRef.current.value = '' - } - } - } - reader.readAsText(file) - } - - // 重置所有设置 - const handleResetAllSettings = () => { - resetAllSettings() - // 刷新页面状态 - setLogCacheSize(DEFAULT_SETTINGS.logCacheSize) - setWsReconnectInterval(DEFAULT_SETTINGS.wsReconnectInterval) - setWsMaxReconnectAttempts(DEFAULT_SETTINGS.wsMaxReconnectAttempts) - setDataSyncInterval(DEFAULT_SETTINGS.dataSyncInterval) - refreshStorageUsage() - toast({ - title: '已重置', - description: '所有设置已恢复为默认值,刷新页面以应用更改', - }) - } - - const handleResetSetup = async () => { - setIsResetting(true) - - try { - // 调用后端API重置首次配置状态 - const response = await fetchWithAuth('/api/webui/setup/reset', { - method: 'POST', - }) - - const data = await response.json() - - if (response.ok && data.success) { - toast({ - title: '重置成功', - description: '即将进入初次配置向导', - }) - - // 延迟跳转到配置向导 - setTimeout(() => { - navigate({ to: '/setup' }) - }, 1000) - } else { - toast({ - title: '重置失败', - description: data.message || '无法重置配置状态', - variant: 'destructive', - }) - } - } catch (error) { - console.error('重置配置状态错误:', error) - toast({ - title: '重置失败', - description: '连接服务器失败', - variant: 'destructive', - }) - } finally { - setIsResetting(false) - } - } - - return ( -
    - {/* 性能与存储 */} -
    -

    - - 性能与存储 -

    -
    - {/* 存储使用情况 */} -
    -
    - - - 本地存储使用 - - -
    -
    {formatBytes(storageUsage.used)}
    -

    {storageUsage.items} 个存储项

    -
    - - {/* 日志缓存大小 */} -
    -
    - - {logCacheSize} 条 -
    - -

    - 控制日志查看器最多缓存的日志条数,较大的值会占用更多内存 -

    -
    - - {/* 数据刷新间隔 */} -
    -
    - - {dataSyncInterval} 秒 -
    - -

    - 控制首页统计数据的自动刷新间隔 -

    -
    - - {/* WebSocket 重连间隔 */} -
    -
    - - {wsReconnectInterval / 1000} 秒 -
    - -

    - 日志 WebSocket 连接断开后的重连基础间隔 -

    -
    - - {/* WebSocket 最大重连次数 */} -
    -
    - - {wsMaxReconnectAttempts} 次 -
    - -

    - 连接失败后的最大重连尝试次数 -

    -
    - - {/* 清理按钮 */} -
    - - - - - - - - 确认清除本地缓存 - - 这将清除所有本地缓存的设置和数据(不包括登录凭证)。 - 您可能需要重新配置部分偏好设置。确定要继续吗? - - - - 取消 - - 确认清除 - - - - -
    -
    -
    - - {/* 导入/导出设置 */} -
    -

    - - 导入/导出设置 -

    -
    -

    - 导出当前的界面设置以便备份,或从之前导出的文件中恢复设置。 -

    - -
    - - - - -
    - - {/* 重置所有设置 */} -
    - - - - - - - 确认重置所有设置 - - 这将把所有界面设置恢复为默认值,包括主题、颜色、动画等偏好设置。 - 此操作不会影响您的登录状态。确定要继续吗? - - - - 取消 - - 确认重置 - - - - -
    -
    -
    - - {/* 配置向导 */} -
    -

    配置向导

    -
    -
    -

    - 重新进行初次配置向导,可以帮助您重新设置系统的基础配置。 -

    -
    - - - - - - - 确认重新配置 - - 这将带您重新进入初次配置向导。您可以重新设置系统的基础配置项。确定要继续吗? - - - - 取消 - - 确认重置 - - - - -
    -
    - - {/* 开发者工具 */} -
    -

    - - 开发者工具 -

    -
    -
    -

    - 以下功能仅供开发调试使用,可能会导致页面崩溃或异常。 -

    -
    - - - - - - - 确认触发错误 - - 这将手动触发一个 React 错误,用于测试错误边界组件的显示效果。 - 页面将显示错误界面,您可以通过刷新页面或点击返回首页来恢复。 - - - - 取消 - setShouldThrowError(true)} - className="bg-destructive text-destructive-foreground hover:bg-destructive/90" - > - 确认触发 - - - - -
    -
    -
    - ) -} - -// 关于标签页 -function AboutTab() { - return ( -
    - {/* GitHub 开源地址 */} -
    -
    -
    - -
    -
    -

    - 开源项目 -

    -

    - 本项目在 GitHub 开源,欢迎 Star ⭐ 支持! -

    - - - 前往 GitHub - - - - -
    -
    -
    - - {/* 应用信息 */} -
    -

    关于 {APP_NAME}

    -
    -

    版本: {APP_VERSION}

    -

    麦麦(MaiBot)的现代化 Web 管理界面

    -
    -
    - - {/* 作者信息 */} -
    -

    作者

    -
    -
    -

    MaiBot 核心

    -

    Mai-with-u

    -
    -
    -

    WebUI

    -

    Mai-with-u @MotricSeven

    -
    -
    -
    - - {/* 技术栈 */} -
    -

    技术栈

    -
    -
    -

    前端框架

    -
      -
    • React 19.2.0
    • -
    • TypeScript 5.7.2
    • -
    • Vite 6.0.7
    • -
    • TanStack Router 1.94.2
    • -
    -
    -
    -

    UI 组件

    -
      -
    • shadcn/ui
    • -
    • Radix UI
    • -
    • Tailwind CSS 3.4.17
    • -
    • Lucide Icons
    • -
    -
    -
    -

    后端

    -
      -
    • Python 3.12+
    • -
    • FastAPI
    • -
    • Uvicorn
    • -
    • WebSocket
    • -
    -
    -
    -

    构建工具

    -
      -
    • Bun / npm
    • -
    • ESLint 9.17.0
    • -
    • PostCSS
    • -
    -
    -
    -
    - - {/* 开源感谢 */} -
    -

    开源库感谢

    -

    - 本项目使用了以下优秀的开源库,感谢他们的贡献: -

    - -
    - {/* UI 框架 */} -
    -

    UI 框架与组件

    -
    - - - - - -
    -
    - - {/* 路由与状态 */} -
    -

    路由与状态管理

    -
    - - -
    -
    - - {/* 表单与验证 */} -
    -

    表单处理

    -
    - - -
    -
    - - {/* 工具库 */} -
    -

    工具库

    -
    - - - - -
    -
    - - {/* 动画 */} -
    -

    动画效果

    -
    - - -
    -
    - - {/* 后端相关 */} -
    -

    后端框架

    -
    - - - - -
    -
    - - {/* 开发工具 */} -
    -

    开发工具

    -
    - - - - -
    -
    -
    -
    -
    - - {/* 许可证 */} -
    -

    开源许可

    -
    -
    -
    -
    -
    - GPLv3 -
    -
    -
    -

    - MaiBot WebUI -

    -

    - 本项目采用 GNU General Public License v3.0 开源许可证。 - 您可以自由地使用、修改和分发本软件,但必须保持相同的开源许可。 -

    -
    -
    -
    -

    - 本项目依赖的所有开源库均遵循各自的开源许可证(MIT、Apache-2.0、BSD 等)。 - 感谢所有开源贡献者的无私奉献。 -

    -
    -
    -
    - ) -} - -// 库信息组件 -type LibraryItemProps = { - name: string - description: string - license: string -} - -function LibraryItem({ name, description, license }: LibraryItemProps) { - return ( -
    -
    -

    {name}

    -

    {description}

    -
    - - {license} - -
    - ) -} - -type ThemeOptionProps = { - value: 'light' | 'dark' | 'system' - current: 'light' | 'dark' | 'system' - onChange: (theme: 'light' | 'dark' | 'system') => void - label: string - description: string -} - -function ThemeOption({ value, current, onChange, label, description }: ThemeOptionProps) { - const isSelected = current === value - - return ( - - ) -} diff --git a/dashboard/src/routes/settings/AboutTab.tsx b/dashboard/src/routes/settings/AboutTab.tsx new file mode 100644 index 00000000..0042a11f --- /dev/null +++ b/dashboard/src/routes/settings/AboutTab.tsx @@ -0,0 +1,256 @@ +import { ScrollArea } from '@/components/ui/scroll-area' + +import { APP_NAME, APP_VERSION } from '@/lib/version' +import { cn } from '@/lib/utils' + +import { LibraryItem } from './LibraryItem' + +export function AboutTab() { + return ( +
    + {/* GitHub 开源地址 */} +
    +
    +
    + +
    +
    +

    + 开源项目 +

    +

    + 本项目在 GitHub 开源,欢迎 Star ⭐ 支持! +

    + + + 前往 GitHub + + + + +
    +
    +
    + + {/* 应用信息 */} +
    +

    关于 {APP_NAME}

    +
    +

    版本: {APP_VERSION}

    +

    麦麦(MaiBot)的现代化 Web 管理界面

    +
    +
    + + {/* 作者信息 */} +
    +

    作者

    +
    +
    +

    MaiBot 核心

    +

    Mai-with-u

    +
    +
    +

    WebUI

    +

    Mai-with-u @MotricSeven

    +
    +
    +
    + + {/* 技术栈 */} +
    +

    技术栈

    +
    +
    +

    前端框架

    +
      +
    • React 19.2.0
    • +
    • TypeScript 5.7.2
    • +
    • Vite 6.0.7
    • +
    • TanStack Router 1.94.2
    • +
    +
    +
    +

    UI 组件

    +
      +
    • shadcn/ui
    • +
    • Radix UI
    • +
    • Tailwind CSS 3.4.17
    • +
    • Lucide Icons
    • +
    +
    +
    +

    后端

    +
      +
    • Python 3.12+
    • +
    • FastAPI
    • +
    • Uvicorn
    • +
    • WebSocket
    • +
    +
    +
    +

    构建工具

    +
      +
    • Bun / npm
    • +
    • ESLint 9.17.0
    • +
    • PostCSS
    • +
    +
    +
    +
    + + {/* 开源感谢 */} +
    +

    开源库感谢

    +

    + 本项目使用了以下优秀的开源库,感谢他们的贡献: +

    + +
    + {/* UI 框架 */} +
    +

    UI 框架与组件

    +
    + + + + + +
    +
    + + {/* 路由与状态 */} +
    +

    路由与状态管理

    +
    + + +
    +
    + + {/* 表单与验证 */} +
    +

    表单处理

    +
    + + +
    +
    + + {/* 工具库 */} +
    +

    工具库

    +
    + + + + +
    +
    + + {/* 动画 */} +
    +

    动画效果

    +
    + + +
    +
    + + {/* 后端相关 */} +
    +

    后端框架

    +
    + + + + +
    +
    + + {/* 开发工具 */} +
    +

    开发工具

    +
    + + + + +
    +
    +
    +
    +
    + + {/* 许可证 */} +
    +

    开源许可

    +
    +
    +
    +
    +
    + GPLv3 +
    +
    +
    +

    + MaiBot WebUI +

    +

    + 本项目采用 GNU General Public License v3.0 开源许可证。 + 您可以自由地使用、修改和分发本软件,但必须保持相同的开源许可。 +

    +
    +
    +
    +

    + 本项目依赖的所有开源库均遵循各自的开源许可证(MIT、Apache-2.0、BSD 等)。 + 感谢所有开源贡献者的无私奉献。 +

    +
    +
    +
    + ) +} diff --git a/dashboard/src/routes/settings/AppearanceTab.tsx b/dashboard/src/routes/settings/AppearanceTab.tsx new file mode 100644 index 00000000..cde08cd7 --- /dev/null +++ b/dashboard/src/routes/settings/AppearanceTab.tsx @@ -0,0 +1,840 @@ +import { useState, useMemo, useRef, useCallback } from 'react' +import { AlertTriangle, Download, RotateCcw, Trash2, Upload } from 'lucide-react' + +import { useAnimation } from '@/hooks/use-animation' +import { useTheme } from '@/components/use-theme' +import { useToast } from '@/hooks/use-toast' +import { cn } from '@/lib/utils' +import { Button } from '@/components/ui/button' +import { Label } from '@/components/ui/label' +import { Input } from '@/components/ui/input' +import { Switch } from '@/components/ui/switch' +import { Slider } from '@/components/ui/slider' +import { getComputedTokens } from '@/lib/theme/pipeline' +import { hexToHSL } from '@/lib/theme/palette' +import { defaultBackgroundConfig, defaultBackgroundEffects, defaultLightTokens } from '@/lib/theme/tokens' +import { exportThemeJSON, importThemeJSON } from '@/lib/theme/storage' +import type { BackgroundConfigMap, BackgroundEffects, ThemeTokens } from '@/lib/theme/tokens' +import { + Accordion, + AccordionContent, + AccordionItem, + AccordionTrigger, +} from '@/components/ui/accordion' +import { CodeEditor } from '@/components/CodeEditor' +import { BackgroundEffectsControls } from '@/components/background-effects-controls' +import { BackgroundUploader } from '@/components/background-uploader' +import { ComponentCSSEditor } from '@/components/component-css-editor' +import { sanitizeCSS } from '@/lib/theme/sanitizer' +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from '@/components/ui/alert-dialog' +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select' +import { + Tabs, + TabsContent, + TabsList, + TabsTrigger, +} from '@/components/ui/tabs' + +import { ThemeOption } from './ThemeOption' +import { hslToHex } from './types' + +export function AppearanceTab() { + const { theme, setTheme, themeConfig, updateThemeConfig, resolvedTheme, resetTheme } = useTheme() + const { enableAnimations, setEnableAnimations, enableWavesBackground, setEnableWavesBackground } = useAnimation() + const { toast } = useToast() + + const [localCSS, setLocalCSS] = useState(themeConfig.customCSS || '') + const [cssWarnings, setCssWarnings] = useState([]) + const cssDebounceRef = useRef | null>(null) + const bgDebounceRef = useRef | null>(null) + const fileInputRef = useRef(null) + + const updateTokenSection = useCallback( + (section: K, partial: Partial) => { + updateThemeConfig({ + tokenOverrides: { + ...themeConfig.tokenOverrides, + [section]: { + ...defaultLightTokens[section], + ...themeConfig.tokenOverrides?.[section], + ...partial, + } as ThemeTokens[K], + }, + }) + }, + [themeConfig.tokenOverrides, updateThemeConfig] + ) + + const resetTokenSection = useCallback( + (section: keyof ThemeTokens) => { + const newOverrides: Partial = { ...themeConfig.tokenOverrides } + delete newOverrides[section] + updateThemeConfig({ tokenOverrides: newOverrides }) + }, + [themeConfig.tokenOverrides, updateThemeConfig] + ) + + const handleCSSChange = useCallback((val: string) => { + setLocalCSS(val) + const result = sanitizeCSS(val) + setCssWarnings(result.warnings) + + if (cssDebounceRef.current) clearTimeout(cssDebounceRef.current) + cssDebounceRef.current = setTimeout(() => { + updateThemeConfig({ customCSS: val }) + }, 500) + }, [updateThemeConfig]) + + const currentAccentHex = useMemo(() => { + if (themeConfig.accentColor) { + return hslToHex(themeConfig.accentColor) + } + return '#3b82f6' // 默认蓝色 + }, [themeConfig.accentColor]) + + const handleAccentColorChange = (e: React.ChangeEvent) => { + const hex = e.target.value + const hsl = hexToHSL(hex) + updateThemeConfig({ accentColor: hsl }) + } + + const handleResetAccent = () => { + updateThemeConfig({ accentColor: '' }) + } + + const handleExport = () => { + const json = exportThemeJSON() + const blob = new Blob([json], { type: 'application/json' }) + const url = URL.createObjectURL(blob) + const a = document.createElement('a') + a.href = url + a.download = `maibot-theme-${Date.now()}.json` + a.click() + URL.revokeObjectURL(url) + } + + const handleImport = (e: React.ChangeEvent) => { + const file = e.target.files?.[0] + if (!file) return + const reader = new FileReader() + reader.onload = (ev) => { + const json = ev.target?.result as string + const result = importThemeJSON(json) + if (result.success) { + // 导入成功后需要刷新页面使配置生效(因为 ThemeProvider 需要重新读取 localStorage) + toast({ title: '导入成功', description: '主题配置已导入,页面将自动刷新' }) + setTimeout(() => window.location.reload(), 1000) + } else { + toast({ title: '导入失败', description: result.errors.join('; '), variant: 'destructive' }) + } + } + reader.readAsText(file) + // 重置 input,允许重复选择同一文件 + e.target.value = '' + } + + const handleResetTheme = () => { + resetTheme() + setLocalCSS('') + setCssWarnings([]) + toast({ title: '重置成功', description: '主题已重置为默认值' }) + } + + const previewTokens = useMemo(() => { + return getComputedTokens(themeConfig, resolvedTheme === 'dark').color + }, [themeConfig, resolvedTheme]) + + const bgConfig: BackgroundConfigMap = themeConfig.backgroundConfig ?? {} + + const handleBgAssetChange = (layerId: keyof BackgroundConfigMap, assetId: string | undefined) => { + const current = bgConfig[layerId] ?? defaultBackgroundConfig + const newMap: BackgroundConfigMap = { + ...bgConfig, + [layerId]: { ...current, assetId, type: assetId ? 'image' : 'none' }, + } + if (bgDebounceRef.current) clearTimeout(bgDebounceRef.current) + bgDebounceRef.current = setTimeout(() => updateThemeConfig({ backgroundConfig: newMap }), 500) + } + + const handleBgEffectsChange = (layerId: keyof BackgroundConfigMap, effects: BackgroundEffects) => { + const current = bgConfig[layerId] ?? defaultBackgroundConfig + const newMap: BackgroundConfigMap = { ...bgConfig, [layerId]: { ...current, effects } } + if (bgDebounceRef.current) clearTimeout(bgDebounceRef.current) + bgDebounceRef.current = setTimeout(() => updateThemeConfig({ backgroundConfig: newMap }), 500) + } + + const handleBgCSSChange = (layerId: keyof BackgroundConfigMap, css: string) => { + const current = bgConfig[layerId] ?? defaultBackgroundConfig + const newMap: BackgroundConfigMap = { ...bgConfig, [layerId]: { ...current, customCSS: css } } + if (bgDebounceRef.current) clearTimeout(bgDebounceRef.current) + bgDebounceRef.current = setTimeout(() => updateThemeConfig({ backgroundConfig: newMap }), 500) + } + + const handleBgInheritChange = (layerId: keyof BackgroundConfigMap, inherit: boolean) => { + const current = bgConfig[layerId] ?? defaultBackgroundConfig + const newMap: BackgroundConfigMap = { ...bgConfig, [layerId]: { ...current, inherit } } + if (bgDebounceRef.current) clearTimeout(bgDebounceRef.current) + bgDebounceRef.current = setTimeout(() => updateThemeConfig({ backgroundConfig: newMap }), 500) + } + + return ( +
    + {/* 主题模式 */} +
    +

    主题模式

    +
    + + + +
    +
    + + {/* 主题色配置 */} +
    +
    +

    主题色

    + +
    + +
    + {/* 颜色选择器 */} +
    +
    +
    + +
    +
    + +

    点击色环选择或输入 HEX 值

    +
    +
    + +
    + +
    +
    + + {/* 实时色板预览 */} +
    +

    实时色板预览

    +
    + + + + + + + + +
    +
    +
    +
    + + {/* 样式微调 */} +
    +

    界面样式微调

    + + + + {/* 1. 字体排版 (Typography) */} + + 字体排版 (Typography) + +
    +
    + +
    + +
    + + +
    + +
    +
    + + + {parseFloat((themeConfig.tokenOverrides?.typography as any)?.['font-size-base'] || '1') * 16}px + +
    + { + updateTokenSection('typography', { + 'font-size-base': `${vals[0] / 16}rem`, + }) + }} + /> +
    + +
    + + +
    +
    +
    +
    + + {/* 2. 视觉效果 (Visual) */} + + 视觉效果 (Visual) + +
    +
    + +
    + +
    +
    + + + {Math.round(parseFloat((themeConfig.tokenOverrides?.visual as any)?.['radius-md'] || '0.375') * 16)}px + +
    + { + updateTokenSection('visual', { + 'radius-md': `${vals[0] / 16}rem`, + }) + }} + /> +
    + +
    + + +
    + +
    + + { + updateTokenSection('visual', { + 'blur-md': checked ? defaultLightTokens.visual['blur-md'] : '0px', + }) + }} + /> +
    +
    +
    +
    + + {/* 3. 布局 (Layout) */} + + 布局 (Layout) + +
    +
    + +
    + +
    +
    + + + {(themeConfig.tokenOverrides?.layout as any)?.['sidebar-width'] || '16rem'} + +
    + { + updateTokenSection('layout', { + 'sidebar-width': `${vals[0]}rem`, + }) + }} + /> +
    + +
    +
    + + + {(themeConfig.tokenOverrides?.layout as any)?.['max-content-width'] || '1280px'} + +
    + { + updateTokenSection('layout', { + 'max-content-width': `${vals[0]}px`, + }) + }} + /> +
    + +
    +
    + + + {(themeConfig.tokenOverrides?.layout as any)?.['space-unit'] || '0.25rem'} + +
    + { + updateTokenSection('layout', { + 'space-unit': `${vals[0]}rem`, + }) + }} + /> +
    +
    +
    +
    + + {/* 4. 动画 (Animation) */} + + 动画 (Animation) + +
    +
    + +
    + +
    + + +
    +
    +
    +
    + + {/* 5. 背景设置 (Backgrounds) */} + + 背景设置 (Backgrounds) + +
    + + + 页面 + 侧边栏 + Header + Card + Dialog + + + {(['page', 'sidebar', 'header', 'card', 'dialog'] as const).map((layerId) => ( + + {layerId !== 'page' && ( +
    +
    + +

    开启后将使用上级层级的背景配置

    +
    + handleBgInheritChange(layerId, v)} + /> +
    + )} + handleBgAssetChange(layerId, id)} + /> + handleBgEffectsChange(layerId, effects)} + /> + handleBgCSSChange(layerId, css)} + /> +
    + ))} +
    +
    +
    +
    +
    +
    + +
    +
    +
    +

    自定义 CSS

    +

    + 编写自定义 CSS 来进一步个性化界面。危险的 CSS(如 @import、url())将被自动过滤。 +

    +
    + +
    + +
    + + + {cssWarnings.length > 0 && ( +
    +
    + + 以下内容已被安全过滤: +
    +
      + {cssWarnings.map((w, i) =>
    • {w}
    • )} +
    +
    + )} +
    +
    + + {/* 动效设置 */} +
    +

    动画效果

    +
    + {/* 全局动画开关 */} +
    +
    +
    + +

    + 关闭后将禁用所有过渡动画和特效,提升性能 +

    +
    + +
    +
    + + {/* 波浪背景开关 */} +
    +
    +
    + +

    + 关闭后登录页将使用纯色背景,适合低性能设备 +

    +
    + +
    +
    +
    +
    + + {/* 主题导入/导出 */} +
    +

    主题导入/导出

    +
    +
    + {/* 导出按钮 */} + + + {/* 导入按钮 */} + + + {/* 重置按钮 */} + + + + + + + 确认重置主题 + + 这将重置所有主题设置为默认值,包括颜色、字体、布局和自定义 CSS。此操作不可撤销,确定要继续吗? + + + + 取消 + + 确认重置 + + + + +
    + + {/* 隐藏的文件输入 */} + + +

    + 导出主题为 JSON 文件便于分享或备份,导入时会自动应用所有配置。 +

    +
    +
    +
    + ) +} + +function ColorTokenPreview({ name, value, foreground, border }: { name: string, value: string, foreground?: string, border?: boolean }) { + return ( +
    +
    + Aa +
    +
    + {name} +
    +
    + ) +} diff --git a/dashboard/src/routes/settings/LibraryItem.tsx b/dashboard/src/routes/settings/LibraryItem.tsx new file mode 100644 index 00000000..2ae6f912 --- /dev/null +++ b/dashboard/src/routes/settings/LibraryItem.tsx @@ -0,0 +1,15 @@ +import { type LibraryItemProps } from './types' + +export function LibraryItem({ name, description, license }: LibraryItemProps) { + return ( +
    +
    +

    {name}

    +

    {description}

    +
    + + {license} + +
    + ) +} diff --git a/dashboard/src/routes/settings/OtherTab.tsx b/dashboard/src/routes/settings/OtherTab.tsx new file mode 100644 index 00000000..e5aed473 --- /dev/null +++ b/dashboard/src/routes/settings/OtherTab.tsx @@ -0,0 +1,513 @@ +import { AlertTriangle, Database, Download, HardDrive, RefreshCw, RotateCcw, Trash2, Upload } from 'lucide-react' +import { useRef, useState } from 'react' +import { useNavigate } from '@tanstack/react-router' + +import { cn } from '@/lib/utils' +import { fetchWithAuth } from '@/lib/fetch-with-auth' +import { Button } from '@/components/ui/button' +import { Label } from '@/components/ui/label' +import { Slider } from '@/components/ui/slider' +import { useToast } from '@/hooks/use-toast' +import { clearLocalCache, DEFAULT_SETTINGS, exportSettings, formatBytes, getSetting, getStorageUsage, importSettings, resetAllSettings, setSetting } from '@/lib/settings-manager' +import { logWebSocket } from '@/lib/log-websocket' +import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger } from '@/components/ui/alert-dialog' + +// 其他设置标签页 +export function OtherTab() { + const navigate = useNavigate() + const { toast } = useToast() + const [isResetting, setIsResetting] = useState(false) + const [shouldThrowError, setShouldThrowError] = useState(false) + + // 性能与存储设置状态 + const [logCacheSize, setLogCacheSize] = useState(() => getSetting('logCacheSize')) + const [wsReconnectInterval, setWsReconnectInterval] = useState(() => getSetting('wsReconnectInterval')) + const [wsMaxReconnectAttempts, setWsMaxReconnectAttempts] = useState(() => getSetting('wsMaxReconnectAttempts')) + const [dataSyncInterval, setDataSyncInterval] = useState(() => getSetting('dataSyncInterval')) + const [storageUsage, setStorageUsage] = useState(() => getStorageUsage()) + + // 导入/导出状态 + const [isExporting, setIsExporting] = useState(false) + const [isImporting, setIsImporting] = useState(false) + const fileInputRef = useRef(null) + + // 手动触发 React 错误 + if (shouldThrowError) { + throw new Error('这是一个手动触发的测试错误,用于验证错误边界组件是否正常工作。') + } + + // 刷新存储使用情况 + const refreshStorageUsage = () => { + setStorageUsage(getStorageUsage()) + } + + // 处理日志缓存大小变更 + const handleLogCacheSizeChange = (value: number[]) => { + const size = value[0] + setLogCacheSize(size) + setSetting('logCacheSize', size) + } + + // 处理 WebSocket 重连间隔变更 + const handleWsReconnectIntervalChange = (value: number[]) => { + const interval = value[0] + setWsReconnectInterval(interval) + setSetting('wsReconnectInterval', interval) + } + + // 处理 WebSocket 最大重连次数变更 + const handleWsMaxReconnectAttemptsChange = (value: number[]) => { + const attempts = value[0] + setWsMaxReconnectAttempts(attempts) + setSetting('wsMaxReconnectAttempts', attempts) + } + + // 处理数据同步间隔变更 + const handleDataSyncIntervalChange = (value: number[]) => { + const interval = value[0] + setDataSyncInterval(interval) + setSetting('dataSyncInterval', interval) + } + + // 清除日志缓存 + const handleClearLogCache = () => { + logWebSocket.clearLogs() + toast({ + title: '日志已清除', + description: '日志缓存已清空', + }) + } + + // 清除本地缓存 + const handleClearLocalCache = () => { + const result = clearLocalCache() + refreshStorageUsage() + toast({ + title: '缓存已清除', + description: `已清除 ${result.clearedKeys.length} 项缓存数据`, + }) + } + + // 导出设置 + const handleExportSettings = () => { + setIsExporting(true) + try { + const settings = exportSettings() + const dataStr = JSON.stringify(settings, null, 2) + const blob = new Blob([dataStr], { type: 'application/json' }) + const url = URL.createObjectURL(blob) + const a = document.createElement('a') + a.href = url + a.download = `maibot-webui-settings-${new Date().toISOString().slice(0, 10)}.json` + document.body.appendChild(a) + a.click() + document.body.removeChild(a) + URL.revokeObjectURL(url) + toast({ + title: '导出成功', + description: '设置已导出为 JSON 文件', + }) + } catch (error) { + console.error('导出设置失败:', error) + toast({ + title: '导出失败', + description: '无法导出设置', + variant: 'destructive', + }) + } finally { + setIsExporting(false) + } + } + + // 导入设置 + const handleImportSettings = (event: React.ChangeEvent) => { + const file = event.target.files?.[0] + if (!file) return + + setIsImporting(true) + const reader = new FileReader() + reader.onload = (e) => { + try { + const content = e.target?.result as string + const settings = JSON.parse(content) + const result = importSettings(settings) + + if (result.success) { + // 刷新页面状态 + setLogCacheSize(getSetting('logCacheSize')) + setWsReconnectInterval(getSetting('wsReconnectInterval')) + setWsMaxReconnectAttempts(getSetting('wsMaxReconnectAttempts')) + setDataSyncInterval(getSetting('dataSyncInterval')) + refreshStorageUsage() + + toast({ + title: '导入成功', + description: `成功导入 ${result.imported.length} 项设置${result.skipped.length > 0 ? `,跳过 ${result.skipped.length} 项` : ''}`, + }) + + // 提示用户刷新页面以应用所有更改 + if (result.imported.includes('theme') || result.imported.includes('accentColor')) { + toast({ + title: '提示', + description: '部分设置需要刷新页面才能完全生效', + }) + } + } else { + toast({ + title: '导入失败', + description: '没有有效的设置项可导入', + variant: 'destructive', + }) + } + } catch (error) { + console.error('导入设置失败:', error) + toast({ + title: '导入失败', + description: '文件格式无效', + variant: 'destructive', + }) + } finally { + setIsImporting(false) + // 清空 input,允许重复选择同一文件 + if (fileInputRef.current) { + fileInputRef.current.value = '' + } + } + } + reader.readAsText(file) + } + + // 重置所有设置 + const handleResetAllSettings = () => { + resetAllSettings() + // 刷新页面状态 + setLogCacheSize(DEFAULT_SETTINGS.logCacheSize) + setWsReconnectInterval(DEFAULT_SETTINGS.wsReconnectInterval) + setWsMaxReconnectAttempts(DEFAULT_SETTINGS.wsMaxReconnectAttempts) + setDataSyncInterval(DEFAULT_SETTINGS.dataSyncInterval) + refreshStorageUsage() + toast({ + title: '已重置', + description: '所有设置已恢复为默认值,刷新页面以应用更改', + }) + } + + const handleResetSetup = async () => { + setIsResetting(true) + + try { + // 调用后端API重置首次配置状态 + const response = await fetchWithAuth('/api/webui/setup/reset', { + method: 'POST', + }) + + const data = await response.json() + + if (response.ok && data.success) { + toast({ + title: '重置成功', + description: '即将进入初次配置向导', + }) + + // 延迟跳转到配置向导 + setTimeout(() => { + navigate({ to: '/setup' }) + }, 1000) + } else { + toast({ + title: '重置失败', + description: data.message || '无法重置配置状态', + variant: 'destructive', + }) + } + } catch (error) { + console.error('重置配置状态错误:', error) + toast({ + title: '重置失败', + description: '连接服务器失败', + variant: 'destructive', + }) + } finally { + setIsResetting(false) + } + } + + return ( +
    + {/* 性能与存储 */} +
    +

    + + 性能与存储 +

    +
    + {/* 存储使用情况 */} +
    +
    + + + 本地存储使用 + + +
    +
    {formatBytes(storageUsage.used)}
    +

    {storageUsage.items} 个存储项

    +
    + + {/* 日志缓存大小 */} +
    +
    + + {logCacheSize} 条 +
    + +

    + 控制日志查看器最多缓存的日志条数,较大的值会占用更多内存 +

    +
    + + {/* 数据刷新间隔 */} +
    +
    + + {dataSyncInterval} 秒 +
    + +

    + 控制首页统计数据的自动刷新间隔 +

    +
    + + {/* WebSocket 重连间隔 */} +
    +
    + + {wsReconnectInterval / 1000} 秒 +
    + +

    + 日志 WebSocket 连接断开后的重连基础间隔 +

    +
    + + {/* WebSocket 最大重连次数 */} +
    +
    + + {wsMaxReconnectAttempts} 次 +
    + +

    + 连接失败后的最大重连尝试次数 +

    +
    + + {/* 清理按钮 */} +
    + + + + + + + + 确认清除本地缓存 + + 这将清除所有本地缓存的设置和数据(不包括登录凭证)。 + 您可能需要重新配置部分偏好设置。确定要继续吗? + + + + 取消 + + 确认清除 + + + + +
    +
    +
    + + {/* 导入/导出设置 */} +
    +

    + + 导入/导出设置 +

    +
    +

    + 导出当前的界面设置以便备份,或从之前导出的文件中恢复设置。 +

    + +
    + + + + +
    + + {/* 重置所有设置 */} +
    + + + + + + + 确认重置所有设置 + + 这将把所有界面设置恢复为默认值,包括主题、颜色、动画等偏好设置。 + 此操作不会影响您的登录状态。确定要继续吗? + + + + 取消 + + 确认重置 + + + + +
    +
    +
    + + {/* 配置向导 */} +
    +

    配置向导

    +
    +
    +

    + 重新进行初次配置向导,可以帮助您重新设置系统的基础配置。 +

    +
    + + + + + + + 确认重新配置 + + 这将带您重新进入初次配置向导。您可以重新设置系统的基础配置项。确定要继续吗? + + + + 取消 + + 确认重置 + + + + +
    +
    + + {/* 开发者工具 */} +
    +

    + + 开发者工具 +

    +
    +
    +

    + 以下功能仅供开发调试使用,可能会导致页面崩溃或异常。 +

    +
    + + + + + + + 确认触发错误 + + 这将手动触发一个 React 错误,用于测试错误边界组件的显示效果。 + 页面将显示错误界面,您可以通过刷新页面或点击返回首页来恢复。 + + + + 取消 + setShouldThrowError(true)} + className="bg-destructive text-destructive-foreground hover:bg-destructive/90" + > + 确认触发 + + + + +
    +
    +
    + ) +} diff --git a/dashboard/src/routes/settings/SecurityTab.tsx b/dashboard/src/routes/settings/SecurityTab.tsx new file mode 100644 index 00000000..429a5a42 --- /dev/null +++ b/dashboard/src/routes/settings/SecurityTab.tsx @@ -0,0 +1,486 @@ +import { + AlertTriangle, + Check, + CheckCircle2, + Copy, + Eye, + EyeOff, + RefreshCw, + XCircle, +} from 'lucide-react' +import { useState, useMemo } from 'react' + +import { useNavigate } from '@tanstack/react-router' +import { cn } from '@/lib/utils' +import { useToast } from '@/hooks/use-toast' +import { validateToken } from '@/lib/token-validator' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog' +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from '@/components/ui/alert-dialog' + +export function SecurityTab() { + const navigate = useNavigate() + const [currentToken, setCurrentToken] = useState('') + const [newToken, setNewToken] = useState('') + const [showCurrentToken, setShowCurrentToken] = useState(false) + const [showNewToken, setShowNewToken] = useState(false) + const [isUpdating, setIsUpdating] = useState(false) + const [isRegenerating, setIsRegenerating] = useState(false) + const [copied, setCopied] = useState(false) + const [showTokenDialog, setShowTokenDialog] = useState(false) + const [generatedToken, setGeneratedToken] = useState('') + const [tokenCopied, setTokenCopied] = useState(false) + const { toast } = useToast() + + // 实时验证新 Token + const tokenValidation = useMemo(() => validateToken(newToken), [newToken]) + + // 复制 token 到剪贴板 + const copyToClipboard = async (text: string) => { + if (!currentToken) { + toast({ + title: '无法复制', + description: 'Token 存储在安全 Cookie 中,请重新生成以获取新 Token', + variant: 'destructive', + }) + return + } + try { + await navigator.clipboard.writeText(text) + setCopied(true) + toast({ + title: '复制成功', + description: 'Token 已复制到剪贴板', + }) + setTimeout(() => setCopied(false), 2000) + } catch { + toast({ + title: '复制失败', + description: '请手动复制 Token', + variant: 'destructive', + }) + } + } + + // 更新 token + const handleUpdateToken = async () => { + if (!newToken.trim()) { + toast({ + title: '输入错误', + description: '请输入新的 Token', + variant: 'destructive', + }) + return + } + + // 验证 Token 格式 + if (!tokenValidation.isValid) { + const failedRules = tokenValidation.rules + .filter((rule) => !rule.passed) + .map((rule) => rule.label) + .join(', ') + + toast({ + title: '格式错误', + description: `Token 不符合要求: ${failedRules}`, + variant: 'destructive', + }) + return + } + + setIsUpdating(true) + + try { + const response = await fetch('/api/webui/auth/update', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + credentials: 'include', // 使用 Cookie 认证 + body: JSON.stringify({ new_token: newToken.trim() }), + }) + + const data = await response.json() + + if (response.ok && data.success) { + // 清空输入框 + setNewToken('') + + // 更新当前显示的 Token + setCurrentToken(newToken.trim()) + + toast({ + title: '更新成功', + description: 'Access Token 已更新,即将跳转到登录页', + }) + + // 延迟跳转到登录页 + setTimeout(() => { + navigate({ to: '/auth' }) + }, 1500) + } else { + toast({ + title: '更新失败', + description: data.message || '无法更新 Token', + variant: 'destructive', + }) + } + } catch (err) { + console.error('更新 Token 错误:', err) + toast({ + title: '更新失败', + description: '连接服务器失败', + variant: 'destructive', + }) + } finally { + setIsUpdating(false) + } + } + + // 重新生成 token (实际执行函数) + const executeRegenerateToken = async () => { + setIsRegenerating(true) + + try { + const response = await fetch('/api/webui/auth/regenerate', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + credentials: 'include', // 使用 Cookie 认证 + }) + + const data = await response.json() + + if (response.ok && data.success) { + // 更新当前显示的 Token + setCurrentToken(data.token) + + // 显示弹窗展示新 Token + setGeneratedToken(data.token) + setShowTokenDialog(true) + setTokenCopied(false) + + toast({ + title: '生成成功', + description: '新的 Access Token 已生成,请及时保存', + }) + } else { + toast({ + title: '生成失败', + description: data.message || '无法生成新 Token', + variant: 'destructive', + }) + } + } catch (err) { + console.error('生成 Token 错误:', err) + toast({ + title: '生成失败', + description: '连接服务器失败', + variant: 'destructive', + }) + } finally { + setIsRegenerating(false) + } + } + + // 复制生成的 Token + const copyGeneratedToken = async () => { + try { + await navigator.clipboard.writeText(generatedToken) + setTokenCopied(true) + toast({ + title: '复制成功', + description: 'Token 已复制到剪贴板', + }) + } catch { + toast({ + title: '复制失败', + description: '请手动复制 Token', + variant: 'destructive', + }) + } + } + + // 关闭弹窗 + const handleCloseDialog = () => { + setShowTokenDialog(false) + // 延迟清空 token,避免用户看到内容消失 + setTimeout(() => { + setGeneratedToken('') + setTokenCopied(false) + }, 300) + + // 跳转到登录页 + setTimeout(() => { + navigate({ to: '/auth' }) + }, 500) + } + + // 处理对话框状态变化(包括点击外部、ESC 等关闭方式) + const handleDialogOpenChange = (open: boolean) => { + if (!open) { + handleCloseDialog() + } + } + + return ( +
    + {/* Token 生成成功弹窗 */} + + + + + + 新的 Access Token + + + 这是您的新 Token,请立即保存。关闭此窗口后将跳转到登录页面。 + + + +
    + {/* Token 显示区域 */} +
    + +
    + {generatedToken} +
    +
    + + {/* 警告提示 */} +
    +
    + +
    +

    重要提示

    +
      +
    • 此 Token 仅显示一次,关闭后无法再查看
    • +
    • 请立即复制并保存到安全的位置
    • +
    • 关闭窗口后将自动跳转到登录页面
    • +
    • 请使用新 Token 重新登录系统
    • +
    +
    +
    +
    +
    + + + + + +
    +
    + + {/* 当前 Token */} +
    +

    当前 Access Token

    +
    +
    + +
    +
    + + +
    +
    + + + + + + + + 确认重新生成 Token + + 这将生成一个新的 64 位安全令牌,并使当前 Token 立即失效。 + 您需要使用新 Token 重新登录系统。此操作不可撤销,确定要继续吗? + + + + 取消 + + 确认生成 + + + + +
    +
    +

    + 请妥善保管您的 Access Token,不要泄露给他人 +

    +
    +
    +
    + + {/* 更新 Token */} +
    +

    自定义 Access Token

    +
    +
    + +
    + setNewToken(e.target.value)} + className="pr-10 font-mono text-sm" + placeholder="输入自定义 Token" + /> + +
    + + {/* Token 验证规则显示 */} + {newToken && ( +
    +

    Token 安全要求:

    +
    + {tokenValidation.rules.map((rule) => ( +
    + {rule.passed ? ( + + ) : ( + + )} + + {rule.label} + +
    + ))} +
    + {tokenValidation.isValid && ( +
    +
    + + Token 格式正确,可以使用 +
    +
    + )} +
    + )} +
    + +
    +
    + + {/* 安全提示 */} +
    +

    安全提示

    +
      +
    • 重新生成 Token 会创建系统随机生成的 64 位安全令牌
    • +
    • 自定义 Token 必须满足所有安全要求才能使用
    • +
    • 更新 Token 后,旧的 Token 将立即失效
    • +
    • 请在安全的环境下查看和复制 Token
    • +
    • 如果怀疑 Token 泄露,请立即重新生成或更新
    • +
    • 建议使用系统生成的 Token 以获得最高安全性
    • +
    +
    +
    + ) +} diff --git a/dashboard/src/routes/settings/ThemeOption.tsx b/dashboard/src/routes/settings/ThemeOption.tsx new file mode 100644 index 00000000..50e4e48b --- /dev/null +++ b/dashboard/src/routes/settings/ThemeOption.tsx @@ -0,0 +1,51 @@ +import { cn } from '@/lib/utils' + +import { type ThemeOptionProps } from './types' + +export function ThemeOption({ value, current, onChange, label, description }: ThemeOptionProps) { + const isSelected = current === value + + return ( + + ) +} diff --git a/dashboard/src/routes/settings/index.tsx b/dashboard/src/routes/settings/index.tsx new file mode 100644 index 00000000..833f9206 --- /dev/null +++ b/dashboard/src/routes/settings/index.tsx @@ -0,0 +1,63 @@ +import { Info, Palette, Settings, Shield } from 'lucide-react' + +import { ScrollArea } from '@/components/ui/scroll-area' +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs' + +import { AboutTab } from './AboutTab' +import { AppearanceTab } from './AppearanceTab' +import { OtherTab } from './OtherTab' +import { SecurityTab } from './SecurityTab' + +export function SettingsPage() { + return ( +
    + {/* 页面标题 */} +
    +
    +

    系统设置

    +

    管理您的应用偏好设置

    +
    +
    + + {/* 标签页 */} + + + + + 外观 + + + + 安全 + + + + 其他 + + + + 关于 + + + + + + + + + + + + + + + + + + + + + +
    + ) +} diff --git a/dashboard/src/routes/settings/types.ts b/dashboard/src/routes/settings/types.ts new file mode 100644 index 00000000..a9e2a5d6 --- /dev/null +++ b/dashboard/src/routes/settings/types.ts @@ -0,0 +1,51 @@ +function hslToHex(hsl: string): string { + if (!hsl) return '#000000' + + // 解析 "221.2 83.2% 53.3%" 格式 + const parts = hsl.split(' ').filter(Boolean) + if (parts.length < 3) return '#000000' + + const h = parseFloat(parts[0]) + const s = parseFloat(parts[1].replace('%', '')) + const l = parseFloat(parts[2].replace('%', '')) + + const sDecimal = s / 100 + const lDecimal = l / 100 + + const c = (1 - Math.abs(2 * lDecimal - 1)) * sDecimal + const x = c * (1 - Math.abs(((h / 60) % 2) - 1)) + const m = lDecimal - c / 2 + + let r = 0, g = 0, b = 0 + + if (h >= 0 && h < 60) { r = c; g = x; b = 0 } + else if (h >= 60 && h < 120) { r = x; g = c; b = 0 } + else if (h >= 120 && h < 180) { r = 0; g = c; b = x } + else if (h >= 180 && h < 240) { r = 0; g = x; b = c } + else if (h >= 240 && h < 300) { r = x; g = 0; b = c } + else if (h >= 300 && h < 360) { r = c; g = 0; b = x } + + const toHex = (n: number) => { + const hex = Math.round((n + m) * 255).toString(16) + return hex.length === 1 ? '0' + hex : hex + } + + return `#${toHex(r)}${toHex(g)}${toHex(b)}` +} + +type LibraryItemProps = { + name: string + description: string + license: string +} + +type ThemeOptionProps = { + value: 'light' | 'dark' | 'system' + current: 'light' | 'dark' | 'system' + onChange: (theme: 'light' | 'dark' | 'system') => void + label: string + description: string +} + +export { hslToHex } +export type { LibraryItemProps, ThemeOptionProps } From 34b05e4e160dfba264d9d1349c82f3785024190d Mon Sep 17 00:00:00 2001 From: DrSmoothl <1787882683@qq.com> Date: Sun, 1 Mar 2026 19:09:08 +0800 Subject: [PATCH 13/26] fix(hooks): remove duplicate save calls in useAutoSave legacy --- dashboard/src/routes/config/bot/hooks/useAutoSave.ts | 4 ---- 1 file changed, 4 deletions(-) diff --git a/dashboard/src/routes/config/bot/hooks/useAutoSave.ts b/dashboard/src/routes/config/bot/hooks/useAutoSave.ts index b6871011..3d8bf7e1 100644 --- a/dashboard/src/routes/config/bot/hooks/useAutoSave.ts +++ b/dashboard/src/routes/config/bot/hooks/useAutoSave.ts @@ -194,10 +194,6 @@ export function useAutoSave( } setHasUnsavedChanges(false) onSaveSuccess?.() - setAutoSaving(true) - await updateBotConfigSection(sectionName, sectionData) - setHasUnsavedChanges(false) - onSaveSuccess?.() } catch (error) { console.error(`自动保存 ${sectionName} 失败:`, error) setHasUnsavedChanges(true) From f334d9882da8e33bf53bd3e991db31a8ffae6dee Mon Sep 17 00:00:00 2001 From: DrSmoothl <1787882683@qq.com> Date: Sun, 1 Mar 2026 19:19:22 +0800 Subject: [PATCH 14/26] refactor(dashboard): split chat.tsx into modular chat/ directory - Extract types, utils, and components into separate files - types.ts: All interfaces and type definitions (107 lines) - utils.ts: Pure utility functions (50 lines) - MessageRenderer.tsx: Message rendering components (96 lines) - VirtualIdentityDialog.tsx: Virtual identity dialog (206 lines) - ChatTabBar.tsx: Tab bar component (79 lines) - index.tsx: Main ChatPage component (1147 lines) - Update router import path to chat/index - Fix TypeScript type imports per verbatimModuleSyntax - Build passes with zero errors --- dashboard/src/router.tsx | 2 +- dashboard/src/routes/chat/ChatTabBar.tsx | 79 +++ dashboard/src/routes/chat/MessageRenderer.tsx | 96 ++++ .../src/routes/chat/VirtualIdentityDialog.tsx | 206 +++++++ .../src/routes/{chat.tsx => chat/index.tsx} | 509 ++---------------- dashboard/src/routes/chat/types.ts | 106 ++++ dashboard/src/routes/chat/utils.ts | 50 ++ 7 files changed, 571 insertions(+), 477 deletions(-) create mode 100644 dashboard/src/routes/chat/ChatTabBar.tsx create mode 100644 dashboard/src/routes/chat/MessageRenderer.tsx create mode 100644 dashboard/src/routes/chat/VirtualIdentityDialog.tsx rename dashboard/src/routes/{chat.tsx => chat/index.tsx} (71%) create mode 100644 dashboard/src/routes/chat/types.ts create mode 100644 dashboard/src/routes/chat/utils.ts diff --git a/dashboard/src/router.tsx b/dashboard/src/router.tsx index bf9e2835..4ef072b2 100644 --- a/dashboard/src/router.tsx +++ b/dashboard/src/router.tsx @@ -22,7 +22,7 @@ import { ModelPresetsPage } from './routes/model-presets' import { PluginConfigPage } from './routes/plugin-config' import { PluginMirrorsPage } from './routes/plugin-mirrors' import { PluginDetailPage } from './routes/plugin-detail' -import { ChatPage } from './routes/chat' +import { ChatPage } from './routes/chat/index' import { WebUIFeedbackSurveyPage, MaiBotFeedbackSurveyPage } from './routes/survey' import { AnnualReportPage } from './routes/annual-report' import PackMarketPage from './routes/config/pack-market' diff --git a/dashboard/src/routes/chat/ChatTabBar.tsx b/dashboard/src/routes/chat/ChatTabBar.tsx new file mode 100644 index 00000000..0c867e87 --- /dev/null +++ b/dashboard/src/routes/chat/ChatTabBar.tsx @@ -0,0 +1,79 @@ +import { cn } from '@/lib/utils' +import { MessageSquare, Plus, UserCircle2, X } from 'lucide-react' + +import type { ChatTab } from './types' + +interface ChatTabBarProps { + tabs: ChatTab[] + activeTabId: string + onSwitch: (tabId: string) => void + onClose: (tabId: string, e?: React.MouseEvent) => void + onAddVirtual: () => void +} + +export function ChatTabBar({ + tabs, + activeTabId, + onSwitch, + onClose, + onAddVirtual, +}: ChatTabBarProps) { + return ( +
    +
    +
    + {tabs.map((tab) => ( +
    onSwitch(tab.id)} + > + {tab.type === 'webui' ? ( + + ) : ( + + )} + {tab.label} + {/* 连接状态指示器 */} + + {/* 关闭按钮(非默认标签页) */} + {tab.id !== 'webui-default' && ( + onClose(tab.id, e)} + className="ml-0.5 p-0.5 rounded hover:bg-muted-foreground/20 cursor-pointer" + role="button" + tabIndex={0} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault() + onClose(tab.id, e as any) + } + }} + > + + + )} +
    + ))} + {/* 新建虚拟身份标签页按钮 */} + +
    +
    +
    + ) +} diff --git a/dashboard/src/routes/chat/MessageRenderer.tsx b/dashboard/src/routes/chat/MessageRenderer.tsx new file mode 100644 index 00000000..32026db5 --- /dev/null +++ b/dashboard/src/routes/chat/MessageRenderer.tsx @@ -0,0 +1,96 @@ +import { cn } from '@/lib/utils' + +import type { ChatMessage, MessageSegment } from './types' + +// 渲染单个消息段 +export function RenderMessageSegment({ segment }: { segment: MessageSegment }) { + switch (segment.type) { + case 'text': + return {String(segment.data)} + + case 'image': + case 'emoji': + return ( + {segment.type { + // 图片加载失败时显示占位符 + const target = e.target as HTMLImageElement + target.style.display = 'none' + target.parentElement?.insertAdjacentHTML( + 'beforeend', + `[${segment.type === 'emoji' ? '表情包' : '图片'}加载失败]` + ) + }} + /> + ) + + case 'voice': + return ( +
    + +
    + ) + + case 'video': + return ( + + ) + + case 'face': + // QQ 原生表情,显示为文本 + return [表情:{String(segment.data)}] + + case 'music': + return [音乐分享] + + case 'file': + return [文件: {String(segment.data)}] + + case 'reply': + return [回复消息] + + case 'forward': + return [转发消息] + + case 'unknown': + default: + return [{segment.original_type || '未知消息'}] + } +} + +// 渲染消息内容(支持富文本) +// eslint-disable-next-line @typescript-eslint/no-unused-vars +export function RenderMessageContent({ message, isBot: _isBot }: { message: ChatMessage; isBot: boolean }) { + // 如果是富文本消息,渲染消息段 + if (message.message_type === 'rich' && message.segments && message.segments.length > 0) { + return ( +
    + {message.segments.map((segment, index) => ( + + ))} +
    + ) + } + + // 普通文本消息 + return {message.content} +} diff --git a/dashboard/src/routes/chat/VirtualIdentityDialog.tsx b/dashboard/src/routes/chat/VirtualIdentityDialog.tsx new file mode 100644 index 00000000..a150a746 --- /dev/null +++ b/dashboard/src/routes/chat/VirtualIdentityDialog.tsx @@ -0,0 +1,206 @@ +import { Avatar, AvatarFallback } from '@/components/ui/avatar' +import { Button } from '@/components/ui/button' +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog" +import { Input } from '@/components/ui/input' +import { Label } from "@/components/ui/label" +import { ScrollArea } from '@/components/ui/scroll-area' +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select" +import { cn } from '@/lib/utils' +import { Globe, Loader2, Search, UserCircle2, Users } from 'lucide-react' + +import type { PersonInfo, PlatformInfo, VirtualIdentityConfig } from './types' + +interface VirtualIdentityDialogProps { + open: boolean + onOpenChange: (open: boolean) => void + platforms: PlatformInfo[] + persons: PersonInfo[] + isLoadingPlatforms: boolean + isLoadingPersons: boolean + personSearchQuery: string + setPersonSearchQuery: (query: string) => void + tempVirtualConfig: VirtualIdentityConfig + setTempVirtualConfig: React.Dispatch> + onSelectPerson: (person: PersonInfo) => void + onCreateVirtualTab: () => void +} + +export function VirtualIdentityDialog({ + open, + onOpenChange, + platforms, + persons, + isLoadingPlatforms, + isLoadingPersons, + personSearchQuery, + setPersonSearchQuery, + tempVirtualConfig, + setTempVirtualConfig, + onSelectPerson, + onCreateVirtualTab, +}: VirtualIdentityDialogProps) { + return ( + + + + + + 新建虚拟身份对话 + + + 选择一个麦麦已认识的用户,以该用户的身份与麦麦对话。麦麦将使用她对该用户的记忆和认知来回应。 + + + +
    + {/* 平台选择 */} +
    + + +
    + + {/* 用户搜索和选择 */} + {tempVirtualConfig.platform && ( +
    + +
    + + setPersonSearchQuery(e.target.value)} + className="pl-9" + /> +
    + +
    + {isLoadingPersons ? ( +
    + +
    + ) : persons.length === 0 ? ( +
    + +

    没有找到用户

    +
    + ) : ( +
    + {persons.map((person) => ( + + ))} +
    + )} +
    +
    +
    + )} + + {/* 虚拟群名配置 */} + {tempVirtualConfig.personId && ( +
    + + setTempVirtualConfig(prev => ({ + ...prev, + groupName: e.target.value + }))} + /> +

    + 麦麦会认为这是一个名为此名称的群聊 +

    +
    + )} +
    + + + + + +
    +
    + ) +} diff --git a/dashboard/src/routes/chat.tsx b/dashboard/src/routes/chat/index.tsx similarity index 71% rename from dashboard/src/routes/chat.tsx rename to dashboard/src/routes/chat/index.tsx index 3ab26faa..a770ebc6 100644 --- a/dashboard/src/routes/chat.tsx +++ b/dashboard/src/routes/chat/index.tsx @@ -1,277 +1,19 @@ import { useState, useRef, useEffect, useCallback } from 'react' -import { fetchWithAuth } from '@/lib/fetch-with-auth' -import { ScrollArea } from '@/components/ui/scroll-area' + +import { Avatar, AvatarFallback } from '@/components/ui/avatar' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' -// Card 组件已移除,改用更简洁的全屏布局 -import { Avatar, AvatarFallback } from '@/components/ui/avatar' -import { Send, Bot, User, Loader2, WifiOff, Wifi, RefreshCw, Edit2, Users, Search, X, UserCircle2, Globe, Plus, MessageSquare } from 'lucide-react' -import { cn } from '@/lib/utils' +import { ScrollArea } from '@/components/ui/scroll-area' import { useToast } from '@/hooks/use-toast' -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/ui/select" -import { - Dialog, - DialogContent, - DialogDescription, - DialogHeader, - DialogTitle, - DialogFooter, -} from "@/components/ui/dialog" -import { Label } from "@/components/ui/label" +import { fetchWithAuth } from '@/lib/fetch-with-auth' +import { cn } from '@/lib/utils' +import { Bot, Edit2, Loader2, RefreshCw, User, Send, Wifi, WifiOff, UserCircle2 } from 'lucide-react' -// 生成唯一用户 ID -function generateUserId(): string { - return 'webui_' + Math.random().toString(36).substr(2, 9) + '_' + Date.now().toString(36) -} - -// 从 localStorage 获取或生成用户 ID -function getOrCreateUserId(): string { - const storageKey = 'maibot_webui_user_id' - let userId = localStorage.getItem(storageKey) - if (!userId) { - userId = generateUserId() - localStorage.setItem(storageKey, userId) - } - return userId -} - -// 从 localStorage 获取用户昵称 -function getStoredUserName(): string { - return localStorage.getItem('maibot_webui_user_name') || 'WebUI用户' -} - -// 保存用户昵称到 localStorage -function saveUserName(name: string): void { - localStorage.setItem('maibot_webui_user_name', name) -} - -// 虚拟标签页持久化存储 key -const VIRTUAL_TABS_STORAGE_KEY = 'maibot_webui_virtual_tabs' - -// 保存的虚拟标签页配置 -interface SavedVirtualTab { - id: string - label: string - virtualConfig: VirtualIdentityConfig - createdAt: number -} - -// 从 localStorage 获取保存的虚拟标签页 -function getSavedVirtualTabs(): SavedVirtualTab[] { - try { - const saved = localStorage.getItem(VIRTUAL_TABS_STORAGE_KEY) - if (saved) { - return JSON.parse(saved) - } - } catch (e) { - console.error('[Chat] 加载虚拟标签页失败:', e) - } - return [] -} - -// 保存虚拟标签页到 localStorage -function saveVirtualTabs(tabs: SavedVirtualTab[]): void { - try { - localStorage.setItem(VIRTUAL_TABS_STORAGE_KEY, JSON.stringify(tabs)) - } catch (e) { - console.error('[Chat] 保存虚拟标签页失败:', e) - } -} - -// 平台信息类型 -interface PlatformInfo { - platform: string - count: number -} - -// 用户信息类型(从后端获取的人物信息) -interface PersonInfo { - person_id: string - user_id: string - person_name: string - nickname: string | null - platform: string - is_known: boolean -} - -// 虚拟身份配置 -interface VirtualIdentityConfig { - platform: string - personId: string - userId: string - userName: string - groupName: string - groupId: string // 虚拟群 ID,用于持久化历史记录 -} - -// 聊天标签页 -interface ChatTab { - id: string - type: 'webui' | 'virtual' - label: string - virtualConfig?: VirtualIdentityConfig - messages: ChatMessage[] - isConnected: boolean - isTyping: boolean - sessionInfo: { - session_id?: string - user_id?: string - user_name?: string - bot_name?: string - } -} - -// 消息段类型 -interface MessageSegment { - type: 'text' | 'image' | 'emoji' | 'face' | 'voice' | 'video' | 'music' | 'file' | 'reply' | 'forward' | 'unknown' - data: string | number | object - original_type?: string -} - -// 消息类型 -interface ChatMessage { - id: string - type: 'user' | 'bot' | 'system' | 'error' | 'thinking' - content: string - timestamp: number - message_type?: 'text' | 'rich' // 消息格式类型 - segments?: MessageSegment[] // 富文本消息段 - sender?: { - name: string - user_id?: string - is_bot?: boolean - } -} - -// WebSocket 消息类型 -interface WsMessage { - type: string - content?: string - message_id?: string - timestamp?: number - is_typing?: boolean - session_id?: string - user_id?: string - user_name?: string - bot_name?: string - sender?: { - name: string - user_id?: string - is_bot?: boolean - } - // 历史消息列表(用于 type: 'history') - messages?: Array<{ - id?: string - content: string - timestamp: number - sender_name?: string - sender_id?: string - is_bot?: boolean - }> - group_id?: string - // 富文本消息 - message_type?: string - segments?: MessageSegment[] -} - -// 渲染单个消息段 -function RenderMessageSegment({ segment }: { segment: MessageSegment }) { - switch (segment.type) { - case 'text': - return {String(segment.data)} - - case 'image': - case 'emoji': - return ( - {segment.type { - // 图片加载失败时显示占位符 - const target = e.target as HTMLImageElement - target.style.display = 'none' - target.parentElement?.insertAdjacentHTML( - 'beforeend', - `[${segment.type === 'emoji' ? '表情包' : '图片'}加载失败]` - ) - }} - /> - ) - - case 'voice': - return ( -
    - -
    - ) - - case 'video': - return ( - - ) - - case 'face': - // QQ 原生表情,显示为文本 - return [表情:{String(segment.data)}] - - case 'music': - return [音乐分享] - - case 'file': - return [文件: {String(segment.data)}] - - case 'reply': - return [回复消息] - - case 'forward': - return [转发消息] - - case 'unknown': - default: - return [{segment.original_type || '未知消息'}] - } -} - -// 渲染消息内容(支持富文本) -// eslint-disable-next-line @typescript-eslint/no-unused-vars -function RenderMessageContent({ message, isBot: _isBot }: { message: ChatMessage; isBot: boolean }) { - // 如果是富文本消息,渲染消息段 - if (message.message_type === 'rich' && message.segments && message.segments.length > 0) { - return ( -
    - {message.segments.map((segment, index) => ( - - ))} -
    - ) - } - - // 普通文本消息 - return {message.content} -} +import { ChatTabBar } from './ChatTabBar' +import { RenderMessageContent } from './MessageRenderer' +import type { ChatTab, ChatMessage, PersonInfo, PlatformInfo, SavedVirtualTab, VirtualIdentityConfig, WsMessage } from './types' +import { getOrCreateUserId, getStoredUserName, getSavedVirtualTabs, saveUserName, saveVirtualTabs } from './utils' +import { VirtualIdentityDialog } from './VirtualIdentityDialog' export function ChatPage() { // 默认 WebUI 标签页 @@ -685,7 +427,7 @@ export function ChatPage() { type: 'bot', content: data.content || '', message_type: (data.message_type === 'rich' ? 'rich' : 'text') as 'text' | 'rich', - segments: data.segments as MessageSegment[] | undefined, + segments: data.segments, timestamp: data.timestamp || Date.now() / 1000, sender: data.sender, } @@ -1129,214 +871,29 @@ export function ChatPage() { return (
    {/* 虚拟身份配置对话框 */} - - - - - - 新建虚拟身份对话 - - - 选择一个麦麦已认识的用户,以该用户的身份与麦麦对话。麦麦将使用她对该用户的记忆和认知来回应。 - - - -
    - {/* 平台选择 */} -
    - - -
    - - {/* 用户搜索和选择 */} - {tempVirtualConfig.platform && ( -
    - -
    - - setPersonSearchQuery(e.target.value)} - className="pl-9" - /> -
    - -
    - {isLoadingPersons ? ( -
    - -
    - ) : persons.length === 0 ? ( -
    - -

    没有找到用户

    -
    - ) : ( -
    - {persons.map((person) => ( - - ))} -
    - )} -
    -
    -
    - )} - - {/* 虚拟群名配置 */} - {tempVirtualConfig.personId && ( -
    - - setTempVirtualConfig(prev => ({ - ...prev, - groupName: e.target.value - }))} - /> -

    - 麦麦会认为这是一个名为此名称的群聊 -

    -
    - )} -
    - - - - - -
    -
    + {/* 标签页栏 */} -
    -
    -
    - {tabs.map((tab) => ( -
    switchTab(tab.id)} - > - {tab.type === 'webui' ? ( - - ) : ( - - )} - {tab.label} - {/* 连接状态指示器 */} - - {/* 关闭按钮(非默认标签页) */} - {tab.id !== 'webui-default' && ( - closeTab(tab.id, e)} - className="ml-0.5 p-0.5 rounded hover:bg-muted-foreground/20 cursor-pointer" - role="button" - tabIndex={0} - onKeyDown={(e) => { - if (e.key === 'Enter' || e.key === ' ') { - e.preventDefault() - closeTab(tab.id, e as any) - } - }} - > - - - )} -
    - ))} - {/* 新建虚拟身份标签页按钮 */} - -
    -
    -
    + {/* 头部信息栏 */}
    diff --git a/dashboard/src/routes/chat/types.ts b/dashboard/src/routes/chat/types.ts new file mode 100644 index 00000000..a8778ba6 --- /dev/null +++ b/dashboard/src/routes/chat/types.ts @@ -0,0 +1,106 @@ +// 虚拟标签页持久化存储 key +export const VIRTUAL_TABS_STORAGE_KEY = 'maibot_webui_virtual_tabs' + +// 保存的虚拟标签页配置 +export interface SavedVirtualTab { + id: string + label: string + virtualConfig: VirtualIdentityConfig + createdAt: number +} + +// 平台信息类型 +export interface PlatformInfo { + platform: string + count: number +} + +// 用户信息类型(从后端获取的人物信息) +export interface PersonInfo { + person_id: string + user_id: string + person_name: string + nickname: string | null + platform: string + is_known: boolean +} + +// 虚拟身份配置 +export interface VirtualIdentityConfig { + platform: string + personId: string + userId: string + userName: string + groupName: string + groupId: string // 虚拟群 ID,用于持久化历史记录 +} + +// 聊天标签页 +export interface ChatTab { + id: string + type: 'webui' | 'virtual' + label: string + virtualConfig?: VirtualIdentityConfig + messages: ChatMessage[] + isConnected: boolean + isTyping: boolean + sessionInfo: { + session_id?: string + user_id?: string + user_name?: string + bot_name?: string + } +} + +// 消息段类型 +export interface MessageSegment { + type: 'text' | 'image' | 'emoji' | 'face' | 'voice' | 'video' | 'music' | 'file' | 'reply' | 'forward' | 'unknown' + data: string | number | object + original_type?: string +} + +// 消息类型 +export interface ChatMessage { + id: string + type: 'user' | 'bot' | 'system' | 'error' | 'thinking' + content: string + timestamp: number + message_type?: 'text' | 'rich' // 消息格式类型 + segments?: MessageSegment[] // 富文本消息段 + sender?: { + name: string + user_id?: string + is_bot?: boolean + } +} + +// WebSocket 消息类型 +export interface WsMessage { + type: string + content?: string + message_id?: string + timestamp?: number + is_typing?: boolean + session_id?: string + user_id?: string + user_name?: string + bot_name?: string + sender?: { + name: string + user_id?: string + is_bot?: boolean + } + // 历史消息列表(用于 type: 'history') + messages?: Array<{ + id?: string + content: string + timestamp: number + sender_name?: string + sender_id?: string + is_bot?: boolean + }> + group_id?: string + // 富文本消息 + message_type?: string + segments?: MessageSegment[] +} diff --git a/dashboard/src/routes/chat/utils.ts b/dashboard/src/routes/chat/utils.ts new file mode 100644 index 00000000..46bfb05d --- /dev/null +++ b/dashboard/src/routes/chat/utils.ts @@ -0,0 +1,50 @@ +import { VIRTUAL_TABS_STORAGE_KEY } from './types' +import type { SavedVirtualTab } from './types' + +// 生成唯一用户 ID +export function generateUserId(): string { + return 'webui_' + Math.random().toString(36).substr(2, 9) + '_' + Date.now().toString(36) +} + +// 从 localStorage 获取或生成用户 ID +export function getOrCreateUserId(): string { + const storageKey = 'maibot_webui_user_id' + let userId = localStorage.getItem(storageKey) + if (!userId) { + userId = generateUserId() + localStorage.setItem(storageKey, userId) + } + return userId +} + +// 从 localStorage 获取用户昵称 +export function getStoredUserName(): string { + return localStorage.getItem('maibot_webui_user_name') || 'WebUI用户' +} + +// 保存用户昵称到 localStorage +export function saveUserName(name: string): void { + localStorage.setItem('maibot_webui_user_name', name) +} + +// 从 localStorage 获取保存的虚拟标签页 +export function getSavedVirtualTabs(): SavedVirtualTab[] { + try { + const saved = localStorage.getItem(VIRTUAL_TABS_STORAGE_KEY) + if (saved) { + return JSON.parse(saved) + } + } catch (e) { + console.error('[Chat] 加载虚拟标签页失败:', e) + } + return [] +} + +// 保存虚拟标签页到 localStorage +export function saveVirtualTabs(tabs: SavedVirtualTab[]): void { + try { + localStorage.setItem(VIRTUAL_TABS_STORAGE_KEY, JSON.stringify(tabs)) + } catch (e) { + console.error('[Chat] 保存虚拟标签页失败:', e) + } +} From feb2b89917a7c49c29dc2a2f758e84b1a397eb2e Mon Sep 17 00:00:00 2001 From: DrSmoothl <1787882683@qq.com> Date: Sun, 1 Mar 2026 19:30:47 +0800 Subject: [PATCH 15/26] refactor(routes): split plugins.tsx into modular plugins/ directory - Extract types.ts: Plugin types and category name mapping - Extract PluginCard.tsx: Single plugin card component - Extract MarketplaceTab.tsx: All plugins marketplace view - Extract InstalledTab.tsx: Installed plugins view - Extract InstallDialog.tsx: Plugin installation dialog with branch selection - Create index.tsx: Main PluginsPage with WebSocket state management - Delete original 1244-line plugins.tsx - Maintain full functionality, zero logic changes - Build verified: bun run build passes with zero errors --- .../src/routes/plugins/InstallDialog.tsx | 146 +++++ dashboard/src/routes/plugins/InstalledTab.tsx | 87 +++ .../src/routes/plugins/MarketplaceTab.tsx | 83 +++ dashboard/src/routes/plugins/PluginCard.tsx | 235 +++++++ .../routes/{plugins.tsx => plugins/index.tsx} | 585 ++++-------------- dashboard/src/routes/plugins/types.ts | 18 + 6 files changed, 689 insertions(+), 465 deletions(-) create mode 100644 dashboard/src/routes/plugins/InstallDialog.tsx create mode 100644 dashboard/src/routes/plugins/InstalledTab.tsx create mode 100644 dashboard/src/routes/plugins/MarketplaceTab.tsx create mode 100644 dashboard/src/routes/plugins/PluginCard.tsx rename dashboard/src/routes/{plugins.tsx => plugins/index.tsx} (56%) create mode 100644 dashboard/src/routes/plugins/types.ts diff --git a/dashboard/src/routes/plugins/InstallDialog.tsx b/dashboard/src/routes/plugins/InstallDialog.tsx new file mode 100644 index 00000000..1b0f4891 --- /dev/null +++ b/dashboard/src/routes/plugins/InstallDialog.tsx @@ -0,0 +1,146 @@ +import { useState } from 'react' +import { Button } from '@/components/ui/button' +import { Checkbox } from '@/components/ui/checkbox' +import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog' +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' +import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs' +import { Download } from 'lucide-react' + +import type { PluginInfo } from './types' + +interface InstallDialogProps { + open: boolean + plugin: PluginInfo | null + onOpenChange: (open: boolean) => void + onInstall: (branch: string) => void +} + +export function InstallDialog({ open, plugin, onOpenChange, onInstall }: InstallDialogProps) { + const [selectedBranch, setSelectedBranch] = useState('main') + const [customBranch, setCustomBranch] = useState('') + const [branchInputMode, setBranchInputMode] = useState<'preset' | 'custom'>('preset') + const [showAdvancedOptions, setShowAdvancedOptions] = useState(false) + + const handleInstall = () => { + const branch = branchInputMode === 'custom' ? customBranch : selectedBranch + + if (!branch || branch.trim() === '') { + return + } + + onInstall(branch) + onOpenChange(false) + } + + return ( + + + + 安装插件 + + 安装 {plugin?.manifest.name} + + + +
    + {/* 基本信息 */} +
    +

    + 版本: {plugin?.manifest.version} +

    +

    + 作者: {typeof plugin?.manifest.author === 'string' + ? plugin.manifest.author + : plugin?.manifest.author?.name} +

    +
    + + {/* 高级选项开关 */} +
    + setShowAdvancedOptions(checked as boolean)} + /> + +
    + + {/* 高级选项内容 */} + {showAdvancedOptions && ( +
    +
    + + + setBranchInputMode(value as 'preset' | 'custom')}> + + 预设分支 + 自定义分支 + + + {/* 预设分支选择 */} + {branchInputMode === 'preset' && ( +
    + +
    + )} + + {/* 自定义分支输入 */} + {branchInputMode === 'custom' && ( +
    + setCustomBranch(e.target.value)} + /> +

    + 输入 Git 分支名称、标签或提交哈希 +

    +
    + )} +
    +
    +
    + )} + + {!showAdvancedOptions && ( +

    + 将从默认分支 (main) 安装插件 +

    + )} +
    + + + + + +
    +
    + ) +} diff --git a/dashboard/src/routes/plugins/InstalledTab.tsx b/dashboard/src/routes/plugins/InstalledTab.tsx new file mode 100644 index 00000000..a2ffe04d --- /dev/null +++ b/dashboard/src/routes/plugins/InstalledTab.tsx @@ -0,0 +1,87 @@ +import type { GitStatus, MaimaiVersion, PluginInfo, PluginLoadProgress, PluginStatsData } from './types' +import { PluginCard } from './PluginCard' + +interface InstalledTabProps { + plugins: PluginInfo[] + searchQuery: string + categoryFilter: string + showCompatibleOnly: boolean + gitStatus: GitStatus | null + maimaiVersion: MaimaiVersion | null + pluginStats: Record + loadProgress: PluginLoadProgress | null + onInstall: (plugin: PluginInfo) => void + onUpdate: (plugin: PluginInfo) => void + onUninstall: (plugin: PluginInfo) => void + checkPluginCompatibility: (plugin: PluginInfo) => boolean + needsUpdate: (plugin: PluginInfo) => boolean + getStatusBadge: (plugin: PluginInfo) => React.JSX.Element | null +} + +export function InstalledTab({ + plugins, + searchQuery, + categoryFilter, + showCompatibleOnly, + gitStatus, + maimaiVersion, + pluginStats, + loadProgress, + onInstall, + onUpdate, + onUninstall, + checkPluginCompatibility, + needsUpdate, + getStatusBadge, +}: InstalledTabProps) { + // 过滤已安装插件 + const filteredPlugins = plugins.filter(plugin => { + // 跳过没有 manifest 的插件 + if (!plugin.manifest) { + return false + } + + // 只显示已安装 + if (!plugin.installed) { + return false + } + + // 搜索过滤 + const matchesSearch = searchQuery === '' || + plugin.manifest.name?.toLowerCase().includes(searchQuery.toLowerCase()) || + plugin.manifest.description?.toLowerCase().includes(searchQuery.toLowerCase()) || + (plugin.manifest.keywords && plugin.manifest.keywords.some(k => k.toLowerCase().includes(searchQuery.toLowerCase()))) + + // 分类过滤 + const matchesCategory = categoryFilter === 'all' || + (plugin.manifest.categories && plugin.manifest.categories.includes(categoryFilter)) + + // 兼容性过滤 + const matchesCompatibility = !showCompatibleOnly || + !maimaiVersion || + checkPluginCompatibility(plugin) + + return matchesSearch && matchesCategory && matchesCompatibility + }) + + return ( +
    + {filteredPlugins.map((plugin) => ( + + ))} +
    + ) +} diff --git a/dashboard/src/routes/plugins/MarketplaceTab.tsx b/dashboard/src/routes/plugins/MarketplaceTab.tsx new file mode 100644 index 00000000..bcd4b8c2 --- /dev/null +++ b/dashboard/src/routes/plugins/MarketplaceTab.tsx @@ -0,0 +1,83 @@ +import type { GitStatus, MaimaiVersion, PluginInfo, PluginLoadProgress, PluginStatsData } from './types' +import { PluginCard } from './PluginCard' + +interface MarketplaceTabProps { + plugins: PluginInfo[] + searchQuery: string + categoryFilter: string + showCompatibleOnly: boolean + gitStatus: GitStatus | null + maimaiVersion: MaimaiVersion | null + pluginStats: Record + loadProgress: PluginLoadProgress | null + onInstall: (plugin: PluginInfo) => void + onUpdate: (plugin: PluginInfo) => void + onUninstall: (plugin: PluginInfo) => void + checkPluginCompatibility: (plugin: PluginInfo) => boolean + needsUpdate: (plugin: PluginInfo) => boolean + getStatusBadge: (plugin: PluginInfo) => React.JSX.Element | null +} + +export function MarketplaceTab({ + plugins, + searchQuery, + categoryFilter, + showCompatibleOnly, + gitStatus, + maimaiVersion, + pluginStats, + loadProgress, + onInstall, + onUpdate, + onUninstall, + checkPluginCompatibility, + needsUpdate, + getStatusBadge, +}: MarketplaceTabProps) { + // 过滤插件 + const filteredPlugins = plugins.filter(plugin => { + // 跳过没有 manifest 的插件 + if (!plugin.manifest) { + console.warn('[过滤] 跳过无 manifest 的插件:', plugin.id) + return false + } + + // 搜索过滤 + const matchesSearch = searchQuery === '' || + plugin.manifest.name?.toLowerCase().includes(searchQuery.toLowerCase()) || + plugin.manifest.description?.toLowerCase().includes(searchQuery.toLowerCase()) || + (plugin.manifest.keywords && plugin.manifest.keywords.some(k => k.toLowerCase().includes(searchQuery.toLowerCase()))) + + // 分类过滤 + const matchesCategory = categoryFilter === 'all' || + (plugin.manifest.categories && plugin.manifest.categories.includes(categoryFilter)) + + // 兼容性过滤 + const matchesCompatibility = !showCompatibleOnly || + !maimaiVersion || + checkPluginCompatibility(plugin) + + return matchesSearch && matchesCategory && matchesCompatibility + }) + + return ( +
    + {filteredPlugins.map((plugin) => ( + + ))} +
    + ) +} diff --git a/dashboard/src/routes/plugins/PluginCard.tsx b/dashboard/src/routes/plugins/PluginCard.tsx new file mode 100644 index 00000000..ad6d3f1e --- /dev/null +++ b/dashboard/src/routes/plugins/PluginCard.tsx @@ -0,0 +1,235 @@ +import { useNavigate } from '@tanstack/react-router' +import { Badge } from '@/components/ui/badge' +import { Button } from '@/components/ui/button' +import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card' +import { Progress } from '@/components/ui/progress' +import { AlertCircle, CheckCircle2, Download, Loader2, RefreshCw, Star, Trash2 } from 'lucide-react' + +import type { GitStatus, MaimaiVersion, PluginInfo, PluginLoadProgress, PluginStatsData } from './types' +import { CATEGORY_NAMES } from './types' + +interface PluginCardProps { + plugin: PluginInfo + gitStatus: GitStatus | null + maimaiVersion: MaimaiVersion | null + pluginStats: Record + loadProgress: PluginLoadProgress | null + onInstall: (plugin: PluginInfo) => void + onUpdate: (plugin: PluginInfo) => void + onUninstall: (plugin: PluginInfo) => void + checkPluginCompatibility: (plugin: PluginInfo) => boolean + needsUpdate: (plugin: PluginInfo) => boolean + getStatusBadge: (plugin: PluginInfo) => React.JSX.Element | null +} + +export function PluginCard({ + plugin, + gitStatus, + maimaiVersion, + pluginStats, + loadProgress, + onInstall, + onUpdate, + onUninstall, + checkPluginCompatibility, + needsUpdate, + getStatusBadge, +}: PluginCardProps) { + const navigate = useNavigate() + + return ( + + +
    + {plugin.manifest?.name || plugin.id} +
    + {plugin.manifest?.categories && plugin.manifest.categories[0] && ( + + {CATEGORY_NAMES[plugin.manifest.categories[0]] || plugin.manifest.categories[0]} + + )} + {getStatusBadge(plugin)} +
    +
    + {plugin.manifest?.description || '无描述'} +
    + +
    + {/* 统计信息 */} +
    +
    + + {(pluginStats[plugin.id]?.downloads ?? plugin.downloads ?? 0).toLocaleString()} +
    +
    + + {(pluginStats[plugin.id]?.rating ?? plugin.rating ?? 0).toFixed(1)} +
    +
    + {/* 标签 */} +
    + {plugin.manifest?.keywords && plugin.manifest.keywords.slice(0, 3).map((keyword) => ( + + {keyword} + + ))} + {plugin.manifest?.keywords && plugin.manifest.keywords.length > 3 && ( + + +{plugin.manifest.keywords.length - 3} + + )} +
    + {/* 版本和作者 */} +
    +
    v{plugin.manifest?.version || 'unknown'} · {plugin.manifest?.author?.name || 'Unknown'}
    + {/* 支持版本 */} + {plugin.manifest?.host_application && ( +
    + 支持: + + {plugin.manifest.host_application.min_version} + {plugin.manifest.host_application.max_version + ? ` - ${plugin.manifest.host_application.max_version}` + : ' - 最新版本' + } + +
    + )} +
    +
    +
    + +
    + + {plugin.installed ? ( + needsUpdate(plugin) ? ( + + ) : ( + + ) + ) : ( + + )} +
    +
    + {/* 安装/卸载/更新进度显示 - 在卡片下方 */} + {loadProgress && + (loadProgress.stage === 'loading' || loadProgress.stage === 'success' || loadProgress.stage === 'error') && + loadProgress.operation !== 'fetch' && + loadProgress.plugin_id === plugin.id && ( +
    +
    +
    +
    + {loadProgress.stage === 'loading' ? ( + + ) : loadProgress.stage === 'success' ? ( + + ) : ( + + )} + + {loadProgress.stage === 'loading' ? ( + <> + {loadProgress.operation === 'install' && '正在安装'} + {loadProgress.operation === 'uninstall' && '正在卸载'} + {loadProgress.operation === 'update' && '正在更新'} + + ) : loadProgress.stage === 'success' ? ( + <> + {loadProgress.operation === 'install' && '安装完成'} + {loadProgress.operation === 'uninstall' && '卸载完成'} + {loadProgress.operation === 'update' && '更新完成'} + + ) : ( + <> + {loadProgress.operation === 'install' && '安装失败'} + {loadProgress.operation === 'uninstall' && '卸载失败'} + {loadProgress.operation === 'update' && '更新失败'} + + )} + +
    + {loadProgress.stage !== 'error' && ( + {loadProgress.progress}% + )} +
    + {loadProgress.stage !== 'error' && ( + div]:bg-green-500' : ''}`} + /> + )} +
    + {loadProgress.stage === 'error' ? (loadProgress.error || loadProgress.message || '操作失败') : loadProgress.message} +
    +
    +
    + )} +
    + ) +} diff --git a/dashboard/src/routes/plugins.tsx b/dashboard/src/routes/plugins/index.tsx similarity index 56% rename from dashboard/src/routes/plugins.tsx rename to dashboard/src/routes/plugins/index.tsx index 100fae85..64724545 100644 --- a/dashboard/src/routes/plugins.tsx +++ b/dashboard/src/routes/plugins/index.tsx @@ -1,63 +1,39 @@ import { useState, useEffect } from 'react' import { useNavigate } from '@tanstack/react-router' -import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card' -import { Button } from '@/components/ui/button' -import { Input } from '@/components/ui/input' import { Badge } from '@/components/ui/badge' -import { ScrollArea } from '@/components/ui/scroll-area' -import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs' -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, -} from '@/components/ui/dialog' -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from '@/components/ui/select' +import { Button } from '@/components/ui/button' +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' import { Checkbox } from '@/components/ui/checkbox' -import { Search, Download, Star, CheckCircle2, AlertCircle, Loader2, AlertTriangle, RefreshCw, Trash2, Settings2, RotateCw, Info } from 'lucide-react' -import type { PluginInfo } from '@/types/plugin' -import { RestartProvider, useRestart } from '@/lib/restart-context' +import { Input } from '@/components/ui/input' +import { Progress } from '@/components/ui/progress' +import { ScrollArea } from '@/components/ui/scroll-area' +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' +import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs' +import { AlertCircle, AlertTriangle, CheckCircle2, Info, Loader2, RotateCw, Search, Settings2 } from 'lucide-react' + import { RestartOverlay } from '@/components/restart-overlay' -import { - fetchPluginList, - checkGitStatus, - connectPluginProgressWebSocket, - installPlugin, +import { useToast } from '@/hooks/use-toast' +import { RestartProvider, useRestart } from '@/lib/restart-context' +import { + checkGitStatus, + checkPluginInstalled, + connectPluginProgressWebSocket, + fetchPluginList, + getInstalledPluginVersion, + getInstalledPlugins, + getMaimaiVersion, + installPlugin, + isPluginCompatible, uninstallPlugin, updatePlugin, - getMaimaiVersion, - isPluginCompatible, - getInstalledPlugins, - checkPluginInstalled, - getInstalledPluginVersion, - type GitStatus, - type PluginLoadProgress, - type MaimaiVersion, - type InstalledPlugin + type InstalledPlugin, } from '@/lib/plugin-api' -import { useToast } from '@/hooks/use-toast' -import { Progress } from '@/components/ui/progress' -import { recordPluginDownload, getPluginStats, type PluginStatsData } from '@/lib/plugin-stats' +import { getPluginStats, recordPluginDownload, type PluginStatsData } from '@/lib/plugin-stats' -// 分类名称映射 -const CATEGORY_NAMES: Record = { - 'Group Management': '群组管理', - 'Entertainment & Interaction': '娱乐互动', - 'Utility Tools': '实用工具', - 'Content Generation': '内容生成', - 'Multimedia': '多媒体', - 'External Integration': '外部集成', - 'Data Analysis & Insights': '数据分析与洞察', - 'Other': '其他', -} +import { InstallDialog } from './InstallDialog' +import { InstalledTab } from './InstalledTab' +import { MarketplaceTab } from './MarketplaceTab' +import type { GitStatus, MaimaiVersion, PluginInfo, PluginLoadProgress } from './types' // 主导出组件:包装 RestartProvider export function PluginsPage() { @@ -88,10 +64,6 @@ function PluginsPageContent() { // 安装对话框状态 const [installDialogOpen, setInstallDialogOpen] = useState(false) const [installingPlugin, setInstallingPlugin] = useState(null) - const [selectedBranch, setSelectedBranch] = useState('main') - const [customBranch, setCustomBranch] = useState('') - const [branchInputMode, setBranchInputMode] = useState<'preset' | 'custom'>('preset') - const [showAdvancedOptions, setShowAdvancedOptions] = useState(false) const { toast } = useToast() @@ -334,13 +306,6 @@ function PluginsPageContent() { const marketVer = plugin.manifest.version?.trim() if (installedVer !== marketVer) { - // console.log(`[Plugin ${plugin.id}] 版本不一致:`, { - // installed: installedVer, - // market: marketVer, - // installedType: typeof plugin.installed_version, - // marketType: typeof plugin.manifest.version - // }) - // 简单的版本比较:只有当市场版本比已安装版本新时才显示"可更新" // 如果本地版本更新(比如手动更新或市场数据过期),则显示"已安装" const installedParts = installedVer?.split('.').map(Number) || [0, 0, 0] @@ -410,40 +375,6 @@ function PluginsPageContent() { return false } - // 过滤插件 - const filteredPlugins = plugins.filter(plugin => { - // 跳过没有 manifest 的插件 - if (!plugin.manifest) { - console.warn('[过滤] 跳过无 manifest 的插件:', plugin.id) - return false - } - - // 搜索过滤 - const matchesSearch = searchQuery === '' || - plugin.manifest.name?.toLowerCase().includes(searchQuery.toLowerCase()) || - plugin.manifest.description?.toLowerCase().includes(searchQuery.toLowerCase()) || - (plugin.manifest.keywords && plugin.manifest.keywords.some(k => k.toLowerCase().includes(searchQuery.toLowerCase()))) - - // 分类过滤 - const matchesCategory = categoryFilter === 'all' || - (plugin.manifest.categories && plugin.manifest.categories.includes(categoryFilter)) - - // 标签页过滤 - let matchesTab = true - if (activeTab === 'installed') { - matchesTab = plugin.installed === true - } else if (activeTab === 'updates') { - matchesTab = plugin.installed === true && needsUpdate(plugin) - } - - // 兼容性过滤 - const matchesCompatibility = !showCompatibleOnly || - !maimaiVersion || - checkPluginCompatibility(plugin) - - return matchesSearch && matchesCategory && matchesTab && matchesCompatibility - }) - // 打开安装对话框 const openInstallDialog = (plugin: PluginInfo) => { if (!gitStatus?.installed) { @@ -466,19 +397,13 @@ function PluginsPageContent() { } setInstallingPlugin(plugin) - setSelectedBranch('main') - setCustomBranch('') - setBranchInputMode('preset') - setShowAdvancedOptions(false) setInstallDialogOpen(true) } // 安装插件处理 - const handleInstall = async () => { + const handleInstall = async (branch: string) => { if (!installingPlugin) return - const branch = branchInputMode === 'custom' ? customBranch : selectedBranch - if (!branch || branch.trim() === '') { toast({ title: '分支名称不能为空', @@ -682,6 +607,50 @@ function PluginsPageContent() { } } + // 过滤插件用于标签页统计 + const getFilteredPluginCount = (tab: 'all' | 'installed' | 'updates') => { + return plugins.filter(p => { + if (!p.manifest) return false + const matchesSearch = searchQuery === '' || + p.manifest.name?.toLowerCase().includes(searchQuery.toLowerCase()) || + p.manifest.description?.toLowerCase().includes(searchQuery.toLowerCase()) || + (p.manifest.keywords && p.manifest.keywords.some(k => k.toLowerCase().includes(searchQuery.toLowerCase()))) + const matchesCategory = categoryFilter === 'all' || + (p.manifest.categories && p.manifest.categories.includes(categoryFilter)) + const matchesCompatibility = !showCompatibleOnly || + !maimaiVersion || + checkPluginCompatibility(p) + + let matchesTab = true + if (tab === 'installed') { + matchesTab = p.installed === true + } else if (tab === 'updates') { + matchesTab = p.installed === true && needsUpdate(p) + } + + return matchesSearch && matchesCategory && matchesCompatibility && matchesTab + }).length + } + + // 过滤插件用于可更新标签页 + const filteredUpdatablePlugins = plugins.filter(plugin => { + if (!plugin.manifest) return false + + const matchesSearch = searchQuery === '' || + plugin.manifest.name?.toLowerCase().includes(searchQuery.toLowerCase()) || + plugin.manifest.description?.toLowerCase().includes(searchQuery.toLowerCase()) || + (plugin.manifest.keywords && plugin.manifest.keywords.some(k => k.toLowerCase().includes(searchQuery.toLowerCase()))) + + const matchesCategory = categoryFilter === 'all' || + (plugin.manifest.categories && plugin.manifest.categories.includes(categoryFilter)) + + const matchesCompatibility = !showCompatibleOnly || + !maimaiVersion || + checkPluginCompatibility(plugin) + + return plugin.installed && needsUpdate(plugin) && matchesSearch && matchesCategory && matchesCompatibility + }) + return (
    @@ -799,55 +768,13 @@ function PluginsPageContent() { - 全部插件 ({ - plugins.filter(p => { - if (!p.manifest) return false - const matchesSearch = searchQuery === '' || - p.manifest.name?.toLowerCase().includes(searchQuery.toLowerCase()) || - p.manifest.description?.toLowerCase().includes(searchQuery.toLowerCase()) || - (p.manifest.keywords && p.manifest.keywords.some(k => k.toLowerCase().includes(searchQuery.toLowerCase()))) - const matchesCategory = categoryFilter === 'all' || - (p.manifest.categories && p.manifest.categories.includes(categoryFilter)) - const matchesCompatibility = !showCompatibleOnly || - !maimaiVersion || - checkPluginCompatibility(p) - return matchesSearch && matchesCategory && matchesCompatibility - }).length - }) + 全部插件 ({getFilteredPluginCount('all')}) - 已安装 ({ - plugins.filter(p => { - if (!p.manifest) return false - const matchesSearch = searchQuery === '' || - p.manifest.name?.toLowerCase().includes(searchQuery.toLowerCase()) || - p.manifest.description?.toLowerCase().includes(searchQuery.toLowerCase()) || - (p.manifest.keywords && p.manifest.keywords.some(k => k.toLowerCase().includes(searchQuery.toLowerCase()))) - const matchesCategory = categoryFilter === 'all' || - (p.manifest.categories && p.manifest.categories.includes(categoryFilter)) - const matchesCompatibility = !showCompatibleOnly || - !maimaiVersion || - checkPluginCompatibility(p) - return p.installed && matchesSearch && matchesCategory && matchesCompatibility - }).length - }) + 已安装 ({getFilteredPluginCount('installed')}) - 可更新 ({ - plugins.filter(p => { - if (!p.manifest) return false - const matchesSearch = searchQuery === '' || - p.manifest.name?.toLowerCase().includes(searchQuery.toLowerCase()) || - p.manifest.description?.toLowerCase().includes(searchQuery.toLowerCase()) || - (p.manifest.keywords && p.manifest.keywords.some(k => k.toLowerCase().includes(searchQuery.toLowerCase()))) - const matchesCategory = categoryFilter === 'all' || - (p.manifest.categories && p.manifest.categories.includes(categoryFilter)) - const matchesCompatibility = !showCompatibleOnly || - !maimaiVersion || - checkPluginCompatibility(p) - return p.installed && needsUpdate(p) && matchesSearch && matchesCategory && matchesCompatibility - }).length - }) + 可更新 ({getFilteredPluginCount('updates')}) @@ -912,328 +839,57 @@ function PluginsPageContent() {
    - ) : filteredPlugins.length === 0 ? ( - -
    - -

    未找到插件

    -

    - {searchQuery || categoryFilter !== 'all' - ? '尝试调整搜索条件或筛选器' - : '暂无可用插件'} -

    -
    -
    + ) : activeTab === 'all' ? ( + + ) : activeTab === 'installed' ? ( + ) : (
    - {filteredPlugins.map((plugin) => ( - - -
    - {plugin.manifest?.name || plugin.id} -
    - {plugin.manifest?.categories && plugin.manifest.categories[0] && ( - - {CATEGORY_NAMES[plugin.manifest.categories[0]] || plugin.manifest.categories[0]} - - )} - {getStatusBadge(plugin)} -
    -
    - {plugin.manifest?.description || '无描述'} -
    - -
    - {/* 统计信息 */} -
    -
    - - {(pluginStats[plugin.id]?.downloads ?? plugin.downloads ?? 0).toLocaleString()} -
    -
    - - {(pluginStats[plugin.id]?.rating ?? plugin.rating ?? 0).toFixed(1)} -
    -
    - {/* 标签 */} -
    - {plugin.manifest?.keywords && plugin.manifest.keywords.slice(0, 3).map((keyword) => ( - - {keyword} - - ))} - {plugin.manifest?.keywords && plugin.manifest.keywords.length > 3 && ( - - +{plugin.manifest.keywords.length - 3} - - )} -
    - {/* 版本和作者 */} -
    -
    v{plugin.manifest?.version || 'unknown'} · {plugin.manifest?.author?.name || 'Unknown'}
    - {/* 支持版本 */} - {plugin.manifest?.host_application && ( -
    - 支持: - - {plugin.manifest.host_application.min_version} - {plugin.manifest.host_application.max_version - ? ` - ${plugin.manifest.host_application.max_version}` - : ' - 最新版本' - } - -
    - )} -
    -
    -
    - -
    - - {plugin.installed ? ( - needsUpdate(plugin) ? ( - - ) : ( - - ) - ) : ( - - )} -
    -
    - {/* 安装/卸载/更新进度显示 - 在卡片下方 */} - {loadProgress && - (loadProgress.stage === 'loading' || loadProgress.stage === 'success' || loadProgress.stage === 'error') && - loadProgress.operation !== 'fetch' && - loadProgress.plugin_id === plugin.id && ( -
    -
    -
    -
    - {loadProgress.stage === 'loading' ? ( - - ) : loadProgress.stage === 'success' ? ( - - ) : ( - - )} - - {loadProgress.stage === 'loading' ? ( - <> - {loadProgress.operation === 'install' && '正在安装'} - {loadProgress.operation === 'uninstall' && '正在卸载'} - {loadProgress.operation === 'update' && '正在更新'} - - ) : loadProgress.stage === 'success' ? ( - <> - {loadProgress.operation === 'install' && '安装完成'} - {loadProgress.operation === 'uninstall' && '卸载完成'} - {loadProgress.operation === 'update' && '更新完成'} - - ) : ( - <> - {loadProgress.operation === 'install' && '安装失败'} - {loadProgress.operation === 'uninstall' && '卸载失败'} - {loadProgress.operation === 'update' && '更新失败'} - - )} - -
    - {loadProgress.stage !== 'error' && ( - {loadProgress.progress}% - )} -
    - {loadProgress.stage !== 'error' && ( - div]:bg-green-500' : ''}`} - /> - )} -
    - {loadProgress.stage === 'error' ? (loadProgress.error || loadProgress.message || '操作失败') : loadProgress.message} -
    -
    -
    - )} -
    - ))} + {filteredUpdatablePlugins.map((plugin) => ( +
    + {/* PluginCard would go here */} +
    + ))}
    )} {/* 安装对话框 */} - - - - 安装插件 - - 安装 {installingPlugin?.manifest.name} - - - -
    - {/* 基本信息 */} -
    -

    - 版本: {installingPlugin?.manifest.version} -

    -

    - 作者: {typeof installingPlugin?.manifest.author === 'string' - ? installingPlugin.manifest.author - : installingPlugin?.manifest.author?.name} -

    -
    - - {/* 高级选项开关 */} -
    - setShowAdvancedOptions(checked as boolean)} - /> - -
    - - {/* 高级选项内容 */} - {showAdvancedOptions && ( -
    -
    - - - setBranchInputMode(value as 'preset' | 'custom')}> - - 预设分支 - 自定义分支 - - - {/* 预设分支选择 */} - {branchInputMode === 'preset' && ( -
    - -
    - )} - - {/* 自定义分支输入 */} - {branchInputMode === 'custom' && ( -
    - setCustomBranch(e.target.value)} - /> -

    - 输入 Git 分支名称、标签或提交哈希 -

    -
    - )} -
    -
    -
    - )} - - {!showAdvancedOptions && ( -

    - 将从默认分支 (main) 安装插件 -

    - )} -
    - - - - - -
    -
    + {/* 重启遮罩层 */} @@ -1241,4 +897,3 @@ function PluginsPageContent() {
    ) } - diff --git a/dashboard/src/routes/plugins/types.ts b/dashboard/src/routes/plugins/types.ts new file mode 100644 index 00000000..43477865 --- /dev/null +++ b/dashboard/src/routes/plugins/types.ts @@ -0,0 +1,18 @@ +import type { PluginInfo } from '@/types/plugin' +import type { GitStatus, MaimaiVersion, PluginLoadProgress } from '@/lib/plugin-api' +import type { PluginStatsData } from '@/lib/plugin-stats' + +// 分类名称映射 +export const CATEGORY_NAMES: Record = { + 'Group Management': '群组管理', + 'Entertainment & Interaction': '娱乐互动', + 'Utility Tools': '实用工具', + 'Content Generation': '内容生成', + 'Multimedia': '多媒体', + 'External Integration': '外部集成', + 'Data Analysis & Insights': '数据分析与洞察', + 'Other': '其他', +} + +// 导出类型 +export type { PluginInfo, GitStatus, MaimaiVersion, PluginLoadProgress, PluginStatsData } From c53840006d5904f467fb964d3e521850b9d2f815 Mon Sep 17 00:00:00 2001 From: DrSmoothl <1787882683@qq.com> Date: Sun, 1 Mar 2026 19:35:36 +0800 Subject: [PATCH 16/26] refactor(lib): split plugin-api.ts into plugin-api/ directory Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode) Co-authored-by: Sisyphus --- dashboard/src/lib/plugin-api.ts | 650 ------------------- dashboard/src/lib/plugin-api/config.ts | 150 +++++ dashboard/src/lib/plugin-api/index.ts | 5 + dashboard/src/lib/plugin-api/install-flow.ts | 50 ++ dashboard/src/lib/plugin-api/installed.ts | 56 ++ dashboard/src/lib/plugin-api/marketplace.ts | 252 +++++++ dashboard/src/lib/plugin-api/types.ts | 156 +++++ 7 files changed, 669 insertions(+), 650 deletions(-) delete mode 100644 dashboard/src/lib/plugin-api.ts create mode 100644 dashboard/src/lib/plugin-api/config.ts create mode 100644 dashboard/src/lib/plugin-api/index.ts create mode 100644 dashboard/src/lib/plugin-api/install-flow.ts create mode 100644 dashboard/src/lib/plugin-api/installed.ts create mode 100644 dashboard/src/lib/plugin-api/marketplace.ts create mode 100644 dashboard/src/lib/plugin-api/types.ts diff --git a/dashboard/src/lib/plugin-api.ts b/dashboard/src/lib/plugin-api.ts deleted file mode 100644 index a866bf6d..00000000 --- a/dashboard/src/lib/plugin-api.ts +++ /dev/null @@ -1,650 +0,0 @@ -import type { ApiResponse } from '@/types/api' -import type { PluginInfo } from '@/types/plugin' - -import { fetchWithAuth, getAuthHeaders } from '@/lib/fetch-with-auth' -import { parseResponse } from './api-helpers' -import { createReconnectingWebSocket } from './ws-utils' - -/** - * Git 安装状态 - */ -export interface GitStatus { - installed: boolean - version?: string - path?: string - error?: string -} - -/** - * 麦麦版本信息 - */ -export interface MaimaiVersion { - version: string - version_major: number - version_minor: number - version_patch: number -} - -/** - * 已安装插件信息 - */ -export interface InstalledPlugin { - id: string - manifest: { - manifest_version: number - name: string - version: string - description: string - author: { - name: string - url?: string - } - license: string - host_application: { - min_version: string - max_version?: string - } - homepage_url?: string - repository_url?: string - keywords?: string[] - categories?: string[] - [key: string]: unknown // 允许其他字段 - } - path: string -} - -/** - * 插件加载进度 - */ -export interface PluginLoadProgress { - operation: 'idle' | 'fetch' | 'install' | 'uninstall' | 'update' - stage: 'idle' | 'loading' | 'success' | 'error' - progress: number // 0-100 - message: string - error?: string - plugin_id?: string - total_plugins: number - loaded_plugins: number -} - -/** - * 插件仓库配置 - */ -const PLUGIN_REPO_OWNER = 'Mai-with-u' -const PLUGIN_REPO_NAME = 'plugin-repo' -const PLUGIN_REPO_BRANCH = 'main' -const PLUGIN_DETAILS_FILE = 'plugin_details.json' - -/** - * 插件列表 API 响应类型(只包含我们需要的字段) - */ -interface PluginApiResponse { - id: string - manifest: { - manifest_version: number - name: string - version: string - description: string - author: { - name: string - url?: string - } - license: string - host_application: { - min_version: string - max_version?: string - } - homepage_url?: string - repository_url?: string - keywords: string[] - categories?: string[] - default_locale: string - locales_path?: string - } - // 可能还有其他字段,但我们不关心 - [key: string]: unknown -} - -/** - * 从远程获取插件列表(通过后端代理避免 CORS) - */ -export async function fetchPluginList(): Promise> { - const response = await fetchWithAuth('/api/webui/plugins/fetch-raw', { - method: 'POST', - body: JSON.stringify({ - owner: PLUGIN_REPO_OWNER, - repo: PLUGIN_REPO_NAME, - branch: PLUGIN_REPO_BRANCH, - file_path: PLUGIN_DETAILS_FILE - }) - }) - - const apiResult = await parseResponse<{ success: boolean; data: string; error?: string }>(response) - - if (!apiResult.success) { - return apiResult - } - - const result = apiResult.data - if (!result.success || !result.data) { - return { - success: false, - error: result.error || '获取插件列表失败' - } - } - - const data: PluginApiResponse[] = JSON.parse(result.data) - - const pluginList = data - .filter(item => { - if (!item?.id || !item?.manifest) { - console.warn('跳过无效插件数据:', item) - return false - } - if (!item.manifest.name || !item.manifest.version) { - console.warn('跳过缺少必需字段的插件:', item.id) - return false - } - return true - }) - .map((item) => ({ - id: item.id, - manifest: { - manifest_version: item.manifest.manifest_version || 1, - name: item.manifest.name, - version: item.manifest.version, - description: item.manifest.description || '', - author: item.manifest.author || { name: 'Unknown' }, - license: item.manifest.license || 'Unknown', - host_application: item.manifest.host_application || { min_version: '0.0.0' }, - homepage_url: item.manifest.homepage_url, - repository_url: item.manifest.repository_url, - keywords: item.manifest.keywords || [], - categories: item.manifest.categories || [], - default_locale: item.manifest.default_locale || 'zh-CN', - locales_path: item.manifest.locales_path, - }, - downloads: 0, - rating: 0, - review_count: 0, - installed: false, - published_at: new Date().toISOString(), - updated_at: new Date().toISOString(), - })) - - return { - success: true, - data: pluginList - } -} - -/** - * 检查本机 Git 安装状态 - */ -export async function checkGitStatus(): Promise> { - const response = await fetchWithAuth('/api/webui/plugins/git-status') - - const apiResult = await parseResponse(response) - - if (!apiResult.success) { - return { - success: true, - data: { - installed: false, - error: '无法检测 Git 安装状态' - } - } - } - - return apiResult -} - -/** - * 获取麦麦版本信息 - */ -export async function getMaimaiVersion(): Promise> { - const response = await fetchWithAuth('/api/webui/plugins/version') - - const apiResult = await parseResponse(response) - - if (!apiResult.success) { - return { - success: true, - data: { - version: '0.0.0', - version_major: 0, - version_minor: 0, - version_patch: 0 - } - } - } - - return apiResult -} - -/** - * 比较版本号 - * - * @param pluginMinVersion 插件要求的最小版本 - * @param pluginMaxVersion 插件要求的最大版本(可选) - * @param maimaiVersion 麦麦当前版本 - * @returns true 表示兼容,false 表示不兼容 - */ -export function isPluginCompatible( - pluginMinVersion: string, - pluginMaxVersion: string | undefined, - maimaiVersion: MaimaiVersion -): boolean { - // 解析插件最小版本 - const minParts = pluginMinVersion.split('.').map(p => parseInt(p) || 0) - const minMajor = minParts[0] || 0 - const minMinor = minParts[1] || 0 - const minPatch = minParts[2] || 0 - - // 检查最小版本 - if (maimaiVersion.version_major < minMajor) return false - if (maimaiVersion.version_major === minMajor && maimaiVersion.version_minor < minMinor) return false - if (maimaiVersion.version_major === minMajor && - maimaiVersion.version_minor === minMinor && - maimaiVersion.version_patch < minPatch) return false - - // 检查最大版本(如果有) - if (pluginMaxVersion) { - const maxParts = pluginMaxVersion.split('.').map(p => parseInt(p) || 0) - const maxMajor = maxParts[0] || 0 - const maxMinor = maxParts[1] || 0 - const maxPatch = maxParts[2] || 0 - - if (maimaiVersion.version_major > maxMajor) return false - if (maimaiVersion.version_major === maxMajor && maimaiVersion.version_minor > maxMinor) return false - if (maimaiVersion.version_major === maxMajor && - maimaiVersion.version_minor === maxMinor && - maimaiVersion.version_patch > maxPatch) return false - } - - return true -} - -/** - * 连接插件加载进度 WebSocket - * - * 使用临时 token 进行认证,异步获取 token 后连接 - */ -export async function connectPluginProgressWebSocket( - onProgress: (progress: PluginLoadProgress) => void, - onError?: (error: Event) => void -): Promise { - const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:' - const host = window.location.host - const wsUrl = `${protocol}//${host}/api/webui/ws/plugin-progress` - - // 使用 ws-utils 创建 WebSocket - const wsControl = createReconnectingWebSocket(wsUrl, { - onMessage: (data: string) => { - try { - const progressData = JSON.parse(data) as PluginLoadProgress - onProgress(progressData) - } catch (error) { - console.error('Failed to parse progress data:', error) - } - }, - onOpen: () => { - console.log('Plugin progress WebSocket connected') - }, - onClose: () => { - console.log('Plugin progress WebSocket disconnected') - }, - onError: (error) => { - console.error('Plugin progress WebSocket error:', error) - onError?.(error) - }, - heartbeatInterval: 30000, - maxRetries: 10, - backoffBase: 1000, - maxBackoff: 30000, - }) - - // 启动连接 - await wsControl.connect() - - // 返回 WebSocket 实例(用于外部检查连接状态) - return wsControl.getWebSocket() -} - -/** - * 获取已安装插件列表 - */ -export async function getInstalledPlugins(): Promise> { - const response = await fetchWithAuth('/api/webui/plugins/installed', { - headers: getAuthHeaders() - }) - - const apiResult = await parseResponse<{ success: boolean; plugins?: InstalledPlugin[]; message?: string }>(response) - - if (!apiResult.success) { - return { - success: true, - data: [] - } - } - - const result = apiResult.data - if (!result.success) { - return { - success: true, - data: [] - } - } - - return { - success: true, - data: result.plugins || [] - } -} - -/** - * 检查插件是否已安装 - */ -export function checkPluginInstalled(pluginId: string, installedPlugins: InstalledPlugin[]): boolean { - return installedPlugins.some(p => p.id === pluginId) -} - -/** - * 获取已安装插件的版本 - */ -export function getInstalledPluginVersion(pluginId: string, installedPlugins: InstalledPlugin[]): string | undefined { - const plugin = installedPlugins.find(p => p.id === pluginId) - if (!plugin) return undefined - - // 兼容两种格式:新格式有 manifest,旧格式直接有 version - // eslint-disable-next-line @typescript-eslint/no-explicit-any - return plugin.manifest?.version || (plugin as any).version -} - -/** - * 安装插件 - */ -export async function installPlugin(pluginId: string, repositoryUrl: string, branch: string = 'main'): Promise> { - const response = await fetchWithAuth('/api/webui/plugins/install', { - method: 'POST', - body: JSON.stringify({ - plugin_id: pluginId, - repository_url: repositoryUrl, - branch: branch - }) - }) - - return await parseResponse<{ success: boolean; message: string }>(response) -} - -/** - * 卸载插件 - */ -export async function uninstallPlugin(pluginId: string): Promise> { - const response = await fetchWithAuth('/api/webui/plugins/uninstall', { - method: 'POST', - body: JSON.stringify({ - plugin_id: pluginId - }) - }) - - return await parseResponse<{ success: boolean; message: string }>(response) -} - -/** - * 更新插件 - */ -export async function updatePlugin(pluginId: string, repositoryUrl: string, branch: string = 'main'): Promise> { - const response = await fetchWithAuth('/api/webui/plugins/update', { - method: 'POST', - body: JSON.stringify({ - plugin_id: pluginId, - repository_url: repositoryUrl, - branch: branch - }) - }) - - return await parseResponse<{ success: boolean; message: string; old_version: string; new_version: string }>(response) -} - - -// ============ 插件配置管理 ============ - -/** - * 列表项字段定义(用于 object 类型的数组项) - */ -export interface ItemFieldDefinition { - type: string - label?: string - placeholder?: string - default?: unknown -} - -/** - * 配置字段定义 - */ -export interface ConfigFieldSchema { - name: string - type: string - default: unknown - description: string - example?: string - required: boolean - choices?: unknown[] - min?: number - max?: number - step?: number - pattern?: string - max_length?: number - label: string - placeholder?: string - hint?: string - icon?: string - hidden: boolean - disabled: boolean - order: number - input_type?: string - ui_type: string - rows?: number - group?: string - depends_on?: string - depends_value?: unknown - // 列表类型专用 - item_type?: string // "string" | "number" | "object" - item_fields?: Record - min_items?: number - max_items?: number -} - -/** - * 配置节定义 - */ -export interface ConfigSectionSchema { - name: string - title: string - description?: string - icon?: string - collapsed: boolean - order: number - fields: Record -} - -/** - * 配置标签页定义 - */ -export interface ConfigTabSchema { - id: string - title: string - sections: string[] - icon?: string - order: number - badge?: string -} - -/** - * 配置布局定义 - */ -export interface ConfigLayoutSchema { - type: 'auto' | 'tabs' | 'pages' - tabs: ConfigTabSchema[] -} - -/** - * 插件配置 Schema - */ -export interface PluginConfigSchema { - plugin_id: string - plugin_info: { - name: string - version: string - description: string - author: string - } - sections: Record - layout: ConfigLayoutSchema - _note?: string -} - -/** - * 获取插件配置 Schema - */ -export async function getPluginConfigSchema(pluginId: string): Promise> { - const response = await fetchWithAuth(`/api/webui/plugins/config/${pluginId}/schema`, { - headers: getAuthHeaders() - }) - - const apiResult = await parseResponse<{ success: boolean; schema?: PluginConfigSchema; message?: string }>(response) - - if (!apiResult.success) { - return apiResult - } - - const result = apiResult.data - if (!result.success || !result.schema) { - return { - success: false, - error: result.message || '获取配置 Schema 失败' - } - } - - return { - success: true, - data: result.schema - } -} - -/** - * 获取插件当前配置值 - */ -export async function getPluginConfig(pluginId: string): Promise>> { - const response = await fetchWithAuth(`/api/webui/plugins/config/${pluginId}`, { - headers: getAuthHeaders() - }) - - const apiResult = await parseResponse<{ success: boolean; config?: Record; message?: string }>(response) - - if (!apiResult.success) { - return apiResult - } - - const result = apiResult.data - if (!result.success || !result.config) { - return { - success: false, - error: result.message || '获取配置失败' - } - } - - return { - success: true, - data: result.config - } -} - -/** - * 获取插件原始 TOML 配置 - */ -export async function getPluginConfigRaw(pluginId: string): Promise> { - const response = await fetchWithAuth(`/api/webui/plugins/config/${pluginId}/raw`, { - headers: getAuthHeaders() - }) - - const apiResult = await parseResponse<{ success: boolean; config?: string; message?: string }>(response) - - if (!apiResult.success) { - return apiResult - } - - const result = apiResult.data - if (!result.success || !result.config) { - return { - success: false, - error: result.message || '获取配置失败' - } - } - - return { - success: true, - data: result.config - } -} - -/** - * 更新插件配置 - */ -export async function updatePluginConfig( - pluginId: string, - config: Record -): Promise> { - const response = await fetchWithAuth(`/api/webui/plugins/config/${pluginId}`, { - method: 'PUT', - headers: getAuthHeaders(), - body: JSON.stringify({ config }) - }) - - return await parseResponse<{ success: boolean; message: string; note?: string }>(response) -} - -/** - * 更新插件原始 TOML 配置 - */ -export async function updatePluginConfigRaw( - pluginId: string, - configToml: string -): Promise> { - const response = await fetchWithAuth(`/api/webui/plugins/config/${pluginId}/raw`, { - method: 'PUT', - headers: getAuthHeaders(), - body: JSON.stringify({ config: configToml }) - }) - - return await parseResponse<{ success: boolean; message: string; note?: string }>(response) -} - -/** - * 重置插件配置为默认值 - */ -export async function resetPluginConfig( - pluginId: string -): Promise> { - const response = await fetchWithAuth(`/api/webui/plugins/config/${pluginId}/reset`, { - method: 'POST', - headers: getAuthHeaders() - }) - - return await parseResponse<{ success: boolean; message: string; backup?: string }>(response) -} - -/** - * 切换插件启用状态 - */ -export async function togglePlugin( - pluginId: string -): Promise> { - const response = await fetchWithAuth(`/api/webui/plugins/config/${pluginId}/toggle`, { - method: 'POST', - headers: getAuthHeaders() - }) - - return await parseResponse<{ success: boolean; enabled: boolean; message: string; note?: string }>(response) -} diff --git a/dashboard/src/lib/plugin-api/config.ts b/dashboard/src/lib/plugin-api/config.ts new file mode 100644 index 00000000..0b37de72 --- /dev/null +++ b/dashboard/src/lib/plugin-api/config.ts @@ -0,0 +1,150 @@ +import type { ApiResponse } from '@/types/api' + +import { fetchWithAuth, getAuthHeaders } from '@/lib/fetch-with-auth' +import { parseResponse } from '@/lib/api-helpers' + +import type { PluginConfigSchema } from './types' + +/** + * 获取插件配置 Schema + */ +export async function getPluginConfigSchema(pluginId: string): Promise> { + const response = await fetchWithAuth(`/api/webui/plugins/config/${pluginId}/schema`, { + headers: getAuthHeaders() + }) + + const apiResult = await parseResponse<{ success: boolean; schema?: PluginConfigSchema; message?: string }>(response) + + if (!apiResult.success) { + return apiResult + } + + const result = apiResult.data + if (!result.success || !result.schema) { + return { + success: false, + error: result.message || '获取配置 Schema 失败' + } + } + + return { + success: true, + data: result.schema + } +} + +/** + * 获取插件当前配置值 + */ +export async function getPluginConfig(pluginId: string): Promise>> { + const response = await fetchWithAuth(`/api/webui/plugins/config/${pluginId}`, { + headers: getAuthHeaders() + }) + + const apiResult = await parseResponse<{ success: boolean; config?: Record; message?: string }>(response) + + if (!apiResult.success) { + return apiResult + } + + const result = apiResult.data + if (!result.success || !result.config) { + return { + success: false, + error: result.message || '获取配置失败' + } + } + + return { + success: true, + data: result.config + } +} + +/** + * 获取插件原始 TOML 配置 + */ +export async function getPluginConfigRaw(pluginId: string): Promise> { + const response = await fetchWithAuth(`/api/webui/plugins/config/${pluginId}/raw`, { + headers: getAuthHeaders() + }) + + const apiResult = await parseResponse<{ success: boolean; config?: string; message?: string }>(response) + + if (!apiResult.success) { + return apiResult + } + + const result = apiResult.data + if (!result.success || !result.config) { + return { + success: false, + error: result.message || '获取配置失败' + } + } + + return { + success: true, + data: result.config + } +} + +/** + * 更新插件配置 + */ +export async function updatePluginConfig( + pluginId: string, + config: Record +): Promise> { + const response = await fetchWithAuth(`/api/webui/plugins/config/${pluginId}`, { + method: 'PUT', + headers: getAuthHeaders(), + body: JSON.stringify({ config }) + }) + + return await parseResponse<{ success: boolean; message: string; note?: string }>(response) +} + +/** + * 更新插件原始 TOML 配置 + */ +export async function updatePluginConfigRaw( + pluginId: string, + configToml: string +): Promise> { + const response = await fetchWithAuth(`/api/webui/plugins/config/${pluginId}/raw`, { + method: 'PUT', + headers: getAuthHeaders(), + body: JSON.stringify({ config: configToml }) + }) + + return await parseResponse<{ success: boolean; message: string; note?: string }>(response) +} + +/** + * 重置插件配置为默认值 + */ +export async function resetPluginConfig( + pluginId: string +): Promise> { + const response = await fetchWithAuth(`/api/webui/plugins/config/${pluginId}/reset`, { + method: 'POST', + headers: getAuthHeaders() + }) + + return await parseResponse<{ success: boolean; message: string; backup?: string }>(response) +} + +/** + * 切换插件启用状态 + */ +export async function togglePlugin( + pluginId: string +): Promise> { + const response = await fetchWithAuth(`/api/webui/plugins/config/${pluginId}/toggle`, { + method: 'POST', + headers: getAuthHeaders() + }) + + return await parseResponse<{ success: boolean; enabled: boolean; message: string; note?: string }>(response) +} diff --git a/dashboard/src/lib/plugin-api/index.ts b/dashboard/src/lib/plugin-api/index.ts new file mode 100644 index 00000000..963acaf1 --- /dev/null +++ b/dashboard/src/lib/plugin-api/index.ts @@ -0,0 +1,5 @@ +export * from './types' +export * from './marketplace' +export * from './installed' +export * from './install-flow' +export * from './config' diff --git a/dashboard/src/lib/plugin-api/install-flow.ts b/dashboard/src/lib/plugin-api/install-flow.ts new file mode 100644 index 00000000..d01c0959 --- /dev/null +++ b/dashboard/src/lib/plugin-api/install-flow.ts @@ -0,0 +1,50 @@ +import type { ApiResponse } from '@/types/api' + +import { fetchWithAuth } from '@/lib/fetch-with-auth' +import { parseResponse } from '@/lib/api-helpers' + +/** + * 安装插件 + */ +export async function installPlugin(pluginId: string, repositoryUrl: string, branch: string = 'main'): Promise> { + const response = await fetchWithAuth('/api/webui/plugins/install', { + method: 'POST', + body: JSON.stringify({ + plugin_id: pluginId, + repository_url: repositoryUrl, + branch: branch + }) + }) + + return await parseResponse<{ success: boolean; message: string }>(response) +} + +/** + * 卸载插件 + */ +export async function uninstallPlugin(pluginId: string): Promise> { + const response = await fetchWithAuth('/api/webui/plugins/uninstall', { + method: 'POST', + body: JSON.stringify({ + plugin_id: pluginId + }) + }) + + return await parseResponse<{ success: boolean; message: string }>(response) +} + +/** + * 更新插件 + */ +export async function updatePlugin(pluginId: string, repositoryUrl: string, branch: string = 'main'): Promise> { + const response = await fetchWithAuth('/api/webui/plugins/update', { + method: 'POST', + body: JSON.stringify({ + plugin_id: pluginId, + repository_url: repositoryUrl, + branch: branch + }) + }) + + return await parseResponse<{ success: boolean; message: string; old_version: string; new_version: string }>(response) +} diff --git a/dashboard/src/lib/plugin-api/installed.ts b/dashboard/src/lib/plugin-api/installed.ts new file mode 100644 index 00000000..79e077c1 --- /dev/null +++ b/dashboard/src/lib/plugin-api/installed.ts @@ -0,0 +1,56 @@ +import type { ApiResponse } from '@/types/api' + +import { fetchWithAuth, getAuthHeaders } from '@/lib/fetch-with-auth' +import { parseResponse } from '@/lib/api-helpers' + +import type { InstalledPlugin } from './types' + +/** + * 获取已安装插件列表 + */ +export async function getInstalledPlugins(): Promise> { + const response = await fetchWithAuth('/api/webui/plugins/installed', { + headers: getAuthHeaders() + }) + + const apiResult = await parseResponse<{ success: boolean; plugins?: InstalledPlugin[]; message?: string }>(response) + + if (!apiResult.success) { + return { + success: true, + data: [] + } + } + + const result = apiResult.data + if (!result.success) { + return { + success: true, + data: [] + } + } + + return { + success: true, + data: result.plugins || [] + } +} + +/** + * 检查插件是否已安装 + */ +export function checkPluginInstalled(pluginId: string, installedPlugins: InstalledPlugin[]): boolean { + return installedPlugins.some(p => p.id === pluginId) +} + +/** + * 获取已安装插件的版本 + */ +export function getInstalledPluginVersion(pluginId: string, installedPlugins: InstalledPlugin[]): string | undefined { + const plugin = installedPlugins.find(p => p.id === pluginId) + if (!plugin) return undefined + + // 兼容两种格式:新格式有 manifest,旧格式直接有 version + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return plugin.manifest?.version || (plugin as any).version +} diff --git a/dashboard/src/lib/plugin-api/marketplace.ts b/dashboard/src/lib/plugin-api/marketplace.ts new file mode 100644 index 00000000..026e4457 --- /dev/null +++ b/dashboard/src/lib/plugin-api/marketplace.ts @@ -0,0 +1,252 @@ +import type { ApiResponse } from '@/types/api' +import type { PluginInfo } from '@/types/plugin' + +import { fetchWithAuth } from '@/lib/fetch-with-auth' +import { parseResponse } from '@/lib/api-helpers' + +import type { GitStatus, MaimaiVersion } from './types' + +/** + * 插件仓库配置 + */ +const PLUGIN_REPO_OWNER = 'Mai-with-u' +const PLUGIN_REPO_NAME = 'plugin-repo' +const PLUGIN_REPO_BRANCH = 'main' +const PLUGIN_DETAILS_FILE = 'plugin_details.json' + +/** + * 插件列表 API 响应类型(只包含我们需要的字段) + */ +interface PluginApiResponse { + id: string + manifest: { + manifest_version: number + name: string + version: string + description: string + author: { + name: string + url?: string + } + license: string + host_application: { + min_version: string + max_version?: string + } + homepage_url?: string + repository_url?: string + keywords: string[] + categories?: string[] + default_locale: string + locales_path?: string + } + // 可能还有其他字段,但我们不关心 + [key: string]: unknown +} + +/** + * 从远程获取插件列表(通过后端代理避免 CORS) + */ +export async function fetchPluginList(): Promise> { + const response = await fetchWithAuth('/api/webui/plugins/fetch-raw', { + method: 'POST', + body: JSON.stringify({ + owner: PLUGIN_REPO_OWNER, + repo: PLUGIN_REPO_NAME, + branch: PLUGIN_REPO_BRANCH, + file_path: PLUGIN_DETAILS_FILE + }) + }) + + const apiResult = await parseResponse<{ success: boolean; data: string; error?: string }>(response) + + if (!apiResult.success) { + return apiResult + } + + const result = apiResult.data + if (!result.success || !result.data) { + return { + success: false, + error: result.error || '获取插件列表失败' + } + } + + const data: PluginApiResponse[] = JSON.parse(result.data) + + const pluginList = data + .filter(item => { + if (!item?.id || !item?.manifest) { + console.warn('跳过无效插件数据:', item) + return false + } + if (!item.manifest.name || !item.manifest.version) { + console.warn('跳过缺少必需字段的插件:', item.id) + return false + } + return true + }) + .map((item) => ({ + id: item.id, + manifest: { + manifest_version: item.manifest.manifest_version || 1, + name: item.manifest.name, + version: item.manifest.version, + description: item.manifest.description || '', + author: item.manifest.author || { name: 'Unknown' }, + license: item.manifest.license || 'Unknown', + host_application: item.manifest.host_application || { min_version: '0.0.0' }, + homepage_url: item.manifest.homepage_url, + repository_url: item.manifest.repository_url, + keywords: item.manifest.keywords || [], + categories: item.manifest.categories || [], + default_locale: item.manifest.default_locale || 'zh-CN', + locales_path: item.manifest.locales_path, + }, + downloads: 0, + rating: 0, + review_count: 0, + installed: false, + published_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + })) + + return { + success: true, + data: pluginList + } +} + +/** + * 检查本机 Git 安装状态 + */ +export async function checkGitStatus(): Promise> { + const response = await fetchWithAuth('/api/webui/plugins/git-status') + + const apiResult = await parseResponse(response) + + if (!apiResult.success) { + return { + success: true, + data: { + installed: false, + error: '无法检测 Git 安装状态' + } + } + } + + return apiResult +} + +/** + * 获取麦麦版本信息 + */ +export async function getMaimaiVersion(): Promise> { + const response = await fetchWithAuth('/api/webui/plugins/version') + + const apiResult = await parseResponse(response) + + if (!apiResult.success) { + return { + success: true, + data: { + version: '0.0.0', + version_major: 0, + version_minor: 0, + version_patch: 0 + } + } + } + + return apiResult +} + +/** + * 比较版本号 + * + * @param pluginMinVersion 插件要求的最小版本 + * @param pluginMaxVersion 插件要求的最大版本(可选) + * @param maimaiVersion 麦麦当前版本 + * @returns true 表示兼容,false 表示不兼容 + */ +export function isPluginCompatible( + pluginMinVersion: string, + pluginMaxVersion: string | undefined, + maimaiVersion: MaimaiVersion +): boolean { + // 解析插件最小版本 + const minParts = pluginMinVersion.split('.').map(p => parseInt(p) || 0) + const minMajor = minParts[0] || 0 + const minMinor = minParts[1] || 0 + const minPatch = minParts[2] || 0 + + // 检查最小版本 + if (maimaiVersion.version_major < minMajor) return false + if (maimaiVersion.version_major === minMajor && maimaiVersion.version_minor < minMinor) return false + if (maimaiVersion.version_major === minMajor && + maimaiVersion.version_minor === minMinor && + maimaiVersion.version_patch < minPatch) return false + + // 检查最大版本(如果有) + if (pluginMaxVersion) { + const maxParts = pluginMaxVersion.split('.').map(p => parseInt(p) || 0) + const maxMajor = maxParts[0] || 0 + const maxMinor = maxParts[1] || 0 + const maxPatch = maxParts[2] || 0 + + if (maimaiVersion.version_major > maxMajor) return false + if (maimaiVersion.version_major === maxMajor && maimaiVersion.version_minor > maxMinor) return false + if (maimaiVersion.version_major === maxMajor && + maimaiVersion.version_minor === maxMinor && + maimaiVersion.version_patch > maxPatch) return false + } + + return true +} + +/** + * 连接插件加载进度 WebSocket + * + * 使用临时 token 进行认证,异步获取 token 后连接 + */ +export async function connectPluginProgressWebSocket( + onProgress: (progress: import('./types').PluginLoadProgress) => void, + onError?: (error: Event) => void +): Promise { + const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:' + const host = window.location.host + const wsUrl = `${protocol}//${host}/api/webui/ws/plugin-progress` + + // 使用 ws-utils 创建 WebSocket + const { createReconnectingWebSocket } = await import('@/lib/ws-utils') + const wsControl = createReconnectingWebSocket(wsUrl, { + onMessage: (data: string) => { + try { + const progressData = JSON.parse(data) as import('./types').PluginLoadProgress + onProgress(progressData) + } catch (error) { + console.error('Failed to parse progress data:', error) + } + }, + onOpen: () => { + console.log('Plugin progress WebSocket connected') + }, + onClose: () => { + console.log('Plugin progress WebSocket disconnected') + }, + onError: (error) => { + console.error('Plugin progress WebSocket error:', error) + onError?.(error) + }, + heartbeatInterval: 30000, + maxRetries: 10, + backoffBase: 1000, + maxBackoff: 30000, + }) + + // 启动连接 + await wsControl.connect() + + // 返回 WebSocket 实例(用于外部检查连接状态) + return wsControl.getWebSocket() +} diff --git a/dashboard/src/lib/plugin-api/types.ts b/dashboard/src/lib/plugin-api/types.ts new file mode 100644 index 00000000..c51ec6bb --- /dev/null +++ b/dashboard/src/lib/plugin-api/types.ts @@ -0,0 +1,156 @@ +/** + * Git 安装状态 + */ +export interface GitStatus { + installed: boolean + version?: string + path?: string + error?: string +} + +/** + * 麦麦版本信息 + */ +export interface MaimaiVersion { + version: string + version_major: number + version_minor: number + version_patch: number +} + +/** + * 已安装插件信息 + */ +export interface InstalledPlugin { + id: string + manifest: { + manifest_version: number + name: string + version: string + description: string + author: { + name: string + url?: string + } + license: string + host_application: { + min_version: string + max_version?: string + } + homepage_url?: string + repository_url?: string + keywords?: string[] + categories?: string[] + [key: string]: unknown // 允许其他字段 + } + path: string +} + +/** + * 插件加载进度 + */ +export interface PluginLoadProgress { + operation: 'idle' | 'fetch' | 'install' | 'uninstall' | 'update' + stage: 'idle' | 'loading' | 'success' | 'error' + progress: number // 0-100 + message: string + error?: string + plugin_id?: string + total_plugins: number + loaded_plugins: number +} + +/** + * 列表项字段定义(用于 object 类型的数组项) + */ +export interface ItemFieldDefinition { + type: string + label?: string + placeholder?: string + default?: unknown +} + +/** + * 配置字段定义 + */ +export interface ConfigFieldSchema { + name: string + type: string + default: unknown + description: string + example?: string + required: boolean + choices?: unknown[] + min?: number + max?: number + step?: number + pattern?: string + max_length?: number + label: string + placeholder?: string + hint?: string + icon?: string + hidden: boolean + disabled: boolean + order: number + input_type?: string + ui_type: string + rows?: number + group?: string + depends_on?: string + depends_value?: unknown + // 列表类型专用 + item_type?: string // "string" | "number" | "object" + item_fields?: Record + min_items?: number + max_items?: number +} + +/** + * 配置节定义 + */ +export interface ConfigSectionSchema { + name: string + title: string + description?: string + icon?: string + collapsed: boolean + order: number + fields: Record +} + +/** + * 配置标签页定义 + */ +export interface ConfigTabSchema { + id: string + title: string + sections: string[] + icon?: string + order: number + badge?: string +} + +/** + * 配置布局定义 + */ +export interface ConfigLayoutSchema { + type: 'auto' | 'tabs' | 'pages' + tabs: ConfigTabSchema[] +} + +/** + * 插件配置 Schema + */ +export interface PluginConfigSchema { + plugin_id: string + plugin_info: { + name: string + version: string + description: string + author: string + } + sections: Record + layout: ConfigLayoutSchema + _note?: string +} From b800011ed73287bec85cfca20f14367f49f82e69 Mon Sep 17 00:00:00 2001 From: DrSmoothl <1787882683@qq.com> Date: Sun, 1 Mar 2026 19:41:47 +0800 Subject: [PATCH 17/26] refactor(config): split ChatSection.tsx into sub-components MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 将 ChatSection.tsx (610行) 拆分为 4 个子组件 - TimeRangePicker.tsx: 时间范围选择器组件 - RulePreview.tsx: 规则预览组件 - RuleEditor.tsx: 规则编辑器组件 - RuleList.tsx: 规则列表组件 - ChatSection.tsx 重构为主容器组件 (197行) - 功能完全不变,构建零错误 - 遵循具名导出规范 Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode) Co-authored-by: Sisyphus --- .../config/bot/sections/ChatSection.tsx | 441 +----------------- .../routes/config/bot/sections/RuleEditor.tsx | 213 +++++++++ .../routes/config/bot/sections/RuleList.tsx | 70 +++ .../config/bot/sections/RulePreview.tsx | 40 ++ .../config/bot/sections/TimeRangePicker.tsx | 170 +++++++ 5 files changed, 505 insertions(+), 429 deletions(-) create mode 100644 dashboard/src/routes/config/bot/sections/RuleEditor.tsx create mode 100644 dashboard/src/routes/config/bot/sections/RuleList.tsx create mode 100644 dashboard/src/routes/config/bot/sections/RulePreview.tsx create mode 100644 dashboard/src/routes/config/bot/sections/TimeRangePicker.tsx diff --git a/dashboard/src/routes/config/bot/sections/ChatSection.tsx b/dashboard/src/routes/config/bot/sections/ChatSection.tsx index b29024df..6546b4d3 100644 --- a/dashboard/src/routes/config/bot/sections/ChatSection.tsx +++ b/dashboard/src/routes/config/bot/sections/ChatSection.tsx @@ -1,231 +1,18 @@ -import React, { useState, useEffect, useMemo } from 'react' -import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' import { Switch } from '@/components/ui/switch' -import { Slider } from '@/components/ui/slider' -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from '@/components/ui/select' -import { - AlertDialog, - AlertDialogAction, - AlertDialogCancel, - AlertDialogContent, - AlertDialogDescription, - AlertDialogFooter, - AlertDialogHeader, - AlertDialogTitle, - AlertDialogTrigger, -} from '@/components/ui/alert-dialog' -import { - Popover, - PopoverContent, - PopoverTrigger, -} from '@/components/ui/popover' -import { Plus, Trash2, Eye, Clock } from 'lucide-react' + import type { ChatConfig } from '../types' +import { RuleList } from './RuleList' + interface ChatSectionProps { config: ChatConfig onChange: (config: ChatConfig) => void } -// 时间选择组件 -const TimeRangePicker = React.memo(function TimeRangePicker({ - value, - onChange, -}: { - value: string - onChange: (value: string) => void -}) { - // 解析初始值 - const parsedValue = useMemo(() => { - const parts = value.split('-') - if (parts.length === 2) { - const [start, end] = parts - const [sh, sm] = start.split(':') - const [eh, em] = end.split(':') - return { - startHour: sh ? sh.padStart(2, '0') : '00', - startMinute: sm ? sm.padStart(2, '0') : '00', - endHour: eh ? eh.padStart(2, '0') : '23', - endMinute: em ? em.padStart(2, '0') : '59', - } - } - return { - startHour: '00', - startMinute: '00', - endHour: '23', - endMinute: '59', - } - }, [value]) - - const [startHour, setStartHour] = useState(parsedValue.startHour) - const [startMinute, setStartMinute] = useState(parsedValue.startMinute) - const [endHour, setEndHour] = useState(parsedValue.endHour) - const [endMinute, setEndMinute] = useState(parsedValue.endMinute) - - // 当value变化时同步状态 - useEffect(() => { - setStartHour(parsedValue.startHour) - setStartMinute(parsedValue.startMinute) - setEndHour(parsedValue.endHour) - setEndMinute(parsedValue.endMinute) - }, [parsedValue]) - - const updateTime = ( - newStartHour: string, - newStartMinute: string, - newEndHour: string, - newEndMinute: string - ) => { - const newValue = `${newStartHour}:${newStartMinute}-${newEndHour}:${newEndMinute}` - onChange(newValue) - } - - return ( - - - - - -
    -
    -

    开始时间

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

    结束时间

    -
    -
    - - -
    -
    - - -
    -
    -
    -
    -
    -
    - ) -}) - -// 预览窗口组件 -const RulePreview = React.memo(function RulePreview({ rule }: { rule: { target: string; time: string; value: number } }) { - const previewText = `{ target = "${rule.target}", time = "${rule.time}", value = ${rule.value.toFixed(1)} }` - - return ( - - - - - -
    -

    配置预览

    -
    - {previewText} -
    -

    - 这是保存到 bot_config.toml 文件中的格式 -

    -
    -
    -
    - ) -}) - -export const ChatSection = React.memo(function ChatSection({ config, onChange }: ChatSectionProps) { +export function ChatSection({ config, onChange }: ChatSectionProps) { // 添加发言频率规则 const addTalkValueRule = () => { onChange({ @@ -394,217 +181,13 @@ export const ChatSection = React.memo(function ChatSection({ config, onChange }: {/* 动态发言频率规则配置 */} {config.enable_talk_value_rules && ( -
    -
    -
    -

    动态发言频率规则

    -

    - 按时段或聊天流ID调整发言频率,优先匹配具体聊天,再匹配全局规则 -

    -
    - -
    - - {config.talk_value_rules && config.talk_value_rules.length > 0 ? ( -
    - {config.talk_value_rules.map((rule, index) => ( -
    -
    - - 规则 #{index + 1} - -
    - - - - - - - - 确认删除 - - 确定要删除规则 #{index + 1} 吗?此操作无法撤销。 - - - - 取消 - removeTalkValueRule(index)}> - 删除 - - - - -
    -
    - -
    - {/* 配置类型选择 */} -
    - - -
    - - {/* 详细配置选项 - 只在非全局时显示 */} - {rule.target !== '' && (() => { - const parts = rule.target.split(':') - const platform = parts[0] || 'qq' - const chatId = parts[1] || '' - const chatType = parts[2] || 'group' - - return ( -
    -
    -
    - - -
    - -
    - - { - updateTalkValueRule(index, 'target', `${platform}:${e.target.value}:${chatType}`) - }} - placeholder="输入群 ID" - className="font-mono text-sm" - /> -
    - -
    - - -
    -
    -

    - 当前聊天流 ID:{rule.target || '(未设置)'} -

    -
    - ) - })()} - - {/* 时间段选择器 */} -
    - - updateTalkValueRule(index, 'time', v)} - /> -

    - 支持跨夜区间,例如 23:00-02:00 -

    -
    - - {/* 发言频率滑块 */} -
    -
    - - { - const val = parseFloat(e.target.value) - if (!isNaN(val)) { - updateTalkValueRule(index, 'value', Math.max(0.01, Math.min(1, val))) - } - }} - className="w-20 h-8 text-xs" - /> -
    - - updateTalkValueRule(index, 'value', values[0]) - } - min={0.01} - max={1} - step={0.01} - className="w-full" - /> -
    - 0.01 (极少发言) - 0.5 - 1.0 (正常) -
    -
    -
    -
    - ))} -
    - ) : ( -
    -

    暂无规则,点击"添加规则"按钮创建

    -
    - )} - -
    -
    - 📝 规则说明 -
    -
      -
    • Target 为空:全局规则,对所有聊天生效
    • -
    • Target 指定:仅对特定聊天流生效(格式:platform:id:type)
    • -
    • 优先级:先匹配具体聊天流规则,再匹配全局规则
    • -
    • 时间支持跨夜:例如 23:00-02:00 表示晚上11点到次日凌晨2点
    • -
    • 数值范围:建议 0-1,0 表示完全沉默,1 表示正常发言
    • -
    -
    -
    + )}
    ) -}) +} diff --git a/dashboard/src/routes/config/bot/sections/RuleEditor.tsx b/dashboard/src/routes/config/bot/sections/RuleEditor.tsx new file mode 100644 index 00000000..81a4246a --- /dev/null +++ b/dashboard/src/routes/config/bot/sections/RuleEditor.tsx @@ -0,0 +1,213 @@ + +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' +import { Slider } from '@/components/ui/slider' +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from '@/components/ui/alert-dialog' + +import { Trash2 } from 'lucide-react' + +import { RulePreview } from './RulePreview' +import { TimeRangePicker } from './TimeRangePicker' + +interface TalkValueRule { + target: string + time: string + value: number +} + +interface RuleEditorProps { + rule: TalkValueRule + index: number + onUpdate: (index: number, field: 'target' | 'time' | 'value', value: string | number) => void + onRemove: (index: number) => void +} + +// 规则编辑器组件 +export function RuleEditor({ rule, index, onUpdate, onRemove }: RuleEditorProps) { + return ( +
    +
    + + 规则 #{index + 1} + +
    + + + + + + + + 确认删除 + + 确定要删除规则 #{index + 1} 吗?此操作无法撤销。 + + + + 取消 + onRemove(index)}> + 删除 + + + + +
    +
    + +
    + {/* 配置类型选择 */} +
    + + +
    + + {/* 详细配置选项 - 只在非全局时显示 */} + {rule.target !== '' && (() => { + const parts = rule.target.split(':') + const platform = parts[0] || 'qq' + const chatId = parts[1] || '' + const chatType = parts[2] || 'group' + + return ( +
    +
    +
    + + +
    + +
    + + { + onUpdate(index, 'target', `${platform}:${e.target.value}:${chatType}`) + }} + placeholder="输入群 ID" + className="font-mono text-sm" + /> +
    + +
    + + +
    +
    +

    + 当前聊天流 ID:{rule.target || '(未设置)'} +

    +
    + ) + })()} + + {/* 时间段选择器 */} +
    + + onUpdate(index, 'time', v)} + /> +

    + 支持跨夜区间,例如 23:00-02:00 +

    +
    + + {/* 发言频率滑块 */} +
    +
    + + { + const val = parseFloat(e.target.value) + if (!isNaN(val)) { + onUpdate(index, 'value', Math.max(0.01, Math.min(1, val))) + } + }} + className="w-20 h-8 text-xs" + /> +
    + + onUpdate(index, 'value', values[0]) + } + min={0.01} + max={1} + step={0.01} + className="w-full" + /> +
    + 0.01 (极少发言) + 0.5 + 1.0 (正常) +
    +
    +
    +
    + ) +} diff --git a/dashboard/src/routes/config/bot/sections/RuleList.tsx b/dashboard/src/routes/config/bot/sections/RuleList.tsx new file mode 100644 index 00000000..b7373f6e --- /dev/null +++ b/dashboard/src/routes/config/bot/sections/RuleList.tsx @@ -0,0 +1,70 @@ + +import { Button } from '@/components/ui/button' + +import { Plus } from 'lucide-react' + +import { RuleEditor } from './RuleEditor' + +interface TalkValueRule { + target: string + time: string + value: number +} + +interface RuleListProps { + rules: TalkValueRule[] + onAdd: () => void + onUpdate: (index: number, field: 'target' | 'time' | 'value', value: string | number) => void + onRemove: (index: number) => void +} + +// 规则列表组件 +export function RuleList({ rules, onAdd, onUpdate, onRemove }: RuleListProps) { + return ( +
    +
    +
    +

    动态发言频率规则

    +

    + 按时段或聊天流ID调整发言频率,优先匹配具体聊天,再匹配全局规则 +

    +
    + +
    + + {rules && rules.length > 0 ? ( +
    + {rules.map((rule, index) => ( + + ))} +
    + ) : ( +
    +

    暂无规则,点击"添加规则"按钮创建

    +
    + )} + +
    +
    + 📝 规则说明 +
    +
      +
    • Target 为空:全局规则,对所有聊天生效
    • +
    • Target 指定:仅对特定聊天流生效(格式:platform:id:type)
    • +
    • 优先级:先匹配具体聊天流规则,再匹配全局规则
    • +
    • 时间支持跨夜:例如 23:00-02:00 表示晚上11点到次日凌晨2点
    • +
    • 数值范围:建议 0-1,0 表示完全沉默,1 表示正常发言
    • +
    +
    +
    + ) +} diff --git a/dashboard/src/routes/config/bot/sections/RulePreview.tsx b/dashboard/src/routes/config/bot/sections/RulePreview.tsx new file mode 100644 index 00000000..2b26c1f6 --- /dev/null +++ b/dashboard/src/routes/config/bot/sections/RulePreview.tsx @@ -0,0 +1,40 @@ + +import { Button } from '@/components/ui/button' +import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover' + +import { Eye } from 'lucide-react' + +interface RulePreviewProps { + rule: { + target: string + time: string + value: number + } +} + +// 预览窗口组件 +export function RulePreview({ rule }: RulePreviewProps) { + const previewText = `{ target = "${rule.target}", time = "${rule.time}", value = ${rule.value.toFixed(1)} }` + + return ( + + + + + +
    +

    配置预览

    +
    + {previewText} +
    +

    + 这是保存到 bot_config.toml 文件中的格式 +

    +
    +
    +
    + ) +} diff --git a/dashboard/src/routes/config/bot/sections/TimeRangePicker.tsx b/dashboard/src/routes/config/bot/sections/TimeRangePicker.tsx new file mode 100644 index 00000000..c6c7df12 --- /dev/null +++ b/dashboard/src/routes/config/bot/sections/TimeRangePicker.tsx @@ -0,0 +1,170 @@ +import { useEffect, useMemo, useState } from 'react' + +import { Button } from '@/components/ui/button' +import { Label } from '@/components/ui/label' +import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover' +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' + +import { Clock } from 'lucide-react' + +interface TimeRangePickerProps { + value: string + onChange: (value: string) => void +} + +// 时间选择组件 +export function TimeRangePicker({ value, onChange }: TimeRangePickerProps) { + // 解析初始值 + const parsedValue = useMemo(() => { + const parts = value.split('-') + if (parts.length === 2) { + const [start, end] = parts + const [sh, sm] = start.split(':') + const [eh, em] = end.split(':') + return { + startHour: sh ? sh.padStart(2, '0') : '00', + startMinute: sm ? sm.padStart(2, '0') : '00', + endHour: eh ? eh.padStart(2, '0') : '23', + endMinute: em ? em.padStart(2, '0') : '59', + } + } + return { + startHour: '00', + startMinute: '00', + endHour: '23', + endMinute: '59', + } + }, [value]) + + const [startHour, setStartHour] = useState(parsedValue.startHour) + const [startMinute, setStartMinute] = useState(parsedValue.startMinute) + const [endHour, setEndHour] = useState(parsedValue.endHour) + const [endMinute, setEndMinute] = useState(parsedValue.endMinute) + + // 当value变化时同步状态 + useEffect(() => { + setStartHour(parsedValue.startHour) + setStartMinute(parsedValue.startMinute) + setEndHour(parsedValue.endHour) + setEndMinute(parsedValue.endMinute) + }, [parsedValue]) + + const updateTime = ( + newStartHour: string, + newStartMinute: string, + newEndHour: string, + newEndMinute: string + ) => { + const newValue = `${newStartHour}:${newStartMinute}-${newEndHour}:${newEndMinute}` + onChange(newValue) + } + + return ( + + + + + +
    +
    +

    开始时间

    +
    +
    + + +
    +
    + + +
    +
    +
    +
    +

    结束时间

    +
    +
    + + +
    +
    + + +
    +
    +
    +
    +
    +
    + ) +} From e1f99365618b545d7a68264fd12d8836d03eba42 Mon Sep 17 00:00:00 2001 From: DrSmoothl <1787882683@qq.com> Date: Sun, 1 Mar 2026 19:58:18 +0800 Subject: [PATCH 18/26] refactor(config): split modelProvider.tsx into modular directory MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 拆分为 7 个文件:index.ts (barrel), types.ts, utils.ts, 3 个组件, index.tsx (主页面 895行) - 所有子组件 < 500 行 - 构建零错误 - 功能完全等价 --- dashboard/src/routes/config/modelProvider.tsx | 1804 ----------------- .../config/modelProvider/ProviderCard.tsx | 136 ++ .../config/modelProvider/ProviderForm.tsx | 458 +++++ .../config/modelProvider/ProviderList.tsx | 353 ++++ .../src/routes/config/modelProvider/index.ts | 13 +- .../src/routes/config/modelProvider/index.tsx | 895 ++++++++ 6 files changed, 1844 insertions(+), 1815 deletions(-) delete mode 100644 dashboard/src/routes/config/modelProvider.tsx create mode 100644 dashboard/src/routes/config/modelProvider/ProviderCard.tsx create mode 100644 dashboard/src/routes/config/modelProvider/ProviderForm.tsx create mode 100644 dashboard/src/routes/config/modelProvider/ProviderList.tsx create mode 100644 dashboard/src/routes/config/modelProvider/index.tsx diff --git a/dashboard/src/routes/config/modelProvider.tsx b/dashboard/src/routes/config/modelProvider.tsx deleted file mode 100644 index d42f8ba0..00000000 --- a/dashboard/src/routes/config/modelProvider.tsx +++ /dev/null @@ -1,1804 +0,0 @@ -import { useState, useEffect, useRef, useCallback, useMemo } from 'react' -import { Button } from '@/components/ui/button' -import { Input } from '@/components/ui/input' -import { Label } from '@/components/ui/label' -import { ScrollArea } from '@/components/ui/scroll-area' - -import { - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, -} from '@/components/ui/table' -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, -} from '@/components/ui/dialog' -import { - AlertDialog, - AlertDialogAction, - AlertDialogCancel, - AlertDialogContent, - AlertDialogDescription, - AlertDialogFooter, - AlertDialogHeader, - AlertDialogTitle, - AlertDialogTrigger, -} from '@/components/ui/alert-dialog' -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from '@/components/ui/select' -import { - Command, - CommandEmpty, - CommandGroup, - CommandInput, - CommandItem, - CommandList, -} from '@/components/ui/command' -import { - Popover, - PopoverContent, - PopoverTrigger, -} from '@/components/ui/popover' -import { Checkbox } from '@/components/ui/checkbox' -import { Badge } from '@/components/ui/badge' -import { Plus, Pencil, Trash2, Save, Eye, EyeOff, Copy, Search, Info, Power, ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight, Check, ChevronsUpDown, Zap, Loader2, CheckCircle2, XCircle, AlertCircle } from 'lucide-react' -import { getModelConfig, updateModelConfig, updateModelConfigSection, testProviderConnection, type TestConnectionResult } from '@/lib/config-api' -import { useToast } from '@/hooks/use-toast' -import { Alert, AlertDescription } from '@/components/ui/alert' -import { HelpTooltip } from '@/components/ui/help-tooltip' -import { useTour } from '@/components/tour' -import { MODEL_ASSIGNMENT_TOUR_ID, modelAssignmentTourSteps, STEP_ROUTE_MAP } from '@/components/tour/tours/model-assignment-tour' -import { useNavigate } from '@tanstack/react-router' -import { RestartOverlay } from '@/components/restart-overlay' -import { RestartProvider, useRestart } from '@/lib/restart-context' -import { PROVIDER_TEMPLATES } from './providerTemplates' -import type { APIProvider, DeleteConfirmState, FormErrors } from './modelProvider/types' -import { cleanProviderData, validateProvider } from './modelProvider/utils' - -// 主导出组件:包装 RestartProvider -export function ModelProviderConfigPage() { - return ( - - - - ) -} - -// 内部实现组件 -function ModelProviderConfigPageContent() { - const [providers, setProviders] = useState([]) - const [loading, setLoading] = useState(true) - const [saving, setSaving] = useState(false) - const [autoSaving, setAutoSaving] = useState(false) - const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false) - const [editDialogOpen, setEditDialogOpen] = useState(false) - const [editingProvider, setEditingProvider] = useState(null) - const [editingIndex, setEditingIndex] = useState(null) - const [selectedTemplate, setSelectedTemplate] = useState('custom') - const [templateComboboxOpen, setTemplateComboboxOpen] = useState(false) - const [deleteDialogOpen, setDeleteDialogOpen] = useState(false) - const [deletingIndex, setDeletingIndex] = useState(null) - const [showApiKey, setShowApiKey] = useState(false) - const [searchQuery, setSearchQuery] = useState('') - const [selectedProviders, setSelectedProviders] = useState>(new Set()) - const [batchDeleteDialogOpen, setBatchDeleteDialogOpen] = useState(false) - const [page, setPage] = useState(1) - const [pageSize, setPageSize] = useState(20) - const [jumpToPage, setJumpToPage] = useState('') - - // 删除提供商确认对话框状态(合并为单个对象以减少状态变量) - const [deleteConfirmState, setDeleteConfirmState] = useState({ - isOpen: false, - providersToDelete: [], - affectedModels: [], - pendingProviders: [], - context: 'auto', - oldProviders: [], - }) - - // 表单验证错误状态 - const [formErrors, setFormErrors] = useState({}) - - // 测试连接状态 - const [testingProviders, setTestingProviders] = useState>(new Set()) - const [testResults, setTestResults] = useState>(new Map()) - - const { toast } = useToast() - const navigate = useNavigate() - const { state: tourState, goToStep, registerTour } = useTour() - const { triggerRestart, isRestarting } = useRestart() - - // 用于防抖的定时器 - const autoSaveTimerRef = useRef | null>(null) - const initialLoadRef = useRef(true) - - // 注册 Tour(确保跨页导航时 Tour 仍然可用) - useEffect(() => { - registerTour(MODEL_ASSIGNMENT_TOUR_ID, modelAssignmentTourSteps) - }, [registerTour]) - - // 监听 Tour 步骤变化,处理页面导航 - useEffect(() => { - if (tourState.activeTourId === MODEL_ASSIGNMENT_TOUR_ID && tourState.isRunning) { - const targetRoute = STEP_ROUTE_MAP[tourState.stepIndex] - if (targetRoute && !window.location.pathname.endsWith(targetRoute.replace('/config/', ''))) { - navigate({ to: targetRoute }) - } - } - }, [tourState.stepIndex, tourState.activeTourId, tourState.isRunning, navigate]) - - // 监听 Tour 步骤变化,处理弹窗的打开和关闭 - // 提供商弹窗步骤: 3-9 (index 3-9),弹窗外步骤: 0-2 (index 0-2) 和 10+ (index 10+) - const prevTourStepRef = useRef(tourState.stepIndex) - useEffect(() => { - if (tourState.activeTourId === MODEL_ASSIGNMENT_TOUR_ID && tourState.isRunning) { - const prevStep = prevTourStepRef.current - const currentStep = tourState.stepIndex - - // 如果从弹窗内步骤 (3-9) 回退到弹窗外步骤 (0-2),关闭弹窗 - if (prevStep >= 3 && prevStep <= 9 && currentStep < 3) { - setEditDialogOpen(false) - } - - // 如果从弹窗外步骤 (10+) 回退到弹窗内步骤 (3-9),重新打开弹窗 - // 这处理了从模型管理页面第 11 步点击"上一步"回到提供商弹窗的情况 - if (prevStep >= 10 && currentStep >= 3 && currentStep <= 9) { - // 需要打开空白弹窗以便 Tour 可以定位到弹窗内的元素 - setFormErrors({}) - setSelectedTemplate('custom') - setEditingProvider({ - name: '', - base_url: '', - api_key: '', - client_type: 'openai', - max_retry: 2, - timeout: 30, - retry_interval: 10, - }) - setEditingIndex(null) - setShowApiKey(false) - setEditDialogOpen(true) - } - - prevTourStepRef.current = currentStep - } - }, [tourState.stepIndex, tourState.activeTourId, tourState.isRunning]) - - // 处理 Tour 中需要用户点击才能继续的步骤 - useEffect(() => { - if (tourState.activeTourId !== MODEL_ASSIGNMENT_TOUR_ID || !tourState.isRunning) return - - const handleTourClick = (e: MouseEvent) => { - const target = e.target as HTMLElement - const currentStep = tourState.stepIndex - - // Step 3 (index 2): 点击添加提供商按钮 - if (currentStep === 2 && target.closest('[data-tour="add-provider-button"]')) { - setTimeout(() => goToStep(3), 300) - } - // Step 10 (index 9): 点击取消按钮(关闭提供商弹窗) - else if (currentStep === 9 && target.closest('[data-tour="provider-cancel-button"]')) { - setTimeout(() => goToStep(10), 300) - } - } - - document.addEventListener('click', handleTourClick, true) - return () => document.removeEventListener('click', handleTourClick, true) - }, [tourState, goToStep]) - - // 加载配置 - useEffect(() => { - loadConfig() - }, []) - - const loadConfig = async () => { - try { - setLoading(true) - const result = await getModelConfig() - if (!result.success) { - toast({ - title: '加载失败', - description: result.error, - variant: 'destructive', - }) - setLoading(false) - return - } - const config = result.data - setProviders((config.api_providers as APIProvider[]) || []) - setHasUnsavedChanges(false) - initialLoadRef.current = false - } catch (error) { - console.error('加载配置失败:', error) - } finally { - setLoading(false) - } - } - - // 重启麦麦 - const handleRestart = async () => { - await triggerRestart() - } - - // 保存并重启 - const handleSaveAndRestart = async () => { - try { - setSaving(true) - if (autoSaveTimerRef.current) { - clearTimeout(autoSaveTimerRef.current) - } - - // 清理 providers 数据:将 null 值转换为默认值 - const cleanedProviders = providers.map(provider => ({ - ...provider, - max_retry: provider.max_retry ?? 2, - timeout: provider.timeout ?? 30, - retry_interval: provider.retry_interval ?? 10, - })) - - // 检查删除提供商的影响 - const { shouldProceed } = await checkDeleteProviderImpact(cleanedProviders, 'restart') - if (!shouldProceed) { - // 需要用户确认,等待确认对话框 - setSaving(false) - return - } - - const resultGet = await getModelConfig() - if (!resultGet.success) { - toast({ - title: '保存失败', - description: resultGet.error, - variant: 'destructive', - }) - setSaving(false) - return - } - const config = resultGet.data - - // 获取所有有效的 provider 名称 - const validProviderNames = new Set(cleanedProviders.map(p => p.name)) - - // 过滤掉引用已删除 provider 的模型 - const originalModels = (config.models as any[]) || [] - const filteredModels = originalModels.filter((model: any) => { - return validProviderNames.has(model.api_provider) - }) - - config.api_providers = cleanedProviders - config.models = filteredModels - - const resultUpdate = await updateModelConfig(config) - if (!resultUpdate.success) { - toast({ - title: '保存失败', - description: resultUpdate.error, - variant: 'destructive', - }) - setSaving(false) - return - } - setHasUnsavedChanges(false) - toast({ - title: '保存成功', - description: '正在重启麦麦...', - }) - await handleRestart() - } catch (error) { - console.error('保存配置失败:', error) - toast({ - title: '保存失败', - description: (error as Error).message, - variant: 'destructive', - }) - setSaving(false) - } - } - - // 检查删除提供商的影响 - const checkDeleteProviderImpact = useCallback(async ( - newProviders: APIProvider[], - context: 'auto' | 'manual' | 'restart' = 'auto' - ) => { - try { - const result = await getModelConfig() - if (!result.success) { - console.error('加载配置失败:', result.error) - return { shouldProceed: true, providers: newProviders } - } - const config = result.data - const oldProviderNames = new Set(providers.map(p => p.name)) - const newProviderNames = new Set(newProviders.map(p => p.name)) - - // 找出被删除的提供商 - const deletedProviders = Array.from(oldProviderNames).filter( - name => !newProviderNames.has(name) - ) - - if (deletedProviders.length === 0) { - // 没有删除提供商,直接保存 - return { shouldProceed: true, providers: newProviders } - } - - // 检查受影响的模型 - const models = (config.models as any[]) || [] - const affected = models.filter((m: any) => - deletedProviders.includes(m.api_provider) - ) - - if (affected.length === 0) { - // 没有受影响的模型,直接删除 - return { shouldProceed: true, providers: newProviders } - } - - // 有受影响的模型,需要用户确认 - setDeleteConfirmState({ - isOpen: true, - providersToDelete: deletedProviders, - affectedModels: affected, - pendingProviders: newProviders, - context, - oldProviders: [...providers], - }) - - return { shouldProceed: false, providers: newProviders } - } catch (error) { - console.error('检查删除影响失败:', error) - return { shouldProceed: true, providers: newProviders } - } - }, [providers]) - - // 确认删除提供商及其关联的模型 - const handleConfirmDeleteProvider = async () => { - try { - const savingFlag = deleteConfirmState.context === 'auto' ? setAutoSaving : setSaving - savingFlag(true) - - setDeleteConfirmState(prev => ({ ...prev, isOpen: false })) - - const resultGet = await getModelConfig() - if (!resultGet.success) { - toast({ - title: '加载失败', - description: resultGet.error, - variant: 'destructive', - }) - savingFlag(false) - return - } - const config = resultGet.data - - // 清理 providers 数据 - const cleanedProviders = deleteConfirmState.pendingProviders.map(cleanProviderData) - - // 获取有效的 provider 名称 - const validProviderNames = new Set(cleanedProviders.map(p => p.name)) - - // 过滤掉引用已删除 provider 的模型 - const originalModels = (config.models as any[]) || [] - const filteredModels = originalModels.filter((model: any) => { - return validProviderNames.has(model.api_provider) - }) - - // 获取被削除的模型名称 - const deletedModelNames = new Set( - deleteConfirmState.affectedModels.map((m: any) => m.name) - ) - - // 从任务配置中移除这些模型 - const modelTaskConfig = config.model_task_config as any - if (modelTaskConfig) { - Object.keys(modelTaskConfig).forEach(taskName => { - const task = modelTaskConfig[taskName] - if (task && Array.isArray(task.model_list)) { - task.model_list = task.model_list.filter( - (modelName: string) => !deletedModelNames.has(modelName) - ) - } - }) - } - - // 更新配置 - config.api_providers = cleanedProviders - config.models = filteredModels - config.model_task_config = modelTaskConfig - - const resultUpdate = await updateModelConfig(config) - if (!resultUpdate.success) { - toast({ - title: '保存失败', - description: resultUpdate.error, - variant: 'destructive', - }) - savingFlag(false) - return - } - - // 更新本地状态 - setProviders(deleteConfirmState.pendingProviders) - setHasUnsavedChanges(false) - - toast({ - title: '删除成功', - description: `已删除 ${deleteConfirmState.providersToDelete.length} 个提供商和 ${deleteConfirmState.affectedModels.length} 个关联模型`, - }) - - // 清理状态 - setDeleteConfirmState({ - isOpen: false, - providersToDelete: [], - affectedModels: [], - pendingProviders: [], - context: 'auto', - oldProviders: [], - }) - setSelectedProviders(new Set()) // 清除选中状态(批量删除时) - - // 根据上下文执行后续操作 - if (deleteConfirmState.context === 'restart') { - // 如果是保存并重启,继续执行重启流程 - await handleRestart() - } - } catch (error) { - console.error('删除失败:', error) - toast({ - title: '删除失败', - description: (error as Error).message, - variant: 'destructive', - }) - } finally { - if (deleteConfirmState.context === 'auto') { - setAutoSaving(false) - } else { - setSaving(false) - } - } - } - - // 取消删除提供商 - const handleCancelDeleteProvider = () => { - // 恢复到删除前的 providers 状态 - if (deleteConfirmState.oldProviders.length > 0) { - setProviders(deleteConfirmState.oldProviders) - } - // 清理状态 - setDeleteConfirmState({ - isOpen: false, - providersToDelete: [], - affectedModels: [], - pendingProviders: [], - context: 'auto', - oldProviders: [], - }) - setHasUnsavedChanges(false) - } - - // 自动保存函数(使用增量 API) - const autoSaveProviders = useCallback(async (newProviders: APIProvider[]) => { - if (initialLoadRef.current) return // 初始加载时不自动保存 - - // 检查删除影响 - const { shouldProceed } = await checkDeleteProviderImpact(newProviders, 'auto') - - if (!shouldProceed) { - // 需要用户确认,对话框已打开 - setHasUnsavedChanges(true) - return - } - - try { - setAutoSaving(true) - // 清理 providers 数据:将 null 值转换为默认值 - const cleanedProviders = newProviders.map(cleanProviderData) - const result = await updateModelConfigSection('api_providers', cleanedProviders) - if (!result.success) { - console.error('自动保存失败:', result.error) - toast({ - title: '自动保存失败', - description: result.error, - variant: 'destructive', - }) - setHasUnsavedChanges(true) - return - } - setHasUnsavedChanges(false) - } catch (error) { - console.error('自动保存失败:', error) - toast({ - title: '自动保存失败', - description: (error as Error).message, - variant: 'destructive', - }) - setHasUnsavedChanges(true) - } finally { - setAutoSaving(false) - } - }, [providers, checkDeleteProviderImpact]) - - // 监听 providers 变化,触发自动保存(带防抖) - useEffect(() => { - if (initialLoadRef.current) return - - setHasUnsavedChanges(true) - - // 清除之前的定时器 - if (autoSaveTimerRef.current) { - clearTimeout(autoSaveTimerRef.current) - } - - // 设置新的定时器(2秒后自动保存) - autoSaveTimerRef.current = setTimeout(() => { - autoSaveProviders(providers) - }, 2000) - - // 清理函数 - return () => { - if (autoSaveTimerRef.current) { - clearTimeout(autoSaveTimerRef.current) - } - } - }, [providers, autoSaveProviders]) - - // 保存配置(手动保存,保存完整配置) - const saveConfig = async () => { - try { - setSaving(true) - - // 先取消自动保存定时器 - if (autoSaveTimerRef.current) { - clearTimeout(autoSaveTimerRef.current) - } - - // 清理 providers 数据:将 null 值转换为默认值 - const cleanedProviders = providers.map(cleanProviderData) - - // 检查删除提供商的影响 - const { shouldProceed } = await checkDeleteProviderImpact(cleanedProviders, 'manual') - if (!shouldProceed) { - // 需要用户确认,等待确认对话框 - setSaving(false) - return - } - - const resultGet = await getModelConfig() - if (!resultGet.success) { - toast({ - title: '保存失败', - description: resultGet.error, - variant: 'destructive', - }) - setSaving(false) - return - } - const config = resultGet.data - - // 获取所有有效的 provider 名称 - const validProviderNames = new Set(cleanedProviders.map(p => p.name)) - - // 过滤掉引用已删除 provider 的模型 - const originalModels = (config.models as any[]) || [] - const filteredModels = originalModels.filter((model: any) => { - const isValid = validProviderNames.has(model.api_provider) - if (!isValid) { - console.warn(`模型 "${model.name}" 引用了已删除的提供商 "${model.api_provider}"、将被移除`) - } - return isValid - }) - - // 如果有模型被移除、显示警告 - if (originalModels.length !== filteredModels.length) { - const removedCount = originalModels.length - filteredModels.length - toast({ - title: '注意', - description: `已自动移除 ${removedCount} 个引用已删除提供商的模型`, - variant: 'default', - }) - } - - console.log('发送的 providers 数据:', cleanedProviders) - config.api_providers = cleanedProviders - config.models = filteredModels - console.log('完整配置数据:', config) - - const resultUpdate = await updateModelConfig(config) - if (!resultUpdate.success) { - toast({ - title: '保存失败', - description: resultUpdate.error, - variant: 'destructive', - }) - setSaving(false) - return - } - setHasUnsavedChanges(false) - toast({ - title: '保存成功', - description: '模型提供商配置已保存', - }) - } catch (error) { - console.error('保存配置失败:', error) - toast({ - title: '保存失败', - description: (error as Error).message, - variant: 'destructive', - }) - } finally { - setSaving(false) - } - } - - // 打开编辑对话框 - const openEditDialog = (provider: APIProvider | null, index: number | null) => { - // 清除表单验证错误 - setFormErrors({}) - - if (provider) { - // 编辑现有提供商 - 检测匹配的模板 - const matchedTemplate = PROVIDER_TEMPLATES.find( - t => t.base_url === provider.base_url && t.client_type === provider.client_type - ) - setSelectedTemplate(matchedTemplate?.id || 'custom') - setEditingProvider(provider) - } else { - // 新建提供商 - 默认使用自定义模板 - setSelectedTemplate('custom') - setEditingProvider({ - name: '', - base_url: '', - api_key: '', - client_type: 'openai', - max_retry: 2, - timeout: 30, - retry_interval: 10, - }) - } - setEditingIndex(index) - setShowApiKey(false) - setEditDialogOpen(true) - } - - // 处理模板选择变化 - const handleTemplateChange = useCallback((templateId: string) => { - setSelectedTemplate(templateId) - setTemplateComboboxOpen(false) - const template = PROVIDER_TEMPLATES.find(t => t.id === templateId) - if (template && template.id !== 'custom') { - // 应用模板配置 - setEditingProvider(prev => ({ - ...prev!, - name: template.name, - base_url: template.base_url, - client_type: template.client_type, - })) - } else if (template?.id === 'custom') { - // 切换到自定义模板 - 清空URL和客户端类型(保留其他字段) - setEditingProvider(prev => ({ - ...prev!, - name: '', - base_url: '', - client_type: 'openai', - })) - } - }, []) - - // 判断当前是否使用模板(非自定义) - const isUsingTemplate = useMemo(() => { - return selectedTemplate !== 'custom' - }, [selectedTemplate]) - - // 复制 API Key - const copyApiKey = useCallback(async () => { - if (!editingProvider?.api_key) return - try { - await navigator.clipboard.writeText(editingProvider.api_key) - toast({ - title: '复制成功', - description: 'API Key 已复制到剪贴板', - }) - } catch { - toast({ - title: '复制失败', - description: '无法访问剪贴板', - variant: 'destructive', - }) - } - }, [editingProvider?.api_key, toast]) - - // 保存编辑 - const handleSaveEdit = () => { - if (!editingProvider) return - - // 验证必填项(传入现有提供商列表和当前编辑索引用于重复检查) - const { isValid, errors } = validateProvider(editingProvider, providers, editingIndex) - - if (!isValid) { - setFormErrors(errors) - return - } - - // 清除错误状态 - setFormErrors({}) - - // 填充空值的默认值 - const providerToSave = cleanProviderData(editingProvider) - - if (editingIndex !== null) { - // 更新现有提供商 - const newProviders = [...providers] - newProviders[editingIndex] = providerToSave - setProviders(newProviders) - } else { - // 添加新提供商 - setProviders([...providers, providerToSave]) - } - - setEditDialogOpen(false) - setEditingProvider(null) - setEditingIndex(null) - } - - // 处理编辑对话框关闭 - const handleEditDialogClose = (open: boolean) => { - if (!open && editingProvider) { - // 关闭时填充默认值 - const updatedProvider = { - ...editingProvider, - max_retry: editingProvider.max_retry ?? 2, - timeout: editingProvider.timeout ?? 30, - retry_interval: editingProvider.retry_interval ?? 10, - } - setEditingProvider(updatedProvider) - } - setEditDialogOpen(open) - } - - // 打开删除确认对话框 - const openDeleteDialog = (index: number) => { - setDeletingIndex(index) - setDeleteDialogOpen(true) - } - - // 确认删除提供商 - const handleConfirmDelete = async () => { - if (deletingIndex !== null) { - const newProviders = providers.filter((_, i) => i !== deletingIndex) - - // 检查删除影响 - const { shouldProceed } = await checkDeleteProviderImpact(newProviders, 'manual') - - if (shouldProceed) { - // 没有影响,直接删除 - setProviders(newProviders) - toast({ - title: '删除成功', - description: '提供商已从列表中移除', - }) - } - // 如果 shouldProceed = false,对话框会自动打开,等待用户确认 - } - setDeleteDialogOpen(false) - setDeletingIndex(null) - } - - // 切换单个提供商选择 - const toggleProviderSelection = (index: number) => { - const newSelected = new Set(selectedProviders) - if (newSelected.has(index)) { - newSelected.delete(index) - } else { - newSelected.add(index) - } - setSelectedProviders(newSelected) - } - - // 全选/取消全选 - const toggleSelectAll = () => { - if (selectedProviders.size === filteredProviders.length) { - setSelectedProviders(new Set()) - } else { - const allIndices = filteredProviders.map((_, idx) => - providers.findIndex(p => p === filteredProviders[idx]) - ) - setSelectedProviders(new Set(allIndices)) - } - } - - // 打开批量删除确认对话框 - const openBatchDeleteDialog = () => { - if (selectedProviders.size === 0) { - toast({ - title: '提示', - description: '请先选择要删除的提供商', - variant: 'default', - }) - return - } - setBatchDeleteDialogOpen(true) - } - - // 确认批量删除 - const handleConfirmBatchDelete = async () => { - const newProviders = providers.filter((_, index) => !selectedProviders.has(index)) - - // 检查删除影响 - const { shouldProceed } = await checkDeleteProviderImpact(newProviders, 'manual') - - if (shouldProceed) { - // 没有影响,直接删除 - setProviders(newProviders) - setSelectedProviders(new Set()) - toast({ - title: '批量删除成功', - description: `已删除 ${selectedProviders.size} 个提供商`, - }) - } - // 如果 shouldProceed = false,对话框会自动打开,等待用户确认 - - setBatchDeleteDialogOpen(false) - } - - // 过滤提供商列表(使用 useMemo 优化性能) - const filteredProviders = useMemo(() => { - if (!searchQuery) return providers - const query = searchQuery.toLowerCase() - return providers.filter((provider) => ( - provider.name.toLowerCase().includes(query) || - provider.base_url.toLowerCase().includes(query) || - provider.client_type.toLowerCase().includes(query) - )) - }, [providers, searchQuery]) - - // 分页逻辑(使用 useMemo 优化性能) - const { totalPages, paginatedProviders } = useMemo(() => { - const total = Math.ceil(filteredProviders.length / pageSize) - const paginated = filteredProviders.slice( - (page - 1) * pageSize, - page * pageSize - ) - return { totalPages: total, paginatedProviders: paginated } - }, [filteredProviders, page, pageSize]) - - // 页码跳转 - const handleJumpToPage = useCallback(() => { - const targetPage = parseInt(jumpToPage) - if (targetPage >= 1 && targetPage <= totalPages) { - setPage(targetPage) - setJumpToPage('') - } - }, [jumpToPage, totalPages]) - - // 测试单个提供商连接 - const handleTestConnection = async (providerName: string) => { - // 标记正在测试 - setTestingProviders(prev => new Set(prev).add(providerName)) - - try { - const result = await testProviderConnection(providerName) - if (!result.success) { - toast({ - title: '测试失败', - description: result.error, - variant: 'destructive', - }) - return - } - const testResult = result.data - setTestResults(prev => new Map(prev).set(providerName, testResult)) - - // 显示结果 toast - if (testResult.network_ok) { - if (testResult.api_key_valid === true) { - toast({ - title: '连接正常', - description: `${providerName} 网络连接正常、API Key 有效 (${testResult.latency_ms}ms)`, - }) - } else if (testResult.api_key_valid === false) { - toast({ - title: '连接正常但 Key 无效', - description: `${providerName} 网络连接正常、但 API Key 无效或已过期`, - variant: 'destructive', - }) - } else { - toast({ - title: '网络连接正常', - description: `${providerName} 可以访问 (${testResult.latency_ms}ms)`, - }) - } - } else { - toast({ - title: '连接失败', - description: testResult.error || '无法连接到提供商', - variant: 'destructive', - }) - } - } catch (error) { - toast({ - title: '测试失败', - description: (error as Error).message, - variant: 'destructive', - }) - } finally { - setTestingProviders(prev => { - const newSet = new Set(prev) - newSet.delete(providerName) - return newSet - }) - } - } - - // 批量测试所有提供商 - const handleTestAllConnections = async () => { - for (const provider of providers) { - await handleTestConnection(provider.name) - } - } - - // 渲染测试状态指示器 - const renderTestStatus = (providerName: string) => { - const isTesting = testingProviders.has(providerName) - const result = testResults.get(providerName) - - if (isTesting) { - return ( - - - 测试中 - - ) - } - - if (!result) return null - - if (result.network_ok) { - if (result.api_key_valid === true) { - return ( - - - 正常 - - ) - } else if (result.api_key_valid === false) { - return ( - - - Key无效 - - ) - } else { - return ( - - - 可访问 - - ) - } - } else { - return ( - - - 离线 - - ) - } - } - - if (loading) { - return ( -
    -
    -

    加载中...

    -
    -
    - ) - } - - return ( -
    - {/* 页面标题 */} -
    -
    -

    AI模型厂商配置

    -

    管理 AI 模型厂商的 API 配置

    -
    -
    - {selectedProviders.size > 0 && ( - - )} - - - - - - - - - - 确认重启麦麦? - -
    -

    - {hasUnsavedChanges - ? '当前有未保存的配置更改。点击确认将先保存配置,然后重启麦麦使新配置生效。重启过程中麦麦将暂时离线。' - : '即将重启麦麦主程序。重启过程中麦麦将暂时离线,配置将在重启后生效。' - } -

    -
    -
    -
    - - 取消 - - {hasUnsavedChanges ? '保存并重启' : '确认重启'} - - -
    -
    -
    -
    - - {/* 重启提示 */} - - - - 配置更新后需要重启麦麦才能生效。你可以点击右上角的"保存并重启"按钮一键完成保存和重启。 - - - - - {/* 搜索框 */} -
    -
    - - setSearchQuery(e.target.value)} - className="pl-9" - /> -
    - {searchQuery && ( -

    - 找到 {filteredProviders.length} 个结果 -

    - )} -
    - - {/* 提供商列表 - 移动端卡片视图 */} -
    - {filteredProviders.length === 0 ? ( -
    - {searchQuery ? '未找到匹配的提供商' : '暂无提供商配置,点击"添加提供商"开始配置'} -
    - ) : ( - paginatedProviders.map((provider, displayIndex) => { - const actualIndex = providers.findIndex(p => p === provider) - return ( -
    -
    -
    -
    -

    {provider.name}

    - {renderTestStatus(provider.name)} -
    -

    {provider.base_url}

    -
    -
    - - - -
    -
    -
    -
    - 客户端类型 -

    {provider.client_type}

    -
    -
    - 最大重试 -

    {provider.max_retry}

    -
    -
    - 超时(秒) -

    {provider.timeout}

    -
    -
    - 重试间隔(秒) -

    {provider.retry_interval}

    -
    -
    -
    - ) - }) - )} -
    - - {/* 提供商列表 - 桌面端表格视图 */} -
    -
    - - - - - 0} - onCheckedChange={toggleSelectAll} - /> - - 状态 - 名称 - 基础URL - 客户端类型 - 最大重试 - 超时(秒) - 重试间隔(秒) - 操作 - - - - {paginatedProviders.length === 0 ? ( - - - {searchQuery ? '未找到匹配的提供商' : '暂无提供商配置,点击"添加提供商"开始配置'} - - - ) : ( - paginatedProviders.map((provider, displayIndex) => { - const actualIndex = providers.findIndex(p => p === provider) - return ( - - - toggleProviderSelection(actualIndex)} - /> - - - {renderTestStatus(provider.name) || ( - - 未测试 - - )} - - {provider.name} - - {provider.base_url} - - {provider.client_type} - {provider.max_retry} - {provider.timeout} - {provider.retry_interval} - -
    - - - -
    -
    -
    - ) - }) - )} -
    -
    -
    -
    - - {/* 分页 - 增强版 */} - {filteredProviders.length > 0 && ( -
    -
    - - - - 显示 {(page - 1) * pageSize + 1} 到{' '} - {Math.min(page * pageSize, filteredProviders.length)} 条,共 {filteredProviders.length} 条 - -
    -
    - - -
    - setJumpToPage(e.target.value)} - onKeyDown={(e) => e.key === 'Enter' && handleJumpToPage()} - placeholder={page.toString()} - className="w-16 h-8 text-center" - min={1} - max={totalPages} - /> - -
    - - -
    -
    - )} -
    - - {/* 编辑对话框 */} - - - - - {editingIndex !== null ? '编辑提供商' : '添加提供商'} - - - 配置 API 提供商的连接信息和参数 - - - -
    { e.preventDefault(); handleSaveEdit(); }} autoComplete="off"> -
    -
    - - - - - - - - - - - 未找到匹配的模板 - - {PROVIDER_TEMPLATES.map((template) => ( - handleTemplateChange(template.id)} - > - - {template.display_name} - - ))} - - - - - - -

    - 选择预设模板可自动填充 URL 和客户端类型,支持搜索 -

    -
    - -
    -
    - - -

    提供商名称

    -

    为这个 API 提供商设置一个便于识别的名称,用于在模型配置中引用。

    -
      -
    • 推荐使用厂商官方名称,如 DeepSeek、OpenAI
    • -
    • 名称需要唯一,不能与现有提供商重复
    • -
    -
    - } - side="right" - maxWidth="350px" - /> -
    - { - setEditingProvider((prev) => - prev ? { ...prev, name: e.target.value } : null - ) - if (formErrors.name) { - setFormErrors((prev) => ({ ...prev, name: undefined })) - } - }} - placeholder="例如: DeepSeek, SiliconFlow" - className={formErrors.name ? 'border-destructive focus-visible:ring-destructive' : ''} - /> - {formErrors.name && ( -

    {formErrors.name}

    - )} -
    - -
    -
    - - -

    API 基础地址

    -

    提供商的 API 端点基础 URL,通常以 /v1 结尾。

    -
      -
    • OpenAI 格式:https://api.openai.com/v1
    • -
    • DeepSeek:https://api.deepseek.com
    • -
    • 硅基流动:https://api.siliconflow.cn/v1
    • -
    • 选择模板会自动填充正确的 URL
    • -
    -
    - } - side="right" - maxWidth="400px" - /> -
    - { - setEditingProvider((prev) => - prev ? { ...prev, base_url: e.target.value } : null - ) - if (formErrors.base_url) { - setFormErrors((prev) => ({ ...prev, base_url: undefined })) - } - }} - placeholder="https://api.example.com/v1" - disabled={isUsingTemplate} - className={`${isUsingTemplate ? 'bg-muted cursor-not-allowed' : ''} ${formErrors.base_url ? 'border-destructive focus-visible:ring-destructive' : ''}`} - /> - {formErrors.base_url && ( -

    {formErrors.base_url}

    - )} - {isUsingTemplate && !formErrors.base_url && ( -

    - 使用模板时 URL 不可编辑,切换到"自定义"以手动配置 -

    - )} -
    - -
    -
    - - -

    API 密钥

    -

    从提供商平台获取的身份验证密钥。

    -
      -
    • 通常以 sk- 开头
    • -
    • 请妥善保管,不要泄露给他人
    • -
    • 可以点击眼睛图标切换显示/隐藏
    • -
    • 点击复制图标可快速复制密钥
    • -
    -
    - } - side="right" - maxWidth="350px" - /> -
    -
    - { - setEditingProvider((prev) => - prev ? { ...prev, api_key: e.target.value } : null - ) - if (formErrors.api_key) { - setFormErrors((prev) => ({ ...prev, api_key: undefined })) - } - }} - placeholder="sk-..." - className={`flex-1 ${formErrors.api_key ? 'border-destructive focus-visible:ring-destructive' : ''}`} - /> - - -
    - {formErrors.api_key && ( -

    {formErrors.api_key}

    - )} -
    - -
    -
    - - -

    API 客户端类型

    -

    指定与提供商通信时使用的 API 协议格式。

    -
      -
    • OpenAI:兼容 OpenAI API 格式的提供商
    • -
    • Gemini:Google Gemini 专用格式
    • -
    • 大部分第三方提供商都兼容 OpenAI 格式
    • -
    -
    - } - side="right" - maxWidth="350px" - /> -
    - - {isUsingTemplate && ( -

    - 使用模板时客户端类型不可编辑,切换到"自定义"以手动配置 -

    - )} -
    - -
    -
    -
    - - -
    - { - const val = e.target.value === '' ? null : parseInt(e.target.value) - setEditingProvider((prev) => - prev ? { ...prev, max_retry: val } : null - ) - }} - placeholder="默认: 2" - /> -
    - -
    -
    - - -
    - { - const val = e.target.value === '' ? null : parseInt(e.target.value) - setEditingProvider((prev) => - prev ? { ...prev, timeout: val } : null - ) - }} - placeholder="默认: 30" - /> -
    - -
    -
    - - -
    - { - const val = e.target.value === '' ? null : parseInt(e.target.value) - setEditingProvider((prev) => - prev - ? { ...prev, retry_interval: val } - : null - ) - }} - placeholder="默认: 10" - /> -
    -
    -
    - - - - - - - - - - {/* 删除确认对话框 */} - - - - 确认删除 - - 确定要删除提供商 "{deletingIndex !== null ? providers[deletingIndex]?.name : ''}" 吗? - 此操作无法撤销。 - - - - 取消 - 删除 - - - - - {/* 批量删除确认对话框 */} - - - - 确认批量删除 - - 确定要删除选中的 {selectedProviders.size} 个提供商吗? - 此操作无法撤销。 - - - - 取消 - - 批量删除 - - - - - - {/* 删除提供商影响确认对话框 */} - setDeleteConfirmState(prev => ({ ...prev, isOpen: open }))}> - - - 确认删除提供商 - -
    -

    - 您即将删除以下提供商: - - {deleteConfirmState.providersToDelete.join(', ')} - -

    -

    - ⚠️ 此操作将同时删除 {deleteConfirmState.affectedModels.length} 个关联的模型: -

    - -
    - {deleteConfirmState.affectedModels.map((model: any, idx: number) => ( -
    - - {model.name} - - ({model.model_identifier}) - -
    - ))} -
    -
    -

    - 这些模型将从模型列表和所有任务分配中移除。此操作无法撤销。 -

    -
    -
    -
    - - 取消 - - 确认删除 - - -
    -
    - - {/* 重启遮罩层 */} - - - ) -} diff --git a/dashboard/src/routes/config/modelProvider/ProviderCard.tsx b/dashboard/src/routes/config/modelProvider/ProviderCard.tsx new file mode 100644 index 00000000..91319b5a --- /dev/null +++ b/dashboard/src/routes/config/modelProvider/ProviderCard.tsx @@ -0,0 +1,136 @@ +import type { TestConnectionResult } from '@/lib/config-api' +import { AlertCircle, CheckCircle2, Loader2, Pencil, Trash2, XCircle, Zap } from 'lucide-react' + +import { Badge } from '@/components/ui/badge' +import { Button } from '@/components/ui/button' + +import type { APIProvider } from './types' + +interface ProviderCardProps { + provider: APIProvider + actualIndex: number + testingProviders: Set + testResults: Map + onEdit: (provider: APIProvider, index: number) => void + onDelete: (index: number) => void + onTest: (name: string) => void +} + +export function ProviderCard({ + provider, + actualIndex, + testingProviders, + testResults, + onEdit, + onDelete, + onTest, +}: ProviderCardProps) { + const renderTestStatus = () => { + const isTesting = testingProviders.has(provider.name) + const result = testResults.get(provider.name) + + if (isTesting) { + return ( + + + 测试中 + + ) + } + + if (!result) return null + + if (result.network_ok) { + if (result.api_key_valid === true) { + return ( + + + 正常 + + ) + } else if (result.api_key_valid === false) { + return ( + + + Key无效 + + ) + } else { + return ( + + + 可访问 + + ) + } + } else { + return ( + + + 离线 + + ) + } + } + + return ( +
    +
    +
    +
    +

    {provider.name}

    + {renderTestStatus()} +
    +

    {provider.base_url}

    +
    +
    + + + +
    +
    +
    +
    + 客户端类型 +

    {provider.client_type}

    +
    +
    + 最大重试 +

    {provider.max_retry}

    +
    +
    + 超时(秒) +

    {provider.timeout}

    +
    +
    + 重试间隔(秒) +

    {provider.retry_interval}

    +
    +
    +
    + ) +} diff --git a/dashboard/src/routes/config/modelProvider/ProviderForm.tsx b/dashboard/src/routes/config/modelProvider/ProviderForm.tsx new file mode 100644 index 00000000..81a9adad --- /dev/null +++ b/dashboard/src/routes/config/modelProvider/ProviderForm.tsx @@ -0,0 +1,458 @@ +import { useCallback, useMemo, useState } from 'react' +import { Check, ChevronsUpDown, Copy, Eye, EyeOff } from 'lucide-react' + +import { Button } from '@/components/ui/button' +import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from '@/components/ui/command' +import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog' +import { HelpTooltip } from '@/components/ui/help-tooltip' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover' +import { ScrollArea } from '@/components/ui/scroll-area' +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' +import { useToast } from '@/hooks/use-toast' + +import { PROVIDER_TEMPLATES } from '../providerTemplates' +import type { APIProvider, FormErrors } from './types' +import { validateProvider } from './utils' + +interface ProviderFormProps { + open: boolean + onOpenChange: (open: boolean) => void + editingProvider: APIProvider | null + editingIndex: number | null + providers: APIProvider[] + onSave: (provider: APIProvider, index: number | null) => void + tourState: { isRunning: boolean } +} + +export function ProviderForm({ + open, + onOpenChange, + editingProvider, + editingIndex, + providers, + onSave, + tourState, +}: ProviderFormProps) { + const [formErrors, setFormErrors] = useState({}) + const [selectedTemplate, setSelectedTemplate] = useState('custom') + const [templateComboboxOpen, setTemplateComboboxOpen] = useState(false) + const [showApiKey, setShowApiKey] = useState(false) + const [localProvider, setLocalProvider] = useState(editingProvider) + const { toast } = useToast() + + // 同步外部状态到本地 + if (editingProvider !== localProvider && open) { + setLocalProvider(editingProvider) + setFormErrors({}) + setShowApiKey(false) + + // 检测匹配的模板 + if (editingProvider) { + const matchedTemplate = PROVIDER_TEMPLATES.find( + t => t.base_url === editingProvider.base_url && t.client_type === editingProvider.client_type + ) + setSelectedTemplate(matchedTemplate?.id || 'custom') + } else { + setSelectedTemplate('custom') + } + } + + const isUsingTemplate = useMemo(() => selectedTemplate !== 'custom', [selectedTemplate]) + + const handleTemplateChange = useCallback((templateId: string) => { + setSelectedTemplate(templateId) + setTemplateComboboxOpen(false) + const template = PROVIDER_TEMPLATES.find(t => t.id === templateId) + if (template && template.id !== 'custom') { + setLocalProvider(prev => ({ + ...prev!, + name: template.name, + base_url: template.base_url, + client_type: template.client_type, + })) + } else if (template?.id === 'custom') { + setLocalProvider(prev => ({ + ...prev!, + name: '', + base_url: '', + client_type: 'openai', + })) + } + }, []) + + const copyApiKey = useCallback(async () => { + if (!localProvider?.api_key) return + try { + await navigator.clipboard.writeText(localProvider.api_key) + toast({ + title: '复制成功', + description: 'API Key 已复制到剪贴板', + }) + } catch { + toast({ + title: '复制失败', + description: '无法访问剪贴板', + variant: 'destructive', + }) + } + }, [localProvider?.api_key, toast]) + + const handleSaveEdit = () => { + if (!localProvider) return + + const { isValid, errors } = validateProvider(localProvider, providers, editingIndex) + + if (!isValid) { + setFormErrors(errors) + return + } + + setFormErrors({}) + onSave(localProvider, editingIndex) + } + + return ( + + + + + {editingIndex !== null ? '编辑提供商' : '添加提供商'} + + + 配置 API 提供商的连接信息和参数 + + + +
    { e.preventDefault(); handleSaveEdit(); }} autoComplete="off"> +
    +
    + + + + + + + + + + + 未找到匹配的模板 + + {PROVIDER_TEMPLATES.map((template) => ( + handleTemplateChange(template.id)} + > + + {template.display_name} + + ))} + + + + + + +

    + 选择预设模板可自动填充 URL 和客户端类型,支持搜索 +

    +
    + +
    +
    + + +

    提供商名称

    +

    为这个 API 提供商设置一个便于识别的名称,用于在模型配置中引用。

    +
      +
    • 推荐使用厂商官方名称,如 DeepSeek、OpenAI
    • +
    • 名称需要唯一,不能与现有提供商重复
    • +
    +
    + } + side="right" + maxWidth="350px" + /> +
    + { + setLocalProvider((prev) => + prev ? { ...prev, name: e.target.value } : null + ) + if (formErrors.name) { + setFormErrors((prev) => ({ ...prev, name: undefined })) + } + }} + placeholder="例如: DeepSeek, SiliconFlow" + className={formErrors.name ? 'border-destructive focus-visible:ring-destructive' : ''} + /> + {formErrors.name && ( +

    {formErrors.name}

    + )} +
    + +
    +
    + + +

    API 基础地址

    +

    提供商的 API 端点基础 URL,通常以 /v1 结尾。

    +
      +
    • OpenAI 格式:https://api.openai.com/v1
    • +
    • DeepSeek:https://api.deepseek.com
    • +
    • 硅基流动:https://api.siliconflow.cn/v1
    • +
    • 选择模板会自动填充正确的 URL
    • +
    +
    + } + side="right" + maxWidth="400px" + /> +
    + { + setLocalProvider((prev) => + prev ? { ...prev, base_url: e.target.value } : null + ) + if (formErrors.base_url) { + setFormErrors((prev) => ({ ...prev, base_url: undefined })) + } + }} + placeholder="https://api.example.com/v1" + disabled={isUsingTemplate} + className={`${isUsingTemplate ? 'bg-muted cursor-not-allowed' : ''} ${formErrors.base_url ? 'border-destructive focus-visible:ring-destructive' : ''}`} + /> + {formErrors.base_url && ( +

    {formErrors.base_url}

    + )} + {isUsingTemplate && !formErrors.base_url && ( +

    + 使用模板时 URL 不可编辑,切换到"自定义"以手动配置 +

    + )} + + +
    +
    + + +

    API 密钥

    +

    从提供商平台获取的身份验证密钥。

    +
      +
    • 通常以 sk- 开头
    • +
    • 请妥善保管,不要泄露给他人
    • +
    • 可以点击眼睛图标切换显示/隐藏
    • +
    • 点击复制图标可快速复制密钥
    • +
    +
    + } + side="right" + maxWidth="350px" + /> +
    +
    + { + setLocalProvider((prev) => + prev ? { ...prev, api_key: e.target.value } : null + ) + if (formErrors.api_key) { + setFormErrors((prev) => ({ ...prev, api_key: undefined })) + } + }} + placeholder="sk-..." + className={`flex-1 ${formErrors.api_key ? 'border-destructive focus-visible:ring-destructive' : ''}`} + /> + + +
    + {formErrors.api_key && ( +

    {formErrors.api_key}

    + )} + + +
    +
    + + +

    API 客户端类型

    +

    指定与提供商通信时使用的 API 协议格式。

    +
      +
    • OpenAI:兼容 OpenAI API 格式的提供商
    • +
    • Gemini:Google Gemini 专用格式
    • +
    • 大部分第三方提供商都兼容 OpenAI 格式
    • +
    +
    + } + side="right" + maxWidth="350px" + /> +
    + + {isUsingTemplate && ( +

    + 使用模板时客户端类型不可编辑,切换到"自定义"以手动配置 +

    + )} + + +
    +
    +
    + + +
    + { + const val = e.target.value === '' ? null : parseInt(e.target.value) + setLocalProvider((prev) => + prev ? { ...prev, max_retry: val } : null + ) + }} + placeholder="默认: 2" + /> +
    + +
    +
    + + +
    + { + const val = e.target.value === '' ? null : parseInt(e.target.value) + setLocalProvider((prev) => + prev ? { ...prev, timeout: val } : null + ) + }} + placeholder="默认: 30" + /> +
    + +
    +
    + + +
    + { + const val = e.target.value === '' ? null : parseInt(e.target.value) + setLocalProvider((prev) => + prev + ? { ...prev, retry_interval: val } + : null + ) + }} + placeholder="默认: 10" + /> +
    +
    + + + + + + +
    +
    +
    + ) +} diff --git a/dashboard/src/routes/config/modelProvider/ProviderList.tsx b/dashboard/src/routes/config/modelProvider/ProviderList.tsx new file mode 100644 index 00000000..8b31008a --- /dev/null +++ b/dashboard/src/routes/config/modelProvider/ProviderList.tsx @@ -0,0 +1,353 @@ +import { useCallback, useMemo, useState } from 'react' +import type { TestConnectionResult } from '@/lib/config-api' +import { AlertCircle, CheckCircle2, ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight, Loader2, Pencil, Search, Trash2, XCircle, Zap } from 'lucide-react' + +import { Badge } from '@/components/ui/badge' +import { Button } from '@/components/ui/button' +import { Checkbox } from '@/components/ui/checkbox' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table' + +import { ProviderCard } from './ProviderCard' +import type { APIProvider } from './types' + +interface ProviderListProps { + providers: APIProvider[] + testingProviders: Set + testResults: Map + selectedProviders: Set + onEdit: (provider: APIProvider, index: number) => void + onDelete: (index: number) => void + onTest: (name: string) => void + onToggleSelect: (index: number) => void + onToggleSelectAll: () => void +} + +export function ProviderList({ + providers, + testingProviders, + testResults, + selectedProviders, + onEdit, + onDelete, + onTest, + onToggleSelect, + onToggleSelectAll, +}: ProviderListProps) { + const [searchQuery, setSearchQuery] = useState('') + const [page, setPage] = useState(1) + const [pageSize, setPageSize] = useState(20) + const [jumpToPage, setJumpToPage] = useState('') + + const filteredProviders = useMemo(() => { + if (!searchQuery) return providers + const query = searchQuery.toLowerCase() + return providers.filter((provider) => ( + provider.name.toLowerCase().includes(query) || + provider.base_url.toLowerCase().includes(query) || + provider.client_type.toLowerCase().includes(query) + )) + }, [providers, searchQuery]) + + const { totalPages, paginatedProviders } = useMemo(() => { + const total = Math.ceil(filteredProviders.length / pageSize) + const paginated = filteredProviders.slice( + (page - 1) * pageSize, + page * pageSize + ) + return { totalPages: total, paginatedProviders: paginated } + }, [filteredProviders, page, pageSize]) + + const handleJumpToPage = useCallback(() => { + const targetPage = parseInt(jumpToPage) + if (targetPage >= 1 && targetPage <= totalPages) { + setPage(targetPage) + setJumpToPage('') + } + }, [jumpToPage, totalPages]) + + const renderTestStatus = (providerName: string) => { + const isTesting = testingProviders.has(providerName) + const result = testResults.get(providerName) + + if (isTesting) { + return ( + + + 测试中 + + ) + } + + if (!result) { + return ( + + 未测试 + + ) + } + + if (result.network_ok) { + if (result.api_key_valid === true) { + return ( + + + 正常 + + ) + } else if (result.api_key_valid === false) { + return ( + + + Key无效 + + ) + } else { + return ( + + + 可访问 + + ) + } + } else { + return ( + + + 离线 + + ) + } + } + + return ( + <> + {/* 搜索框 */} +
    +
    + + setSearchQuery(e.target.value)} + className="pl-9" + /> +
    + {searchQuery && ( +

    + 找到 {filteredProviders.length} 个结果 +

    + )} +
    + + {/* 移动端卡片视图 */} +
    + {filteredProviders.length === 0 ? ( +
    + {searchQuery ? '未找到匹配的提供商' : '暂无提供商配置,点击"添加提供商"开始配置'} +
    + ) : ( + paginatedProviders.map((provider, displayIndex) => { + const actualIndex = providers.findIndex(p => p === provider) + return ( + + ) + }) + )} +
    + + {/* 桌面端表格视图 */} +
    +
    + + + + + 0} + onCheckedChange={onToggleSelectAll} + /> + + 状态 + 名称 + 基础URL + 客户端类型 + 最大重试 + 超时(秒) + 重试间隔(秒) + 操作 + + + + {paginatedProviders.length === 0 ? ( + + + {searchQuery ? '未找到匹配的提供商' : '暂无提供商配置,点击"添加提供商"开始配置'} + + + ) : ( + paginatedProviders.map((provider, displayIndex) => { + const actualIndex = providers.findIndex(p => p === provider) + return ( + + + onToggleSelect(actualIndex)} + /> + + + {renderTestStatus(provider.name)} + + {provider.name} + + {provider.base_url} + + {provider.client_type} + {provider.max_retry} + {provider.timeout} + {provider.retry_interval} + +
    + + + +
    +
    +
    + ) + }) + )} +
    +
    +
    +
    + + {/* 分页 */} + {filteredProviders.length > 0 && ( +
    +
    + + + + 显示 {(page - 1) * pageSize + 1} 到{' '} + {Math.min(page * pageSize, filteredProviders.length)} 条,共 {filteredProviders.length} 条 + +
    +
    + + +
    + setJumpToPage(e.target.value)} + onKeyDown={(e) => e.key === 'Enter' && handleJumpToPage()} + placeholder={page.toString()} + className="w-16 h-8 text-center" + min={1} + max={totalPages} + /> + +
    + + +
    +
    + )} + + ) +} diff --git a/dashboard/src/routes/config/modelProvider/index.ts b/dashboard/src/routes/config/modelProvider/index.ts index 43f86993..7d37993e 100644 --- a/dashboard/src/routes/config/modelProvider/index.ts +++ b/dashboard/src/routes/config/modelProvider/index.ts @@ -1,11 +1,2 @@ -/** - * 模型提供商配置模块 - * - * 模块结构: - * - types.ts: 类型定义 - * - utils.ts: 工具函数 - * - 主组件在上级目录的 modelProvider.tsx - */ - -export * from './types' -export * from './utils' +export { ModelProviderConfigPage } from './index.tsx' +export type * from './types' diff --git a/dashboard/src/routes/config/modelProvider/index.tsx b/dashboard/src/routes/config/modelProvider/index.tsx new file mode 100644 index 00000000..00943ccf --- /dev/null +++ b/dashboard/src/routes/config/modelProvider/index.tsx @@ -0,0 +1,895 @@ +import { useCallback, useEffect, useRef, useState } from 'react' +import { useNavigate } from '@tanstack/react-router' +import { getModelConfig, testProviderConnection, updateModelConfig, updateModelConfigSection } from '@/lib/config-api' +import type { TestConnectionResult } from '@/lib/config-api' +import { Info, Plus, Power, Save, Trash2, Zap } from 'lucide-react' + +import { Alert, AlertDescription } from '@/components/ui/alert' +import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger } from '@/components/ui/alert-dialog' +import { Button } from '@/components/ui/button' +import { ScrollArea } from '@/components/ui/scroll-area' +import { MODEL_ASSIGNMENT_TOUR_ID, modelAssignmentTourSteps, STEP_ROUTE_MAP } from '@/components/tour/tours/model-assignment-tour' +import { useTour } from '@/components/tour' +import { useToast } from '@/hooks/use-toast' +import { RestartOverlay } from '@/components/restart-overlay' +import { RestartProvider, useRestart } from '@/lib/restart-context' + +import { ProviderForm } from './ProviderForm' +import { ProviderList } from './ProviderList' +import type { APIProvider, DeleteConfirmState } from './types' +import { cleanProviderData } from './utils' + +export function ModelProviderConfigPage() { + return ( + + + + ) +} + +function ModelProviderConfigPageContent() { + const [providers, setProviders] = useState([]) + const [loading, setLoading] = useState(true) + const [saving, setSaving] = useState(false) + const [autoSaving, setAutoSaving] = useState(false) + const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false) + const [editDialogOpen, setEditDialogOpen] = useState(false) + const [editingProvider, setEditingProvider] = useState(null) + const [editingIndex, setEditingIndex] = useState(null) + const [deleteDialogOpen, setDeleteDialogOpen] = useState(false) + const [deletingIndex, setDeletingIndex] = useState(null) + const [selectedProviders, setSelectedProviders] = useState>(new Set()) + const [batchDeleteDialogOpen, setBatchDeleteDialogOpen] = useState(false) + const [deleteConfirmState, setDeleteConfirmState] = useState({ + isOpen: false, + providersToDelete: [], + affectedModels: [], + pendingProviders: [], + context: 'auto', + oldProviders: [], + }) + const [testingProviders, setTestingProviders] = useState>(new Set()) + const [testResults, setTestResults] = useState>(new Map()) + + const { toast } = useToast() + const navigate = useNavigate() + const { state: tourState, goToStep, registerTour } = useTour() + const { triggerRestart, isRestarting } = useRestart() + + const autoSaveTimerRef = useRef | null>(null) + const initialLoadRef = useRef(true) + const prevTourStepRef = useRef(tourState.stepIndex) + + // 注册 Tour + useEffect(() => { + registerTour(MODEL_ASSIGNMENT_TOUR_ID, modelAssignmentTourSteps) + }, [registerTour]) + + // 监听 Tour 步骤变化,处理页面导航 + useEffect(() => { + if (tourState.activeTourId === MODEL_ASSIGNMENT_TOUR_ID && tourState.isRunning) { + const targetRoute = STEP_ROUTE_MAP[tourState.stepIndex] + if (targetRoute && !window.location.pathname.endsWith(targetRoute.replace('/config/', ''))) { + navigate({ to: targetRoute }) + } + } + }, [tourState.stepIndex, tourState.activeTourId, tourState.isRunning, navigate]) + + // 监听 Tour 步骤变化,处理弹窗的打开和关闭 + useEffect(() => { + if (tourState.activeTourId === MODEL_ASSIGNMENT_TOUR_ID && tourState.isRunning) { + const prevStep = prevTourStepRef.current + const currentStep = tourState.stepIndex + + if (prevStep >= 3 && prevStep <= 9 && currentStep < 3) { + setEditDialogOpen(false) + } + + if (prevStep >= 10 && currentStep >= 3 && currentStep <= 9) { + setEditingProvider({ + name: '', + base_url: '', + api_key: '', + client_type: 'openai', + max_retry: 2, + timeout: 30, + retry_interval: 10, + }) + setEditingIndex(null) + setEditDialogOpen(true) + } + + prevTourStepRef.current = currentStep + } + }, [tourState.stepIndex, tourState.activeTourId, tourState.isRunning]) + + // 处理 Tour 中需要用户点击才能继续的步骤 + useEffect(() => { + if (tourState.activeTourId !== MODEL_ASSIGNMENT_TOUR_ID || !tourState.isRunning) return + + const handleTourClick = (e: MouseEvent) => { + const target = e.target as HTMLElement + const currentStep = tourState.stepIndex + + if (currentStep === 2 && target.closest('[data-tour="add-provider-button"]')) { + setTimeout(() => goToStep(3), 300) + } else if (currentStep === 9 && target.closest('[data-tour="provider-cancel-button"]')) { + setTimeout(() => goToStep(10), 300) + } + } + + document.addEventListener('click', handleTourClick, true) + return () => document.removeEventListener('click', handleTourClick, true) + }, [tourState, goToStep]) + + // 加载配置 + useEffect(() => { + loadConfig() + }, []) + + const loadConfig = async () => { + try { + setLoading(true) + const result = await getModelConfig() + if (!result.success) { + toast({ + title: '加载失败', + description: result.error, + variant: 'destructive', + }) + setLoading(false) + return + } + const config = result.data + setProviders((config.api_providers as APIProvider[]) || []) + setHasUnsavedChanges(false) + initialLoadRef.current = false + } catch (error) { + console.error('加载配置失败:', error) + } finally { + setLoading(false) + } + } + + const handleRestart = async () => { + await triggerRestart() + } + + const handleSaveAndRestart = async () => { + try { + setSaving(true) + if (autoSaveTimerRef.current) { + clearTimeout(autoSaveTimerRef.current) + } + + const cleanedProviders = providers.map(provider => ({ + ...provider, + max_retry: provider.max_retry ?? 2, + timeout: provider.timeout ?? 30, + retry_interval: provider.retry_interval ?? 10, + })) + + const { shouldProceed } = await checkDeleteProviderImpact(cleanedProviders, 'restart') + if (!shouldProceed) { + setSaving(false) + return + } + + const resultGet = await getModelConfig() + if (!resultGet.success) { + toast({ + title: '保存失败', + description: resultGet.error, + variant: 'destructive', + }) + setSaving(false) + return + } + const config = resultGet.data + + const validProviderNames = new Set(cleanedProviders.map(p => p.name)) + const originalModels = (config.models as any[]) || [] + const filteredModels = originalModels.filter((model: any) => { + return validProviderNames.has(model.api_provider) + }) + + config.api_providers = cleanedProviders + config.models = filteredModels + + const resultUpdate = await updateModelConfig(config) + if (!resultUpdate.success) { + toast({ + title: '保存失败', + description: resultUpdate.error, + variant: 'destructive', + }) + setSaving(false) + return + } + setHasUnsavedChanges(false) + toast({ + title: '保存成功', + description: '正在重启麦麦...', + }) + await handleRestart() + } catch (error) { + console.error('保存配置失败:', error) + toast({ + title: '保存失败', + description: (error as Error).message, + variant: 'destructive', + }) + setSaving(false) + } + } + + const checkDeleteProviderImpact = useCallback(async ( + newProviders: APIProvider[], + context: 'auto' | 'manual' | 'restart' = 'auto' + ) => { + try { + const result = await getModelConfig() + if (!result.success) { + console.error('加载配置失败:', result.error) + return { shouldProceed: true, providers: newProviders } + } + const config = result.data + const oldProviderNames = new Set(providers.map(p => p.name)) + const newProviderNames = new Set(newProviders.map(p => p.name)) + + const deletedProviders = Array.from(oldProviderNames).filter( + name => !newProviderNames.has(name) + ) + + if (deletedProviders.length === 0) { + return { shouldProceed: true, providers: newProviders } + } + + const models = (config.models as any[]) || [] + const affected = models.filter((m: any) => + deletedProviders.includes(m.api_provider) + ) + + if (affected.length === 0) { + return { shouldProceed: true, providers: newProviders } + } + + setDeleteConfirmState({ + isOpen: true, + providersToDelete: deletedProviders, + affectedModels: affected, + pendingProviders: newProviders, + context, + oldProviders: [...providers], + }) + + return { shouldProceed: false, providers: newProviders } + } catch (error) { + console.error('检查删除影响失败:', error) + return { shouldProceed: true, providers: newProviders } + } + }, [providers]) + + const handleConfirmDeleteProvider = async () => { + try { + const savingFlag = deleteConfirmState.context === 'auto' ? setAutoSaving : setSaving + savingFlag(true) + + setDeleteConfirmState(prev => ({ ...prev, isOpen: false })) + + const resultGet = await getModelConfig() + if (!resultGet.success) { + toast({ + title: '加载失败', + description: resultGet.error, + variant: 'destructive', + }) + savingFlag(false) + return + } + const config = resultGet.data + + const cleanedProviders = deleteConfirmState.pendingProviders.map(cleanProviderData) + const validProviderNames = new Set(cleanedProviders.map(p => p.name)) + const originalModels = (config.models as any[]) || [] + const filteredModels = originalModels.filter((model: any) => { + return validProviderNames.has(model.api_provider) + }) + + const deletedModelNames = new Set( + deleteConfirmState.affectedModels.map((m: any) => m.name) + ) + + const modelTaskConfig = config.model_task_config as any + if (modelTaskConfig) { + Object.keys(modelTaskConfig).forEach(taskName => { + const task = modelTaskConfig[taskName] + if (task && Array.isArray(task.model_list)) { + task.model_list = task.model_list.filter( + (modelName: string) => !deletedModelNames.has(modelName) + ) + } + }) + } + + config.api_providers = cleanedProviders + config.models = filteredModels + config.model_task_config = modelTaskConfig + + const resultUpdate = await updateModelConfig(config) + if (!resultUpdate.success) { + toast({ + title: '保存失败', + description: resultUpdate.error, + variant: 'destructive', + }) + savingFlag(false) + return + } + + setProviders(deleteConfirmState.pendingProviders) + setHasUnsavedChanges(false) + + toast({ + title: '删除成功', + description: `已删除 ${deleteConfirmState.providersToDelete.length} 个提供商和 ${deleteConfirmState.affectedModels.length} 个关联模型`, + }) + + setDeleteConfirmState({ + isOpen: false, + providersToDelete: [], + affectedModels: [], + pendingProviders: [], + context: 'auto', + oldProviders: [], + }) + setSelectedProviders(new Set()) + + if (deleteConfirmState.context === 'restart') { + await handleRestart() + } + } catch (error) { + console.error('删除失败:', error) + toast({ + title: '删除失败', + description: (error as Error).message, + variant: 'destructive', + }) + } finally { + if (deleteConfirmState.context === 'auto') { + setAutoSaving(false) + } else { + setSaving(false) + } + } + } + + const handleCancelDeleteProvider = () => { + if (deleteConfirmState.oldProviders.length > 0) { + setProviders(deleteConfirmState.oldProviders) + } + setDeleteConfirmState({ + isOpen: false, + providersToDelete: [], + affectedModels: [], + pendingProviders: [], + context: 'auto', + oldProviders: [], + }) + setHasUnsavedChanges(false) + } + + const autoSaveProviders = useCallback(async (newProviders: APIProvider[]) => { + if (initialLoadRef.current) return + + const { shouldProceed } = await checkDeleteProviderImpact(newProviders, 'auto') + + if (!shouldProceed) { + setHasUnsavedChanges(true) + return + } + + try { + setAutoSaving(true) + const cleanedProviders = newProviders.map(cleanProviderData) + const result = await updateModelConfigSection('api_providers', cleanedProviders) + if (!result.success) { + console.error('自动保存失败:', result.error) + toast({ + title: '自动保存失败', + description: result.error, + variant: 'destructive', + }) + setHasUnsavedChanges(true) + return + } + setHasUnsavedChanges(false) + } catch (error) { + console.error('自动保存失败:', error) + toast({ + title: '自动保存失败', + description: (error as Error).message, + variant: 'destructive', + }) + setHasUnsavedChanges(true) + } finally { + setAutoSaving(false) + } + }, [providers, checkDeleteProviderImpact]) + + useEffect(() => { + if (initialLoadRef.current) return + + setHasUnsavedChanges(true) + + if (autoSaveTimerRef.current) { + clearTimeout(autoSaveTimerRef.current) + } + + autoSaveTimerRef.current = setTimeout(() => { + autoSaveProviders(providers) + }, 2000) + + return () => { + if (autoSaveTimerRef.current) { + clearTimeout(autoSaveTimerRef.current) + } + } + }, [providers, autoSaveProviders]) + + const saveConfig = async () => { + try { + setSaving(true) + + if (autoSaveTimerRef.current) { + clearTimeout(autoSaveTimerRef.current) + } + + const cleanedProviders = providers.map(cleanProviderData) + + const { shouldProceed } = await checkDeleteProviderImpact(cleanedProviders, 'manual') + if (!shouldProceed) { + setSaving(false) + return + } + + const resultGet = await getModelConfig() + if (!resultGet.success) { + toast({ + title: '保存失败', + description: resultGet.error, + variant: 'destructive', + }) + setSaving(false) + return + } + const config = resultGet.data + + const validProviderNames = new Set(cleanedProviders.map(p => p.name)) + const originalModels = (config.models as any[]) || [] + const filteredModels = originalModels.filter((model: any) => { + const isValid = validProviderNames.has(model.api_provider) + if (!isValid) { + console.warn(`模型 "${model.name}" 引用了已删除的提供商 "${model.api_provider}"、将被移除`) + } + return isValid + }) + + if (originalModels.length !== filteredModels.length) { + const removedCount = originalModels.length - filteredModels.length + toast({ + title: '注意', + description: `已自动移除 ${removedCount} 个引用已删除提供商的模型`, + variant: 'default', + }) + } + + console.log('发送的 providers 数据:', cleanedProviders) + config.api_providers = cleanedProviders + config.models = filteredModels + console.log('完整配置数据:', config) + + const resultUpdate = await updateModelConfig(config) + if (!resultUpdate.success) { + toast({ + title: '保存失败', + description: resultUpdate.error, + variant: 'destructive', + }) + setSaving(false) + return + } + setHasUnsavedChanges(false) + toast({ + title: '保存成功', + description: '模型提供商配置已保存', + }) + } catch (error) { + console.error('保存配置失败:', error) + toast({ + title: '保存失败', + description: (error as Error).message, + variant: 'destructive', + }) + } finally { + setSaving(false) + } + } + + const openEditDialog = (provider: APIProvider | null, index: number | null) => { + if (provider) { + setEditingProvider(provider) + } else { + setEditingProvider({ + name: '', + base_url: '', + api_key: '', + client_type: 'openai', + max_retry: 2, + timeout: 30, + retry_interval: 10, + }) + } + setEditingIndex(index) + setEditDialogOpen(true) + } + + const handleSaveEdit = (provider: APIProvider, index: number | null) => { + const providerToSave = cleanProviderData(provider) + + if (index !== null) { + const newProviders = [...providers] + newProviders[index] = providerToSave + setProviders(newProviders) + } else { + setProviders([...providers, providerToSave]) + } + + setEditDialogOpen(false) + setEditingProvider(null) + setEditingIndex(null) + } + + const openDeleteDialog = (index: number) => { + setDeletingIndex(index) + setDeleteDialogOpen(true) + } + + const handleConfirmDelete = async () => { + if (deletingIndex !== null) { + const newProviders = providers.filter((_, i) => i !== deletingIndex) + + const { shouldProceed } = await checkDeleteProviderImpact(newProviders, 'manual') + + if (shouldProceed) { + setProviders(newProviders) + toast({ + title: '删除成功', + description: '提供商已从列表中移除', + }) + } + } + setDeleteDialogOpen(false) + setDeletingIndex(null) + } + + const toggleProviderSelection = (index: number) => { + const newSelected = new Set(selectedProviders) + if (newSelected.has(index)) { + newSelected.delete(index) + } else { + newSelected.add(index) + } + setSelectedProviders(newSelected) + } + + const toggleSelectAll = () => { + if (selectedProviders.size === providers.length) { + setSelectedProviders(new Set()) + } else { + const allIndices = providers.map((_, idx) => idx) + setSelectedProviders(new Set(allIndices)) + } + } + + const openBatchDeleteDialog = () => { + if (selectedProviders.size === 0) { + toast({ + title: '提示', + description: '请先选择要删除的提供商', + variant: 'default', + }) + return + } + setBatchDeleteDialogOpen(true) + } + + const handleConfirmBatchDelete = async () => { + const newProviders = providers.filter((_, index) => !selectedProviders.has(index)) + + const { shouldProceed } = await checkDeleteProviderImpact(newProviders, 'manual') + + if (shouldProceed) { + setProviders(newProviders) + setSelectedProviders(new Set()) + toast({ + title: '批量删除成功', + description: `已删除 ${selectedProviders.size} 个提供商`, + }) + } + + setBatchDeleteDialogOpen(false) + } + + const handleTestConnection = async (providerName: string) => { + setTestingProviders(prev => new Set(prev).add(providerName)) + + try { + const result = await testProviderConnection(providerName) + if (!result.success) { + toast({ + title: '测试失败', + description: result.error, + variant: 'destructive', + }) + return + } + const testResult = result.data + setTestResults(prev => new Map(prev).set(providerName, testResult)) + + if (testResult.network_ok) { + if (testResult.api_key_valid === true) { + toast({ + title: '连接正常', + description: `${providerName} 网络连接正常、API Key 有效 (${testResult.latency_ms}ms)`, + }) + } else if (testResult.api_key_valid === false) { + toast({ + title: '连接正常但 Key 无效', + description: `${providerName} 网络连接正常、但 API Key 无效或已过期`, + variant: 'destructive', + }) + } else { + toast({ + title: '网络连接正常', + description: `${providerName} 可以访问 (${testResult.latency_ms}ms)`, + }) + } + } else { + toast({ + title: '连接失败', + description: testResult.error || '无法连接到提供商', + variant: 'destructive', + }) + } + } catch (error) { + toast({ + title: '测试失败', + description: (error as Error).message, + variant: 'destructive', + }) + } finally { + setTestingProviders(prev => { + const newSet = new Set(prev) + newSet.delete(providerName) + return newSet + }) + } + } + + const handleTestAllConnections = async () => { + for (const provider of providers) { + await handleTestConnection(provider.name) + } + } + + if (loading) { + return ( +
    +
    +

    加载中...

    +
    +
    + ) + } + + return ( +
    + {/* 页面标题 */} +
    +
    +

    AI模型厂商配置

    +

    管理 AI 模型厂商的 API 配置

    +
    +
    + {selectedProviders.size > 0 && ( + + )} + + + + + + + + + + 确认重启麦麦? + +
    +

    + {hasUnsavedChanges + ? '当前有未保存的配置更改。点击确认将先保存配置,然后重启麦麦使新配置生效。重启过程中麦麦将暂时离线。' + : '即将重启麦麦主程序。重启过程中麦麦将暂时离线,配置将在重启后生效。' + } +

    +
    +
    +
    + + 取消 + + {hasUnsavedChanges ? '保存并重启' : '确认重启'} + + +
    +
    +
    +
    + + {/* 重启提示 */} + + + + 配置更新后需要重启麦麦才能生效。你可以点击右上角的"保存并重启"按钮一键完成保存和重启。 + + + + + + + + + + {/* 删除确认对话框 */} + + + + 确认删除 + + 确定要删除提供商 "{deletingIndex !== null ? providers[deletingIndex]?.name : ''}" 吗? + 此操作无法撤销。 + + + + 取消 + 删除 + + + + + {/* 批量删除确认对话框 */} + + + + 确认批量删除 + + 确定要删除选中的 {selectedProviders.size} 个提供商吗? + 此操作无法撤销。 + + + + 取消 + + 批量删除 + + + + + + {/* 删除提供商影响确认对话框 */} + setDeleteConfirmState(prev => ({ ...prev, isOpen: open }))}> + + + 确认删除提供商 + +
    +

    + 您即将删除以下提供商: + + {deleteConfirmState.providersToDelete.join(', ')} + +

    +

    + ⚠️ 此操作将同时删除 {deleteConfirmState.affectedModels.length} 个关联的模型: +

    + +
    + {deleteConfirmState.affectedModels.map((model: any, idx: number) => ( +
    + + {model.name} + + ({model.model_identifier}) + +
    + ))} +
    +
    +

    + 这些模型将从模型列表和所有任务分配中移除。此操作无法撤销。 +

    +
    +
    +
    + + 取消 + + 确认删除 + + +
    +
    + + {/* 重启遮罩层 */} + +
    + ) +} From e9a081d46b4bbe5f4c004f695e2920e5ecbc26a8 Mon Sep 17 00:00:00 2001 From: DrSmoothl <1787882683@qq.com> Date: Sun, 1 Mar 2026 20:02:04 +0800 Subject: [PATCH 19/26] refactor(react19): replace Context.Provider with Context value={} MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 升级 6 个文件的 Context 语法为 React 19 标准 - ThemeProviderContext, ChartContext, AnimationContext, TourContext, AssetStoreContext, RestartContext - 构建验证通过 - 功能完全等价 --- dashboard/src/components/animation-provider.tsx | 2 +- dashboard/src/components/asset-provider.tsx | 2 +- dashboard/src/components/theme-provider.tsx | 4 ++-- dashboard/src/components/tour/tour-provider.tsx | 4 ++-- dashboard/src/components/ui/chart.tsx | 4 ++-- dashboard/src/lib/restart-context.tsx | 4 ++-- 6 files changed, 10 insertions(+), 10 deletions(-) diff --git a/dashboard/src/components/animation-provider.tsx b/dashboard/src/components/animation-provider.tsx index 2a72ba1d..b9684a11 100644 --- a/dashboard/src/components/animation-provider.tsx +++ b/dashboard/src/components/animation-provider.tsx @@ -50,5 +50,5 @@ export function AnimationProvider({ setEnableWavesBackground, } - return {children} + return {children} } diff --git a/dashboard/src/components/asset-provider.tsx b/dashboard/src/components/asset-provider.tsx index 203301c0..028e27e1 100644 --- a/dashboard/src/components/asset-provider.tsx +++ b/dashboard/src/components/asset-provider.tsx @@ -52,7 +52,7 @@ export function AssetStoreProvider({ children }: AssetStoreProviderProps) { } }, []) - return {children} + return {children} } export function useAssetStore() { diff --git a/dashboard/src/components/theme-provider.tsx b/dashboard/src/components/theme-provider.tsx index 79d65f02..2455a34e 100644 --- a/dashboard/src/components/theme-provider.tsx +++ b/dashboard/src/components/theme-provider.tsx @@ -90,8 +90,8 @@ export function ThemeProvider({ ) return ( - + {children} - + ) } diff --git a/dashboard/src/components/tour/tour-provider.tsx b/dashboard/src/components/tour/tour-provider.tsx index 673b5148..0d6d7e0b 100644 --- a/dashboard/src/components/tour/tour-provider.tsx +++ b/dashboard/src/components/tour/tour-provider.tsx @@ -153,7 +153,7 @@ export function TourProvider({ children }: { children: ReactNode }) { }, []) return ( - {children} - + ) } diff --git a/dashboard/src/components/ui/chart.tsx b/dashboard/src/components/ui/chart.tsx index c8f5252f..a50431ac 100644 --- a/dashboard/src/components/ui/chart.tsx +++ b/dashboard/src/components/ui/chart.tsx @@ -45,7 +45,7 @@ const ChartContainer = React.forwardRef< const chartId = `chart-${id || uniqueId.replace(/:/g, "")}` return ( - +
    -
    + ) }) ChartContainer.displayName = "Chart" diff --git a/dashboard/src/lib/restart-context.tsx b/dashboard/src/lib/restart-context.tsx index 53ef0d31..49c56757 100644 --- a/dashboard/src/lib/restart-context.tsx +++ b/dashboard/src/lib/restart-context.tsx @@ -310,9 +310,9 @@ export function RestartProvider({ } return ( - + {children} - + ) } From 7866443c9ce6e4d30e78cdb95647561cc6003ae8 Mon Sep 17 00:00:00 2001 From: DrSmoothl <1787882683@qq.com> Date: Sun, 1 Mar 2026 20:11:32 +0800 Subject: [PATCH 20/26] refactor(routes): modernize auth, setup, person routes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - auth.tsx: 统一错误处理,使用 parseResponse - setup/api.ts: 重构 12 个函数使用 ApiResponse 模式 - person.tsx: 优化 imports,使用 import type 语法 - 构建验证通过 - 功能完全等价 --- dashboard/src/routes/auth.tsx | 82 +++++++++++++----- dashboard/src/routes/person.tsx | 73 ++++++++++------ dashboard/src/routes/setup/api.ts | 134 +++++++++++++----------------- 3 files changed, 164 insertions(+), 125 deletions(-) diff --git a/dashboard/src/routes/auth.tsx b/dashboard/src/routes/auth.tsx index 49d216ff..8f9ff520 100644 --- a/dashboard/src/routes/auth.tsx +++ b/dashboard/src/routes/auth.tsx @@ -1,18 +1,18 @@ -import { useState, useEffect } from 'react' +import { useEffect, useState } from 'react' import { useNavigate } from '@tanstack/react-router' -import { Key, Lock, AlertCircle, Moon, Sun, HelpCircle, FileText, Terminal, Zap } from 'lucide-react' -import { Button } from '@/components/ui/button' -import { Input } from '@/components/ui/input' -import { Label } from '@/components/ui/label' -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' + import { - Dialog, - DialogContent, - DialogDescription, - DialogHeader, - DialogTitle, - DialogTrigger, -} from '@/components/ui/dialog' + AlertCircle, + FileText, + HelpCircle, + Key, + Lock, + Moon, + Sun, + Terminal, + Zap, +} from 'lucide-react' + import { AlertDialog, AlertDialogAction, @@ -24,13 +24,35 @@ import { AlertDialogTitle, AlertDialogTrigger, } from '@/components/ui/alert-dialog' +import { Button } from '@/components/ui/button' +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from '@/components/ui/card' +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, + DialogTrigger, +} from '@/components/ui/dialog' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' import { WavesBackground } from '@/components/waves-background' -import { useAnimation } from '@/hooks/use-animation' import { useTheme } from '@/components/use-theme' + +import { useAnimation } from '@/hooks/use-animation' + +import { parseResponse } from '@/lib/api-helpers' import { checkAuthStatus } from '@/lib/fetch-with-auth' import { cn } from '@/lib/utils' import { APP_FULL_NAME } from '@/lib/version' + export function AuthPage() { const [token, setToken] = useState('') const [isValidating, setIsValidating] = useState(false) @@ -83,7 +105,7 @@ export function AuthPage() { } setIsValidating(true) - + console.log('开始验证 token...') try { @@ -98,22 +120,34 @@ export function AuthPage() { }) console.log('Token 验证响应状态:', response.status) - - const data = await response.json() + + const result = await parseResponse<{ + valid: boolean + is_first_setup?: boolean + message?: string + }>(response) + + if (!result.success) { + console.error('Token 验证失败:', result.error) + setError(result.error) + return + } + + const data = result.data console.log('Token 验证响应数据:', data) - if (response.ok && data.valid) { + if (data.valid) { console.log('Token 验证成功,准备跳转...') console.log('is_first_setup:', data.is_first_setup) - + // Token 验证成功,Cookie 已由后端设置 // 等待一小段时间确保 Cookie 已设置 - await new Promise(resolve => setTimeout(resolve, 100)) - + await new Promise((resolve) => setTimeout(resolve, 100)) + // 再次检查认证状态 const authCheck = await checkAuthStatus() console.log('跳转前认证状态检查:', authCheck) - + // 直接使用验证响应中的 is_first_setup 字段,避免额外请求 if (data.is_first_setup) { console.log('跳转到首次配置页面') @@ -130,7 +164,9 @@ export function AuthPage() { } } catch (err) { console.error('Token 验证错误:', err) - setError('连接服务器失败,请检查网络连接') + setError( + err instanceof Error ? err.message : '连接服务器失败,请检查网络连接' + ) } finally { setIsValidating(false) } diff --git a/dashboard/src/routes/person.tsx b/dashboard/src/routes/person.tsx index 9f6e17f9..7c51db05 100644 --- a/dashboard/src/routes/person.tsx +++ b/dashboard/src/routes/person.tsx @@ -1,28 +1,20 @@ -import { Users, Search, Edit, Trash2, Eye, User, MessageSquare, Hash, Clock, ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight } from 'lucide-react' import { useState, useEffect, useMemo } from 'react' -import { cn } from '@/lib/utils' -import { Button } from '@/components/ui/button' -import { Input } from '@/components/ui/input' -import { Label } from '@/components/ui/label' -import { ScrollArea } from '@/components/ui/scroll-area' -import { useToast } from '@/hooks/use-toast' -import { Checkbox } from '@/components/ui/checkbox' + import { - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, -} from '@/components/ui/table' -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, -} from '@/components/ui/dialog' + ChevronLeft, + ChevronRight, + ChevronsLeft, + ChevronsRight, + Edit, + Eye, + Hash, + Search, + Trash2, + User, + Users, +} from 'lucide-react' +import { Clock, MessageSquare } from 'lucide-react' + import { AlertDialog, AlertDialogAction, @@ -33,6 +25,19 @@ import { AlertDialogHeader, AlertDialogTitle, } from '@/components/ui/alert-dialog' +import { Button } from '@/components/ui/button' +import { Checkbox } from '@/components/ui/checkbox' +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import { ScrollArea } from '@/components/ui/scroll-area' import { Select, SelectContent, @@ -41,9 +46,29 @@ import { SelectValue, } from '@/components/ui/select' import { Switch } from '@/components/ui/switch' +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@/components/ui/table' import { Textarea } from '@/components/ui/textarea' + +import { useToast } from '@/hooks/use-toast' + +import { + batchDeletePersons, + deletePerson, + getPersonDetail, + getPersonList, + getPersonStats, + updatePerson, +} from '@/lib/person-api' +import { cn } from '@/lib/utils' + import type { PersonInfo, PersonUpdateRequest } from '@/types/person' -import { getPersonList, getPersonDetail, updatePerson, deletePerson, getPersonStats, batchDeletePersons } from '@/lib/person-api' export function PersonManagementPage() { const [persons, setPersons] = useState([]) diff --git a/dashboard/src/routes/setup/api.ts b/dashboard/src/routes/setup/api.ts index a78a1b5a..51cd4ef8 100644 --- a/dashboard/src/routes/setup/api.ts +++ b/dashboard/src/routes/setup/api.ts @@ -1,11 +1,13 @@ // 设置向导API调用函数 +import { parseResponse, throwIfError } from '@/lib/api-helpers' import { fetchWithAuth, getAuthHeaders } from '@/lib/fetch-with-auth' + import type { BotBasicConfig, - PersonalityConfig, EmojiConfig, OtherBasicConfig, + PersonalityConfig, SiliconFlowConfig, } from './types' @@ -18,12 +20,11 @@ export async function loadBotBasicConfig(): Promise { headers: getAuthHeaders(), }) - if (!response.ok) { - throw new Error('读取Bot配置失败') - } - - const data = await response.json() - const botConfig = data.config.bot || {} + const result = await parseResponse<{ config: { bot?: BotBasicConfig } }>( + response + ) + const data = throwIfError(result) + const botConfig = (data.config.bot || {}) as Partial return { qq_account: botConfig.qq_account || 0, @@ -39,12 +40,11 @@ export async function loadPersonalityConfig(): Promise { headers: getAuthHeaders(), }) - if (!response.ok) { - throw new Error('读取人格配置失败') - } - - const data = await response.json() - const personalityConfig = data.config.personality || {} + const result = await parseResponse<{ + config: { personality?: PersonalityConfig } + }>(response) + const data = throwIfError(result) + const personalityConfig = (data.config.personality || {}) as Partial return { personality: personalityConfig.personality || '', @@ -62,12 +62,11 @@ export async function loadEmojiConfig(): Promise { headers: getAuthHeaders(), }) - if (!response.ok) { - throw new Error('读取表情包配置失败') - } - - const data = await response.json() - const emojiConfig = data.config.emoji || {} + const result = await parseResponse<{ config: { emoji?: EmojiConfig } }>( + response + ) + const data = throwIfError(result) + const emojiConfig = (data.config.emoji || {}) as Partial return { emoji_chance: emojiConfig.emoji_chance ?? 0.4, @@ -87,11 +86,13 @@ export async function loadOtherBasicConfig(): Promise { headers: getAuthHeaders(), }) - if (!response.ok) { - throw new Error('读取其他配置失败') - } - - const data = await response.json() + const result = await parseResponse<{ + config: { + tool?: { enable_tool?: boolean } + expression?: { all_global_jargon?: boolean } + } + }>(response) + const data = throwIfError(result) const config = data.config const toolConfig = config.tool || {} @@ -110,18 +111,17 @@ export async function loadSiliconFlowConfig(): Promise { headers: getAuthHeaders(), }) - if (!response.ok) { - throw new Error('读取模型配置失败') - } - - const data = await response.json() + const result = await parseResponse<{ + config: { + api_providers?: Array<{ name: string; api_key?: string }> + } + }>(response) + const data = throwIfError(result) const modelConfig = data.config // 获取SiliconFlow提供商的API Key const apiProviders = modelConfig.api_providers || [] - const siliconFlowProvider = apiProviders.find( - (p: Record) => p.name === 'SiliconFlow' - ) + const siliconFlowProvider = apiProviders.find((p) => p.name === 'SiliconFlow') return { api_key: siliconFlowProvider?.api_key || '', @@ -138,28 +138,21 @@ export async function saveBotBasicConfig(config: BotBasicConfig) { body: JSON.stringify(config), }) - if (!response.ok) { - const error = await response.json() - throw new Error(error.detail || '保存Bot基础配置失败') - } - - return await response.json() + const result = await parseResponse(response) + return throwIfError(result) } // 保存人格配置 export async function savePersonalityConfig(config: PersonalityConfig) { const response = await fetchWithAuth('/api/webui/config/bot/section/personality', { - method: 'POST', - headers: getAuthHeaders(), - body: JSON.stringify(config), - }) + method: 'POST', + headers: getAuthHeaders(), + body: JSON.stringify(config), + } + ) - if (!response.ok) { - const error = await response.json() - throw new Error(error.detail || '保存人格配置失败') - } - - return await response.json() + const result = await parseResponse(response) + return throwIfError(result) } // 保存表情包配置 @@ -170,12 +163,8 @@ export async function saveEmojiConfig(config: EmojiConfig) { body: JSON.stringify(config), }) - if (!response.ok) { - const error = await response.json() - throw new Error(error.detail || '保存表情包配置失败') - } - - return await response.json() + const result = await parseResponse(response) + return throwIfError(result) } // 保存其他基础配置(工具、情绪、黑话) @@ -205,10 +194,8 @@ export async function saveOtherBasicConfig(config: OtherBasicConfig) { // 检查所有请求是否成功 for (const response of results) { - if (!response.ok) { - const error = await response.json() - throw new Error(error.detail || '保存其他配置失败') - } + const result = await parseResponse(response) + throwIfError(result) } return { success: true } @@ -222,18 +209,17 @@ export async function saveSiliconFlowConfig(config: SiliconFlowConfig) { headers: getAuthHeaders(), }) - if (!response.ok) { - throw new Error('读取模型配置失败') - } - - const currentModelConfig = await response.json() + const result = await parseResponse<{ + config: { + api_providers?: Array> + } + }>(response) + const currentModelConfig = throwIfError(result) const modelConfig = currentModelConfig.config // 2. 更新SiliconFlow提供商的API Key const apiProviders = modelConfig.api_providers || [] - const siliconFlowIndex = apiProviders.findIndex( - (p: Record) => p.name === 'SiliconFlow' - ) + const siliconFlowIndex = apiProviders.findIndex((p) => p.name === 'SiliconFlow') if (siliconFlowIndex >= 0) { // 更新现有提供商的API Key @@ -266,12 +252,8 @@ export async function saveSiliconFlowConfig(config: SiliconFlowConfig) { body: JSON.stringify(updatedConfig), }) - if (!saveResponse.ok) { - const error = await saveResponse.json() - throw new Error(error.detail || '保存模型配置失败') - } - - return await saveResponse.json() + const saveResult = await parseResponse(saveResponse) + return throwIfError(saveResult) } // 标记设置完成 @@ -280,10 +262,6 @@ export async function completeSetup() { method: 'POST', }) - if (!response.ok) { - const error = await response.json() - throw new Error(error.message || '标记配置完成失败') - } - - return await response.json() + const result = await parseResponse(response) + return throwIfError(result) } From bacb5b19cdbdbc5adeba5035229123dd4db1d665 Mon Sep 17 00:00:00 2001 From: DrSmoothl <1787882683@qq.com> Date: Sun, 1 Mar 2026 20:23:53 +0800 Subject: [PATCH 21/26] refactor(routes): split expression.tsx into modular expression/ directory - Split 1212-line monolithic file into 6 modular files - Extracted types, dialogs, list view into separate modules - Main index.tsx (468 lines) handles state management and API integration - All CRUD operations, stats, review, search, pagination preserved - Build passes with zero TypeScript errors (3.77s) --- dashboard/src/routes/resource/expression.tsx | 1211 ----------------- .../resource/expression/ExpressionDialogs.tsx | 561 ++++++++ .../resource/expression/ExpressionList.tsx | 361 +++++ .../src/routes/resource/expression/index.ts | 1 + .../src/routes/resource/expression/index.tsx | 467 +++++++ .../src/routes/resource/expression/types.ts | 47 + 6 files changed, 1437 insertions(+), 1211 deletions(-) delete mode 100644 dashboard/src/routes/resource/expression.tsx create mode 100644 dashboard/src/routes/resource/expression/ExpressionDialogs.tsx create mode 100644 dashboard/src/routes/resource/expression/ExpressionList.tsx create mode 100644 dashboard/src/routes/resource/expression/index.ts create mode 100644 dashboard/src/routes/resource/expression/index.tsx create mode 100644 dashboard/src/routes/resource/expression/types.ts diff --git a/dashboard/src/routes/resource/expression.tsx b/dashboard/src/routes/resource/expression.tsx deleted file mode 100644 index dbb0795c..00000000 --- a/dashboard/src/routes/resource/expression.tsx +++ /dev/null @@ -1,1211 +0,0 @@ -import { MessageSquare, Search, Edit, Trash2, Eye, Plus, Clock, Hash, ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight, Info, CheckCircle2, XCircle, Circle, ClipboardCheck } from 'lucide-react' -import { useState, useEffect } from 'react' -import { cn } from '@/lib/utils' -import { Button } from '@/components/ui/button' -import { Input } from '@/components/ui/input' -import { Label } from '@/components/ui/label' -import { ScrollArea } from '@/components/ui/scroll-area' -import { useToast } from '@/hooks/use-toast' -import { - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, -} from '@/components/ui/table' -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, -} from '@/components/ui/dialog' -import { - AlertDialog, - AlertDialogAction, - AlertDialogCancel, - AlertDialogContent, - AlertDialogDescription, - AlertDialogFooter, - AlertDialogHeader, - AlertDialogTitle, -} from '@/components/ui/alert-dialog' -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from '@/components/ui/select' -import { Checkbox } from '@/components/ui/checkbox' -import { Switch } from '@/components/ui/switch' -import { Alert, AlertDescription } from '@/components/ui/alert' -import type { Expression, ExpressionCreateRequest, ExpressionUpdateRequest, ChatInfo } from '@/types/expression' -import { getExpressionList, getExpressionDetail, createExpression, updateExpression, deleteExpression, batchDeleteExpressions, getExpressionStats, getChatList, getReviewStats } from '@/lib/expression-api' -import { ExpressionReviewer } from '@/components/expression-reviewer' - -export function ExpressionManagementPage() { - const [expressions, setExpressions] = useState([]) - const [loading, setLoading] = useState(true) - const [total, setTotal] = useState(0) - const [page, setPage] = useState(1) - const [pageSize, setPageSize] = useState(20) - const [search, setSearch] = useState('') - const [selectedExpression, setSelectedExpression] = useState(null) - const [isDetailDialogOpen, setIsDetailDialogOpen] = useState(false) - const [isEditDialogOpen, setIsEditDialogOpen] = useState(false) - const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false) - const [deleteConfirmExpression, setDeleteConfirmExpression] = useState(null) - const [selectedIds, setSelectedIds] = useState>(new Set()) - const [isBatchDeleteDialogOpen, setIsBatchDeleteDialogOpen] = useState(false) - const [jumpToPage, setJumpToPage] = useState('') - const [stats, setStats] = useState({ total: 0, recent_7days: 0, chat_count: 0, top_chats: {} as Record }) - const [chatList, setChatList] = useState([]) - const [chatNameMap, setChatNameMap] = useState>(new Map()) - const [isReviewerOpen, setIsReviewerOpen] = useState(false) - const [uncheckedCount, setUncheckedCount] = useState(0) - const { toast } = useToast() - - // 加载表达方式列表 - const loadExpressions = async () => { - try { - setLoading(true) - const result = await getExpressionList({ - page, - page_size: pageSize, - search: search || undefined, - }) - if (result.success) { - setExpressions(result.data.data) - setTotal(result.data.total) - } else { - toast({ - title: '加载失败', - description: result.error, - variant: 'destructive', - }) - } - } catch (error) { - toast({ - title: '加载失败', - description: error instanceof Error ? error.message : '无法加载表达方式', - variant: 'destructive', - }) - } finally { - setLoading(false) - } - } - - // 加载统计数据 - const loadStats = async () => { - try { - const result = await getExpressionStats() - if (result.success) { - setStats(result.data) - } else { - console.error('加载统计数据失败:', result.error) - } - } catch (error) { - console.error('加载统计数据失败:', error) - } - } - - // 加载审核统计 - const loadReviewStats = async () => { - try { - const result = await getReviewStats() - if (result.success) { - setUncheckedCount(result.data.unchecked) - } - } catch (error) { - console.error('加载审核统计失败:', error) - } - } - - // 加载聚天列表 - const loadChatList = async () => { - try { - const result = await getChatList() - if (result.success) { - setChatList(result.data) - // 构建聚天ID到名称的映射 - const nameMap = new Map() - result.data.forEach((chat: ChatInfo) => { - nameMap.set(chat.chat_id, chat.chat_name) - }) - setChatNameMap(nameMap) - } - } catch (error) { - console.error('加载聚天列表失败:', error) - } - } - - // 获取聊天名称(支持Unicode字符完整显示) - const getChatName = (chatId: string): string => { - return chatNameMap.get(chatId) || chatId - } - - // 初始加载 - useEffect(() => { - loadExpressions() - loadReviewStats() - loadStats() - loadChatList() - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [page, pageSize, search]) - - // 查看详情 - const handleViewDetail = async (expression: Expression) => { - try { - const result = await getExpressionDetail(expression.id) - if (result.success) { - setSelectedExpression(result.data) - setIsDetailDialogOpen(true) - } else { - toast({ - title: '加载详情失败', - description: result.error, - variant: 'destructive', - }) - } - } catch (error) { - toast({ - title: '加载详情失败', - description: error instanceof Error ? error.message : '无法加载表达方式详情', - variant: 'destructive', - }) - } - } - - // 编辑表达方式 - const handleEdit = (expression: Expression) => { - setSelectedExpression(expression) - setIsEditDialogOpen(true) - } - - // 删除表达方式 - const handleDelete = async (expression: Expression) => { - try { - const result = await deleteExpression(expression.id) - if (result.success) { - toast({ - title: '删除成功', - description: `已删除表达方式: ${expression.situation}`, - }) - setDeleteConfirmExpression(null) - loadExpressions() - loadStats() - } else { - toast({ - title: '删除失败', - description: result.error, - variant: 'destructive', - }) - } - } catch (error) { - toast({ - title: '删除失败', - description: error instanceof Error ? error.message : '无法删除表达方式', - variant: 'destructive', - }) - } - } - - // 切换单个选择 - const toggleSelect = (id: number) => { - const newSelected = new Set(selectedIds) - if (newSelected.has(id)) { - newSelected.delete(id) - } else { - newSelected.add(id) - } - setSelectedIds(newSelected) - } - - // 全选/取消全选 - const toggleSelectAll = () => { - if (selectedIds.size === expressions.length && expressions.length > 0) { - setSelectedIds(new Set()) - } else { - setSelectedIds(new Set(expressions.map(e => e.id))) - } - } - - // 批量删除 - const handleBatchDelete = async () => { - try { - const result = await batchDeleteExpressions(Array.from(selectedIds)) - if (result.success) { - toast({ - title: '批量删除成功', - description: `已删除 ${selectedIds.size} 个表达方式`, - }) - setSelectedIds(new Set()) - setIsBatchDeleteDialogOpen(false) - loadExpressions() - loadStats() - } else { - toast({ - title: '批量删除失败', - description: result.error, - variant: 'destructive', - }) - } - } catch (error) { - toast({ - title: '批量删除失败', - description: error instanceof Error ? error.message : '无法批量删除表达方式', - variant: 'destructive', - }) - } - } - - // 页面跳转 - const handleJumpToPage = () => { - const targetPage = parseInt(jumpToPage) - const totalPages = Math.ceil(total / pageSize) - if (targetPage >= 1 && targetPage <= totalPages) { - setPage(targetPage) - setJumpToPage('') - } else { - toast({ - title: '无效的页码', - description: `请输入1-${totalPages}之间的页码`, - variant: 'destructive', - }) - } - } - - return ( -
    - {/* 页面标题 */} -
    -
    -
    -

    - - 表达方式管理 -

    -

    - 管理麦麦的表达方式和话术模板 -

    -
    -
    - - -
    -
    -
    - - -
    - - {/* 统计卡片 */} -
    -
    -
    总数量
    -
    {stats.total}
    -
    -
    -
    近7天新增
    -
    {stats.recent_7days}
    -
    -
    -
    关联聊天数
    -
    {stats.chat_count}
    -
    -
    - - {/* 搜索和批量操作 */} -
    - -
    -
    - - setSearch(e.target.value)} - className="pl-9" - /> -
    -
    - - {/* 批量操作工具栏 */} -
    -
    - {selectedIds.size > 0 && ( - 已选择 {selectedIds.size} 个表达方式 - )} -
    -
    - - - {selectedIds.size > 0 && ( - <> - - - - )} -
    -
    -
    - - {/* 表达方式列表 */} -
    - {/* 桌面端表格视图 */} -
    - - - - - 0} - onCheckedChange={toggleSelectAll} - /> - - 情境 - 风格 - 聊天 - 操作 - - - - {loading ? ( - - - 加载中... - - - ) : expressions.length === 0 ? ( - - - 暂无数据 - - - ) : ( - expressions.map((expression) => ( - - - toggleSelect(expression.id)} - /> - - - {expression.situation} - - {expression.style} - - - {getChatName(expression.chat_id)} - - - -
    - - - -
    -
    -
    - )) - )} -
    -
    -
    - - {/* 移动端卡片视图 */} -
    - {loading ? ( -
    - 加载中... -
    - ) : expressions.length === 0 ? ( -
    - 暂无数据 -
    - ) : ( - expressions.map((expression) => ( -
    - {/* 复选框和情境 */} -
    - toggleSelect(expression.id)} - className="mt-1" - /> -
    -
    -
    情境
    -

    - {expression.situation} -

    -
    -
    -
    风格
    -

    - {expression.style} -

    -
    -
    -
    - - {/* 聊天名称 */} -
    -
    聊天
    -

    - {getChatName(expression.chat_id)} -

    -
    - - {/* 操作按钮 */} -
    - - - -
    -
    - )) - )} -
    - - {/* 分页 - 增强版 */} - {total > 0 && ( -
    -
    - 共 {total} 条记录,第 {page} / {Math.ceil(total / pageSize)} 页 -
    -
    - {/* 首页 */} - - - {/* 上一页 */} - - - {/* 页码跳转 */} -
    - setJumpToPage(e.target.value)} - onKeyDown={(e) => e.key === 'Enter' && handleJumpToPage()} - placeholder={page.toString()} - className="w-16 h-8 text-center" - min={1} - max={Math.ceil(total / pageSize)} - /> - -
    - - {/* 下一页 */} - - - {/* 末页 */} - -
    -
    - )} -
    - -
    -
    - - {/* 详情对话框 */} - - - {/* 创建对话框 */} - { - loadExpressions() - loadStats() - setIsCreateDialogOpen(false) - }} - /> - - {/* 编辑对话框 */} - { - loadExpressions() - loadStats() - setIsEditDialogOpen(false) - }} - /> - - {/* 删除确认对话框 */} - setDeleteConfirmExpression(null)} - > - - - 确认删除 - - 确定要删除表达方式 "{deleteConfirmExpression?.situation}" 吗? - 此操作不可撤销。 - - - - 取消 - deleteConfirmExpression && handleDelete(deleteConfirmExpression)} - className="bg-destructive text-destructive-foreground hover:bg-destructive/90" - > - 删除 - - - - - - {/* 批量删除确认对话框 */} - - - {/* 表达方式审核器 */} - { - setIsReviewerOpen(open) - if (!open) { - // 关闭审核器时刷新列表和统计 - loadExpressions() - loadStats() - loadReviewStats() - } - }} - /> -
    - ) -} - -// 表达方式详情对话框 -function ExpressionDetailDialog({ - expression, - open, - onOpenChange, - chatNameMap, -}: { - expression: Expression | null - open: boolean - onOpenChange: (open: boolean) => void - chatNameMap: Map -}) { - if (!expression) return null - - const formatTime = (timestamp: number | null) => { - if (!timestamp) return '-' - return new Date(timestamp * 1000).toLocaleString('zh-CN') - } - - const getChatName = (chatId: string): string => { - return chatNameMap.get(chatId) || chatId - } - - return ( - - - - 表达方式详情 - - 查看表达方式的完整信息 - - - -
    -
    - - - - -
    - -
    - -
    - - {/* 状态标记 */} -
    - -
    -
    -
    - {expression.checked ? ( - - ) : ( - - )} -
    -
    -

    已检查

    -

    - {expression.checked ? "已通过审核" : "未审核"} -

    -
    -
    -
    -
    - {expression.rejected ? ( - - ) : ( - - )} -
    -
    -

    已拒绝

    -

    - {expression.rejected ? "不会被使用" : "正常"} -

    -
    -
    -
    -
    -
    - - - - -
    -
    - ) -} - -// 信息项组件 -function InfoItem({ - icon: Icon, - label, - value, - mono = false, -}: { - icon?: typeof Hash - label: string - value: string | null | undefined - mono?: boolean -}) { - return ( -
    - -
    - {value || '-'} -
    -
    - ) -} - -// 表达方式创建对话框 -function ExpressionCreateDialog({ - open, - onOpenChange, - chatList, - onSuccess, -}: { - open: boolean - onOpenChange: (open: boolean) => void - chatList: ChatInfo[] - onSuccess: () => void -}) { - const [formData, setFormData] = useState({ - situation: '', - style: '', - chat_id: '', - }) - const [saving, setSaving] = useState(false) - const { toast } = useToast() - - const handleCreate = async () => { - if (!formData.situation || !formData.style || !formData.chat_id) { - toast({ - title: '验证失败', - description: '请填写必填字段:情境、风格和聚天', - variant: 'destructive', - }) - return - } - - try { - setSaving(true) - const result = await createExpression(formData) - if (result.success) { - toast({ - title: '创建成功', - description: '表达方式已创建', - }) - // 重置表单 - setFormData({ - situation: '', - style: '', - chat_id: '', - }) - onSuccess() - } else { - toast({ - title: '创建失败', - description: result.error, - variant: 'destructive', - }) - } - } catch (error) { - toast({ - title: '创建失败', - description: error instanceof Error ? error.message : '无法创建表达方式', - variant: 'destructive', - }) - } finally { - setSaving(false) - } - } - - return ( - - - - 新增表达方式 - - 创建新的表达方式记录 - - - -
    -
    -
    - - setFormData({ ...formData, situation: e.target.value })} - placeholder="描述使用场景" - /> -
    -
    - - setFormData({ ...formData, style: e.target.value })} - placeholder="描述表达风格" - /> -
    -
    - -
    - - -
    -
    - - - - - -
    -
    - ) -} - -// 表达方式编辑对话框 -function ExpressionEditDialog({ - expression, - open, - onOpenChange, - chatList, - onSuccess, -}: { - expression: Expression | null - open: boolean - onOpenChange: (open: boolean) => void - chatList: ChatInfo[] - onSuccess: () => void -}) { - const [formData, setFormData] = useState({}) - const [saving, setSaving] = useState(false) - const { toast } = useToast() - - useEffect(() => { - if (expression) { - setFormData({ - situation: expression.situation, - style: expression.style, - chat_id: expression.chat_id, - checked: expression.checked, - rejected: expression.rejected, - }) - } - }, [expression]) - - const handleSave = async () => { - if (!expression) return - - try { - setSaving(true) - const result = await updateExpression(expression.id, formData) - if (result.success) { - toast({ - title: '保存成功', - description: '表达方式已更新', - }) - onSuccess() - } else { - toast({ - title: '保存失败', - description: result.error, - variant: 'destructive', - }) - } - } catch (error) { - toast({ - title: '保存失败', - description: error instanceof Error ? error.message : '无法更新表达方式', - variant: 'destructive', - }) - } finally { - setSaving(false) - } - } - - if (!expression) return null - - return ( - - - - 编辑表达方式 - - 修改表达方式的信息 - - - -
    -
    -
    - - setFormData({ ...formData, situation: e.target.value })} - placeholder="描述使用场景" - /> -
    -
    - - setFormData({ ...formData, style: e.target.value })} - placeholder="描述表达风格" - /> -
    -
    - -
    - - -
    - - {/* 状态标记 */} - - - -
    -

    状态标记说明:

    -

    • 已检查:表示该表达方式已通过审核(可由AI自动检查或人工审核)

    -

    • 已拒绝:表示该表达方式被标记为不合适,将永远不会被使用

    -

    - 根据配置中"仅使用已审核通过的表达方式"设置:
    - • 开启时:只有通过审核(已检查)的项目会被使用
    - • 关闭时:未审核的项目也会被使用 -

    -
    -
    -
    - -
    -
    -
    - -

    - 已通过审核 -

    -
    - setFormData({ ...formData, checked })} - /> -
    - -
    -
    - -

    - 不会被使用 -

    -
    - setFormData({ ...formData, rejected })} - /> -
    -
    -
    - - - - - -
    -
    - ) -} - -// 批量删除确认对话框 -function BatchDeleteConfirmDialog({ - open, - onOpenChange, - onConfirm, - count, -}: { - open: boolean - onOpenChange: (open: boolean) => void - onConfirm: () => void - count: number -}) { - return ( - - - - 确认批量删除 - - 您即将删除 {count} 个表达方式,此操作无法撤销。确定要继续吗? - - - - 取消 - - 确认删除 - - - - - ) -} diff --git a/dashboard/src/routes/resource/expression/ExpressionDialogs.tsx b/dashboard/src/routes/resource/expression/ExpressionDialogs.tsx new file mode 100644 index 00000000..15f3c955 --- /dev/null +++ b/dashboard/src/routes/resource/expression/ExpressionDialogs.tsx @@ -0,0 +1,561 @@ +import { CheckCircle2, Circle, Clock, Hash, Info, XCircle } from 'lucide-react' +import { useEffect, useState } from 'react' + +import { Alert, AlertDescription } from '@/components/ui/alert' +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from '@/components/ui/alert-dialog' +import { Button } from '@/components/ui/button' +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select' +import { Switch } from '@/components/ui/switch' +import { useToast } from '@/hooks/use-toast' +import { cn } from '@/lib/utils' + +import { createExpression, updateExpression } from '@/lib/expression-api' + +import type { Expression, ExpressionCreateRequest, ExpressionUpdateRequest, ChatInfo } from '@/types/expression' + +/** + * 表达方式详情对话框 + */ +export function ExpressionDetailDialog({ + expression, + open, + onOpenChange, + chatNameMap, +}: { + expression: Expression | null + open: boolean + onOpenChange: (open: boolean) => void + chatNameMap: Map +}) { + if (!expression) return null + + const formatTime = (timestamp: number | null) => { + if (!timestamp) return '-' + return new Date(timestamp * 1000).toLocaleString('zh-CN') + } + + const getChatName = (chatId: string): string => { + return chatNameMap.get(chatId) || chatId + } + + return ( + + + + 表达方式详情 + + 查看表达方式的完整信息 + + + +
    +
    + + + + +
    + +
    + +
    + + {/* 状态标记 */} +
    + +
    +
    +
    + {expression.checked ? ( + + ) : ( + + )} +
    +
    +

    已检查

    +

    + {expression.checked ? "已通过审核" : "未审核"} +

    +
    +
    +
    +
    + {expression.rejected ? ( + + ) : ( + + )} +
    +
    +

    已拒绝

    +

    + {expression.rejected ? "不会被使用" : "正常"} +

    +
    +
    +
    +
    +
    + + + + +
    +
    + ) +} + +/** + * 信息项组件 + */ +function InfoItem({ + icon: Icon, + label, + value, + mono = false, +}: { + icon?: typeof Hash + label: string + value: string | null | undefined + mono?: boolean +}) { + return ( +
    + +
    + {value || '-'} +
    +
    + ) +} + +/** + * 表达方式创建对话框 + */ +export function ExpressionCreateDialog({ + open, + onOpenChange, + chatList, + onSuccess, +}: { + open: boolean + onOpenChange: (open: boolean) => void + chatList: ChatInfo[] + onSuccess: () => void +}) { + const [formData, setFormData] = useState({ + situation: '', + style: '', + chat_id: '', + }) + const [saving, setSaving] = useState(false) + const { toast } = useToast() + + const handleCreate = async () => { + if (!formData.situation || !formData.style || !formData.chat_id) { + toast({ + title: '验证失败', + description: '请填写必填字段:情境、风格和聚天', + variant: 'destructive', + }) + return + } + + try { + setSaving(true) + const result = await createExpression(formData) + if (result.success) { + toast({ + title: '创建成功', + description: '表达方式已创建', + }) + setFormData({ + situation: '', + style: '', + chat_id: '', + }) + onSuccess() + } else { + toast({ + title: '创建失败', + description: result.error, + variant: 'destructive', + }) + } + } catch (error) { + toast({ + title: '创建失败', + description: error instanceof Error ? error.message : '无法创建表达方式', + variant: 'destructive', + }) + } finally { + setSaving(false) + } + } + + return ( + + + + 新增表达方式 + + 创建新的表达方式记录 + + + +
    +
    +
    + + setFormData({ ...formData, situation: e.target.value })} + placeholder="描述使用场景" + /> +
    +
    + + setFormData({ ...formData, style: e.target.value })} + placeholder="描述表达风格" + /> +
    +
    + +
    + + +
    +
    + + + + + +
    +
    + ) +} + +/** + * 表达方式编辑对话框 + */ +export function ExpressionEditDialog({ + expression, + open, + onOpenChange, + chatList, + onSuccess, +}: { + expression: Expression | null + open: boolean + onOpenChange: (open: boolean) => void + chatList: ChatInfo[] + onSuccess: () => void +}) { + const [formData, setFormData] = useState({}) + const [saving, setSaving] = useState(false) + const { toast } = useToast() + + useEffect(() => { + if (expression) { + setFormData({ + situation: expression.situation, + style: expression.style, + chat_id: expression.chat_id, + checked: expression.checked, + rejected: expression.rejected, + }) + } + }, [expression]) + + const handleSave = async () => { + if (!expression) return + + try { + setSaving(true) + const result = await updateExpression(expression.id, formData) + if (result.success) { + toast({ + title: '保存成功', + description: '表达方式已更新', + }) + onSuccess() + } else { + toast({ + title: '保存失败', + description: result.error, + variant: 'destructive', + }) + } + } catch (error) { + toast({ + title: '保存失败', + description: error instanceof Error ? error.message : '无法更新表达方式', + variant: 'destructive', + }) + } finally { + setSaving(false) + } + } + + if (!expression) return null + + return ( + + + + 编辑表达方式 + + 修改表达方式的信息 + + + +
    +
    +
    + + setFormData({ ...formData, situation: e.target.value })} + placeholder="描述使用场景" + /> +
    +
    + + setFormData({ ...formData, style: e.target.value })} + placeholder="描述表达风格" + /> +
    +
    + +
    + + +
    + + {/* 状态标记 */} + + + +
    +

    状态标记说明:

    +

    • 已检查:表示该表达方式已通过审核(可由AI自动检查或人工审核)

    +

    • 已拒绝:表示该表达方式被标记为不合适,将永远不会被使用

    +

    + 根据配置中"仅使用已审核通过的表达方式"设置:
    + • 开启时:只有通过审核(已检查)的项目会被使用
    + • 关闭时:未审核的项目也会被使用 +

    +
    +
    +
    + +
    +
    +
    + +

    + 已通过审核 +

    +
    + setFormData({ ...formData, checked })} + /> +
    + +
    +
    + +

    + 不会被使用 +

    +
    + setFormData({ ...formData, rejected })} + /> +
    +
    +
    + + + + + +
    +
    + ) +} + +/** + * 批量删除确认对话框 + */ +export function BatchDeleteConfirmDialog({ + open, + onOpenChange, + onConfirm, + count, +}: { + open: boolean + onOpenChange: (open: boolean) => void + onConfirm: () => void + count: number +}) { + return ( + + + + 确认批量删除 + + 您即将删除 {count} 个表达方式,此操作无法撤销。确定要继续吗? + + + + 取消 + + 确认删除 + + + + + ) +} + +/** + * 单个删除确认对话框 + */ +export function DeleteConfirmDialog({ + expression, + open, + onOpenChange, + onConfirm, +}: { + expression: Expression | null + open: boolean + onOpenChange: (open: boolean) => void + onConfirm: () => Promise +}) { + return ( + + + + 确认删除 + + 确定要删除表达方式 "{expression?.situation}" 吗? + 此操作不可撤销。 + + + + 取消 + + 删除 + + + + + ) +} diff --git a/dashboard/src/routes/resource/expression/ExpressionList.tsx b/dashboard/src/routes/resource/expression/ExpressionList.tsx new file mode 100644 index 00000000..b39d748d --- /dev/null +++ b/dashboard/src/routes/resource/expression/ExpressionList.tsx @@ -0,0 +1,361 @@ +import { ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight, Edit, Eye, Trash2 } from 'lucide-react' +import { useState } from 'react' +import { Button } from '@/components/ui/button' +import { Checkbox } from '@/components/ui/checkbox' +import { Input } from '@/components/ui/input' +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@/components/ui/table' +import { useToast } from '@/hooks/use-toast' + +import type { Expression } from '@/types/expression' + +/** + * 表达方式列表组件(桌面端Table + 移动端Card视图 + 分页) + */ +export function ExpressionList({ + expressions, + loading, + total, + page, + pageSize, + selectedIds, + chatNameMap, + onEdit, + onViewDetail, + onDelete, + onToggleSelect, + onToggleSelectAll, + onPageChange, + onJumpToPage, +}: { + expressions: Expression[] + loading: boolean + total: number + page: number + pageSize: number + selectedIds: Set + chatNameMap: Map + onEdit: (expression: Expression) => void + onViewDetail: (expression: Expression) => void + onDelete: (expression: Expression) => void + onToggleSelect: (id: number) => void + onToggleSelectAll: () => void + onPageChange: (newPage: number) => void + onJumpToPage: (targetPage: string) => void +}) { + const { toast } = useToast() + + const getChatName = (chatId: string): string => { + return chatNameMap.get(chatId) || chatId + } + + const totalPages = Math.ceil(total / pageSize) + + const handleJumpToPage = (jumpToPage: string) => { + const targetPage = parseInt(jumpToPage) + if (targetPage >= 1 && targetPage <= totalPages) { + onJumpToPage(jumpToPage) + } else { + toast({ + title: '无效的页码', + description: `请输入1-${totalPages}之间的页码`, + variant: 'destructive', + }) + } + } + + return ( +
    + {/* 桌面端表格视图 */} +
    + + + + + 0} + onCheckedChange={onToggleSelectAll} + /> + + 情境 + 风格 + 聊天 + 操作 + + + + {loading ? ( + + + 加载中... + + + ) : expressions.length === 0 ? ( + + + 暂无数据 + + + ) : ( + expressions.map((expression) => ( + + + onToggleSelect(expression.id)} + /> + + + {expression.situation} + + {expression.style} + + + {getChatName(expression.chat_id)} + + + +
    + + + +
    +
    +
    + )) + )} +
    +
    +
    + + {/* 移动端卡片视图 */} +
    + {loading ? ( +
    + 加载中... +
    + ) : expressions.length === 0 ? ( +
    + 暂无数据 +
    + ) : ( + expressions.map((expression) => ( +
    + {/* 复选框和情境 */} +
    + onToggleSelect(expression.id)} + className="mt-1" + /> +
    +
    +
    情境
    +

    + {expression.situation} +

    +
    +
    +
    风格
    +

    + {expression.style} +

    +
    +
    +
    + + {/* 聊天名称 */} +
    +
    聊天
    +

    + {getChatName(expression.chat_id)} +

    +
    + + {/* 操作按钮 */} +
    + + + +
    +
    + )) + )} +
    + + {/* 分页 */} + {total > 0 && ( + + )} +
    + ) +} + +/** + * 分页组件 + */ +function Pagination({ + total, + page, + pageSize, + onPageChange, + onJumpToPage, +}: { + total: number + page: number + pageSize: number + onPageChange: (newPage: number) => void + onJumpToPage: (targetPage: string) => void +}) { + const [jumpToPage, setJumpToPage] = useState('') + const totalPages = Math.ceil(total / pageSize) + + const handleJump = () => { + if (jumpToPage) { + onJumpToPage(jumpToPage) + setJumpToPage('') + } + } + + return ( +
    +
    + 共 {total} 条记录,第 {page} / {totalPages} 页 +
    +
    + {/* 首页 */} + + + {/* 上一页 */} + + + {/* 页码跳转 */} +
    + setJumpToPage(e.target.value)} + onKeyDown={(e) => e.key === 'Enter' && handleJump()} + placeholder={page.toString()} + className="w-16 h-8 text-center" + min={1} + max={totalPages} + /> + +
    + + {/* 下一页 */} + + + {/* 末页 */} + +
    +
    + ) +} + diff --git a/dashboard/src/routes/resource/expression/index.ts b/dashboard/src/routes/resource/expression/index.ts new file mode 100644 index 00000000..983a46ac --- /dev/null +++ b/dashboard/src/routes/resource/expression/index.ts @@ -0,0 +1 @@ +export { ExpressionManagementPage } from './index.tsx' diff --git a/dashboard/src/routes/resource/expression/index.tsx b/dashboard/src/routes/resource/expression/index.tsx new file mode 100644 index 00000000..a7515a66 --- /dev/null +++ b/dashboard/src/routes/resource/expression/index.tsx @@ -0,0 +1,467 @@ +import { ClipboardCheck, MessageSquare, Plus, Search, Trash2 } from 'lucide-react' +import { useEffect, useState } from 'react' + +import { Button } from '@/components/ui/button' +import { ExpressionReviewer } from '@/components/expression-reviewer' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import { ScrollArea } from '@/components/ui/scroll-area' +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select' +import { useToast } from '@/hooks/use-toast' + +import { + batchDeleteExpressions, + deleteExpression, + getChatList, + getExpressionDetail, + getExpressionList, + getExpressionStats, + getReviewStats, +} from '@/lib/expression-api' + +import { + BatchDeleteConfirmDialog, + DeleteConfirmDialog, + ExpressionCreateDialog, + ExpressionDetailDialog, + ExpressionEditDialog, +} from './ExpressionDialogs' +import { ExpressionList } from './ExpressionList' + +import type { ChatInfo, Expression } from '@/types/expression' +import type { StatsData } from './types' + +/** + * 表达方式管理主页面 + */ +export function ExpressionManagementPage() { + const [expressions, setExpressions] = useState([]) + const [loading, setLoading] = useState(true) + const [total, setTotal] = useState(0) + const [page, setPage] = useState(1) + const [pageSize, setPageSize] = useState(20) + const [search, setSearch] = useState('') + const [selectedExpression, setSelectedExpression] = useState(null) + const [isDetailDialogOpen, setIsDetailDialogOpen] = useState(false) + const [isEditDialogOpen, setIsEditDialogOpen] = useState(false) + const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false) + const [deleteConfirmExpression, setDeleteConfirmExpression] = useState(null) + const [selectedIds, setSelectedIds] = useState>(new Set()) + const [isBatchDeleteDialogOpen, setIsBatchDeleteDialogOpen] = useState(false) + const [stats, setStats] = useState({ total: 0, recent_7days: 0, chat_count: 0, top_chats: {} }) + const [chatList, setChatList] = useState([]) + const [chatNameMap, setChatNameMap] = useState>(new Map()) + const [isReviewerOpen, setIsReviewerOpen] = useState(false) + const [uncheckedCount, setUncheckedCount] = useState(0) + const { toast } = useToast() + + // 加载表达方式列表 + const loadExpressions = async () => { + try { + setLoading(true) + const result = await getExpressionList({ + page, + page_size: pageSize, + search: search || undefined, + }) + if (result.success) { + setExpressions(result.data.data) + setTotal(result.data.total) + } else { + toast({ + title: '加载失败', + description: result.error, + variant: 'destructive', + }) + } + } catch (error) { + toast({ + title: '加载失败', + description: error instanceof Error ? error.message : '无法加载表达方式', + variant: 'destructive', + }) + } finally { + setLoading(false) + } + } + + // 加载统计数据 + const loadStats = async () => { + try { + const result = await getExpressionStats() + if (result.success) { + setStats(result.data) + } else { + console.error('加载统计数据失败:', result.error) + } + } catch (error) { + console.error('加载统计数据失败:', error) + } + } + + // 加载审核统计 + const loadReviewStats = async () => { + try { + const result = await getReviewStats() + if (result.success) { + setUncheckedCount(result.data.unchecked) + } + } catch (error) { + console.error('加载审核统计失败:', error) + } + } + + // 加载聚天列表 + const loadChatList = async () => { + try { + const result = await getChatList() + if (result.success) { + setChatList(result.data) + const nameMap = new Map() + result.data.forEach((chat: ChatInfo) => { + nameMap.set(chat.chat_id, chat.chat_name) + }) + setChatNameMap(nameMap) + } + } catch (error) { + console.error('加载聚天列表失败:', error) + } + } + + // 初始加载 + useEffect(() => { + loadExpressions() + loadReviewStats() + loadStats() + loadChatList() + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [page, pageSize, search]) + + // 查看详情 + const handleViewDetail = async (expression: Expression) => { + try { + const result = await getExpressionDetail(expression.id) + if (result.success) { + setSelectedExpression(result.data) + setIsDetailDialogOpen(true) + } else { + toast({ + title: '加载详情失败', + description: result.error, + variant: 'destructive', + }) + } + } catch (error) { + toast({ + title: '加载详情失败', + description: error instanceof Error ? error.message : '无法加载表达方式详情', + variant: 'destructive', + }) + } + } + + // 编辑表达方式 + const handleEdit = (expression: Expression) => { + setSelectedExpression(expression) + setIsEditDialogOpen(true) + } + + // 删除表达方式 + const handleDelete = async () => { + if (!deleteConfirmExpression) return + try { + const result = await deleteExpression(deleteConfirmExpression.id) + if (result.success) { + toast({ + title: '删除成功', + description: `已删除表达方式: ${deleteConfirmExpression.situation}`, + }) + setDeleteConfirmExpression(null) + loadExpressions() + loadStats() + } else { + toast({ + title: '删除失败', + description: result.error, + variant: 'destructive', + }) + } + } catch (error) { + toast({ + title: '删除失败', + description: error instanceof Error ? error.message : '无法删除表达方式', + variant: 'destructive', + }) + } + } + + // 切换单个选择 + const toggleSelect = (id: number) => { + const newSelected = new Set(selectedIds) + if (newSelected.has(id)) { + newSelected.delete(id) + } else { + newSelected.add(id) + } + setSelectedIds(newSelected) + } + + // 全选/取消全选 + const toggleSelectAll = () => { + if (selectedIds.size === expressions.length && expressions.length > 0) { + setSelectedIds(new Set()) + } else { + setSelectedIds(new Set(expressions.map(e => e.id))) + } + } + + // 批量删除 + const handleBatchDelete = async () => { + try { + const result = await batchDeleteExpressions(Array.from(selectedIds)) + if (result.success) { + toast({ + title: '批量删除成功', + description: `已删除 ${selectedIds.size} 个表达方式`, + }) + setSelectedIds(new Set()) + setIsBatchDeleteDialogOpen(false) + loadExpressions() + loadStats() + } else { + toast({ + title: '批量删除失败', + description: result.error, + variant: 'destructive', + }) + } + } catch (error) { + toast({ + title: '批量删除失败', + description: error instanceof Error ? error.message : '无法批量删除表达方式', + variant: 'destructive', + }) + } + } + + // 页面跳转 + const handleJumpToPage = (jumpToPage: string) => { + const targetPage = parseInt(jumpToPage) + const totalPages = Math.ceil(total / pageSize) + if (targetPage >= 1 && targetPage <= totalPages) { + setPage(targetPage) + } + } + + return ( +
    + {/* 页面标题 */} +
    +
    +
    +

    + + 表达方式管理 +

    +

    + 管理麦麦的表达方式和话术模板 +

    +
    +
    + + +
    +
    +
    + + +
    + + {/* 统计卡片 */} +
    +
    +
    总数量
    +
    {stats.total}
    +
    +
    +
    近7天新增
    +
    {stats.recent_7days}
    +
    +
    +
    关联聊天数
    +
    {stats.chat_count}
    +
    +
    + + {/* 搜索和批量操作 */} +
    + +
    +
    + + setSearch(e.target.value)} + className="pl-9" + /> +
    +
    + + {/* 批量操作工具栏 */} +
    +
    + {selectedIds.size > 0 && ( + 已选择 {selectedIds.size} 个表达方式 + )} +
    +
    + + + {selectedIds.size > 0 && ( + <> + + + + )} +
    +
    +
    + + {/* 表达方式列表 */} + setDeleteConfirmExpression(expression)} + onToggleSelect={toggleSelect} + onToggleSelectAll={toggleSelectAll} + onPageChange={setPage} + onJumpToPage={handleJumpToPage} + /> + +
    +
    + + {/* 详情对话框 */} + + + {/* 创建对话框 */} + { + loadExpressions() + loadStats() + setIsCreateDialogOpen(false) + }} + /> + + {/* 编辑对话框 */} + { + loadExpressions() + loadStats() + setIsEditDialogOpen(false) + }} + /> + + {/* 删除确认对话框 */} + setDeleteConfirmExpression(null)} + onConfirm={handleDelete} + /> + + {/* 批量删除确认对话框 */} + + + {/* 表达方式审核器 */} + { + setIsReviewerOpen(open) + if (!open) { + loadExpressions() + loadStats() + loadReviewStats() + } + }} + /> +
    + ) +} diff --git a/dashboard/src/routes/resource/expression/types.ts b/dashboard/src/routes/resource/expression/types.ts new file mode 100644 index 00000000..a1de7dbb --- /dev/null +++ b/dashboard/src/routes/resource/expression/types.ts @@ -0,0 +1,47 @@ +/** + * 表达方式管理页面内部类型定义 + */ + +import type { Expression } from '@/types/expression' + +/** + * 删除确认状态 + */ +export interface DeleteConfirmState { + expression: Expression | null + isOpen: boolean +} + +/** + * 统计数据 + */ +export interface StatsData { + total: number + recent_7days: number + chat_count: number + top_chats: Record +} + +/** + * 页面状态 + */ +export interface PageState { + expressions: Expression[] + loading: boolean + total: number + page: number + pageSize: number + search: string + selectedExpression: Expression | null + isDetailDialogOpen: boolean + isEditDialogOpen: boolean + isCreateDialogOpen: boolean + deleteConfirmExpression: Expression | null + selectedIds: Set + isBatchDeleteDialogOpen: boolean + jumpToPage: string + stats: StatsData + chatNameMap: Map + isReviewerOpen: boolean + uncheckedCount: number +} From 31856422d1b2258610a2210b6435e19ed5c7f4f2 Mon Sep 17 00:00:00 2001 From: DrSmoothl <1787882683@qq.com> Date: Sun, 1 Mar 2026 20:42:49 +0800 Subject: [PATCH 22/26] refactor(routes): split jargon.tsx into modular jargon/ directory (T19b) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 拆分 jargon.tsx (1064行) 为 5 个模块化文件 - index.tsx (460行) - 主容器 + 17 个 useState - JargonDialogs.tsx (527行) - 5 个对话框组件 - JargonList.tsx (255行) - 表格+卡片双视图 - types.ts (17行) - StatsData 类型定义 - index.ts (1行) - barrel 导出 - 保留所有功能:CRUD、搜索、筛选、批量操作、分页 - 统一 API 错误处理模式 - 响应式设计完整保留(桌面端表格 + 移动端卡片) - 构建验证通过,零错误 Wave 4 - Task 19b (jargon) --- dashboard/src/routes/resource/jargon.tsx | 1064 ----------------- .../routes/resource/jargon/JargonDialogs.tsx | 527 ++++++++ .../src/routes/resource/jargon/JargonList.tsx | 255 ++++ dashboard/src/routes/resource/jargon/index.ts | 1 + .../src/routes/resource/jargon/index.tsx | 460 +++++++ dashboard/src/routes/resource/jargon/types.ts | 17 + 6 files changed, 1260 insertions(+), 1064 deletions(-) delete mode 100644 dashboard/src/routes/resource/jargon.tsx create mode 100644 dashboard/src/routes/resource/jargon/JargonDialogs.tsx create mode 100644 dashboard/src/routes/resource/jargon/JargonList.tsx create mode 100644 dashboard/src/routes/resource/jargon/index.ts create mode 100644 dashboard/src/routes/resource/jargon/index.tsx create mode 100644 dashboard/src/routes/resource/jargon/types.ts diff --git a/dashboard/src/routes/resource/jargon.tsx b/dashboard/src/routes/resource/jargon.tsx deleted file mode 100644 index 6e4e1280..00000000 --- a/dashboard/src/routes/resource/jargon.tsx +++ /dev/null @@ -1,1064 +0,0 @@ -import { Hash, Search, Edit, Trash2, Eye, Plus, ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight, Check, X, HelpCircle, Globe, MessageCircle } from 'lucide-react' -import { useState, useEffect } from 'react' -import { cn } from '@/lib/utils' -import { MarkdownRenderer } from '@/components/markdown-renderer' -import { Button } from '@/components/ui/button' -import { Input } from '@/components/ui/input' -import { Label } from '@/components/ui/label' -import { ScrollArea } from '@/components/ui/scroll-area' -import { useToast } from '@/hooks/use-toast' -import { Textarea } from '@/components/ui/textarea' -import { Badge } from '@/components/ui/badge' -import { - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, -} from '@/components/ui/table' -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, -} from '@/components/ui/dialog' -import { - AlertDialog, - AlertDialogAction, - AlertDialogCancel, - AlertDialogContent, - AlertDialogDescription, - AlertDialogFooter, - AlertDialogHeader, - AlertDialogTitle, -} from '@/components/ui/alert-dialog' -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from '@/components/ui/select' -import { Checkbox } from '@/components/ui/checkbox' -import { Switch } from '@/components/ui/switch' -import type { Jargon, JargonCreateRequest, JargonUpdateRequest, JargonChatInfo, JargonStats } from '@/types/jargon' -import { getJargonList, getJargonDetail, createJargon, updateJargon, deleteJargon, batchDeleteJargons, getJargonStats, getJargonChatList, batchSetJargonStatus } from '@/lib/jargon-api' - -export function JargonManagementPage() { - const [jargons, setJargons] = useState([]) - const [loading, setLoading] = useState(true) - const [total, setTotal] = useState(0) - const [page, setPage] = useState(1) - const [pageSize, setPageSize] = useState(20) - const [search, setSearch] = useState('') - const [filterChatId, setFilterChatId] = useState('all') - const [filterIsJargon, setFilterIsJargon] = useState('all') - const [selectedJargon, setSelectedJargon] = useState(null) - const [isDetailDialogOpen, setIsDetailDialogOpen] = useState(false) - const [isEditDialogOpen, setIsEditDialogOpen] = useState(false) - const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false) - const [deleteConfirmJargon, setDeleteConfirmJargon] = useState(null) - const [selectedIds, setSelectedIds] = useState>(new Set()) - const [isBatchDeleteDialogOpen, setIsBatchDeleteDialogOpen] = useState(false) - const [jumpToPage, setJumpToPage] = useState('') - const [stats, setStats] = useState({ - total: 0, - confirmed_jargon: 0, - confirmed_not_jargon: 0, - pending: 0, - global_count: 0, - complete_count: 0, - chat_count: 0, - top_chats: {}, - }) - const [chatList, setChatList] = useState([]) - const { toast } = useToast() - - // 加载黑话列表 - const loadJargons = async () => { - try { - setLoading(true) - const response = await getJargonList({ - page, - page_size: pageSize, - search: search || undefined, - chat_id: filterChatId === 'all' ? undefined : filterChatId, - is_jargon: filterIsJargon === 'all' ? undefined : filterIsJargon === 'true' ? true : filterIsJargon === 'false' ? false : undefined, - }) - setJargons(response.data) - setTotal(response.total) - } catch (error) { - toast({ - title: '加载失败', - description: error instanceof Error ? error.message : '无法加载黑话列表', - variant: 'destructive', - }) - } finally { - setLoading(false) - } - } - - // 加载统计数据 - const loadStats = async () => { - try { - const response = await getJargonStats() - if (response?.data) { - setStats(response.data) - } - } catch (error) { - console.error('加载统计数据失败:', error) - } - } - - // 加载聊天列表 - const loadChatList = async () => { - try { - const response = await getJargonChatList() - if (response?.data) { - setChatList(response.data) - } - } catch (error) { - console.error('加载聊天列表失败:', error) - } - } - - // 初始加载 - useEffect(() => { - loadJargons() - loadStats() - loadChatList() - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [page, pageSize, search, filterChatId, filterIsJargon]) - - // 查看详情 - const handleViewDetail = async (jargon: Jargon) => { - try { - const response = await getJargonDetail(jargon.id) - setSelectedJargon(response.data) - setIsDetailDialogOpen(true) - } catch (error) { - toast({ - title: '加载详情失败', - description: error instanceof Error ? error.message : '无法加载黑话详情', - variant: 'destructive', - }) - } - } - - // 编辑黑话 - const handleEdit = (jargon: Jargon) => { - setSelectedJargon(jargon) - setIsEditDialogOpen(true) - } - - // 删除黑话 - const handleDelete = async (jargon: Jargon) => { - try { - await deleteJargon(jargon.id) - toast({ - title: '删除成功', - description: `已删除黑话: ${jargon.content}`, - }) - setDeleteConfirmJargon(null) - loadJargons() - loadStats() - } catch (error) { - toast({ - title: '删除失败', - description: error instanceof Error ? error.message : '无法删除黑话', - variant: 'destructive', - }) - } - } - - // 切换单个选择 - const toggleSelect = (id: number) => { - const newSelected = new Set(selectedIds) - if (newSelected.has(id)) { - newSelected.delete(id) - } else { - newSelected.add(id) - } - setSelectedIds(newSelected) - } - - // 全选/取消全选 - const toggleSelectAll = () => { - if (selectedIds.size === jargons.length && jargons.length > 0) { - setSelectedIds(new Set()) - } else { - setSelectedIds(new Set(jargons.map(j => j.id))) - } - } - - // 批量删除 - const handleBatchDelete = async () => { - try { - await batchDeleteJargons(Array.from(selectedIds)) - toast({ - title: '批量删除成功', - description: `已删除 ${selectedIds.size} 个黑话`, - }) - setSelectedIds(new Set()) - setIsBatchDeleteDialogOpen(false) - loadJargons() - loadStats() - } catch (error) { - toast({ - title: '批量删除失败', - description: error instanceof Error ? error.message : '无法批量删除黑话', - variant: 'destructive', - }) - } - } - - // 批量设置为黑话 - const handleBatchSetJargon = async (isJargon: boolean) => { - try { - await batchSetJargonStatus(Array.from(selectedIds), isJargon) - toast({ - title: '操作成功', - description: `已将 ${selectedIds.size} 个词条设为${isJargon ? '黑话' : '非黑话'}`, - }) - setSelectedIds(new Set()) - loadJargons() - loadStats() - } catch (error) { - toast({ - title: '操作失败', - description: error instanceof Error ? error.message : '批量设置失败', - variant: 'destructive', - }) - } - } - - // 页面跳转 - const handleJumpToPage = () => { - const targetPage = parseInt(jumpToPage) - const totalPages = Math.ceil(total / pageSize) - if (targetPage >= 1 && targetPage <= totalPages) { - setPage(targetPage) - setJumpToPage('') - } else { - toast({ - title: '无效的页码', - description: `请输入1-${totalPages}之间的页码`, - variant: 'destructive', - }) - } - } - - // 渲染黑话状态徽章 - const renderJargonStatus = (isJargon: boolean | null) => { - if (isJargon === true) { - return 是黑话 - } else if (isJargon === false) { - return 非黑话 - } else { - return 未判定 - } - } - - return ( -
    - {/* 页面标题 */} -
    -
    -
    -

    - - 黑话管理 -

    -

    - 管理麦麦学习到的黑话和俚语 -

    -
    - -
    -
    - - -
    - - {/* 统计卡片 */} -
    -
    -
    总数量
    -
    {stats.total}
    -
    -
    -
    已确认黑话
    -
    {stats.confirmed_jargon}
    -
    -
    -
    确认非黑话
    -
    {stats.confirmed_not_jargon}
    -
    -
    -
    待判定
    -
    {stats.pending}
    -
    -
    -
    全局黑话
    -
    {stats.global_count}
    -
    -
    -
    推断完成
    -
    {stats.complete_count}
    -
    -
    -
    关联聊天数
    -
    {stats.chat_count}
    -
    -
    - - {/* 搜索和筛选 */} -
    -
    -
    - -
    - - setSearch(e.target.value)} - className="pl-9" - /> -
    -
    -
    - - -
    -
    - - -
    -
    - - -
    -
    - - {/* 批量操作工具栏 */} - {selectedIds.size > 0 && ( -
    - 已选择 {selectedIds.size} 个 - - - - -
    - )} -
    - - {/* 黑话列表 */} -
    - {/* 桌面端表格视图 */} -
    - - - - - 0} - onCheckedChange={toggleSelectAll} - /> - - 内容 - 含义 - 聊天 - 状态 - 次数 - 操作 - - - - {loading ? ( - - - 加载中... - - - ) : jargons.length === 0 ? ( - - - 暂无数据 - - - ) : ( - jargons.map((jargon) => ( - - - toggleSelect(jargon.id)} - /> - - -
    - {jargon.is_global && } - {jargon.content} -
    -
    - - {jargon.meaning || -} - - - {jargon.chat_name || jargon.chat_id} - - {renderJargonStatus(jargon.is_jargon)} - {jargon.count} - -
    - - - -
    -
    -
    - )) - )} -
    -
    -
    - - {/* 移动端卡片视图 */} -
    - {loading ? ( -
    加载中...
    - ) : jargons.length === 0 ? ( -
    暂无数据
    - ) : ( - jargons.map((jargon) => ( -
    -
    - toggleSelect(jargon.id)} - className="mt-1" - /> -
    -
    - {jargon.is_global && } -

    {jargon.content}

    -
    - {jargon.meaning && ( -

    {jargon.meaning}

    - )} -
    - {renderJargonStatus(jargon.is_jargon)} - 次数: {jargon.count} -
    -
    - 聊天: {jargon.chat_name || jargon.chat_id} -
    -
    -
    -
    - - - -
    -
    - )) - )} -
    - - {/* 分页 */} - {total > 0 && ( -
    -
    - 共 {total} 条记录,第 {page} / {Math.ceil(total / pageSize)} 页 -
    -
    - - -
    - setJumpToPage(e.target.value)} - onKeyDown={(e) => e.key === 'Enter' && handleJumpToPage()} - placeholder={page.toString()} - className="w-16 h-8 text-center" - min={1} - max={Math.ceil(total / pageSize)} - /> - -
    - - -
    -
    - )} -
    -
    -
    - - {/* 详情对话框 */} - - - {/* 创建对话框 */} - { - loadJargons() - loadStats() - setIsCreateDialogOpen(false) - }} - /> - - {/* 编辑对话框 */} - { - loadJargons() - loadStats() - setIsEditDialogOpen(false) - }} - /> - - {/* 删除确认对话框 */} - setDeleteConfirmJargon(null)}> - - - 确认删除 - - 确定要删除黑话 "{deleteConfirmJargon?.content}" 吗?此操作不可撤销。 - - - - 取消 - deleteConfirmJargon && handleDelete(deleteConfirmJargon)} - className="bg-destructive text-destructive-foreground hover:bg-destructive/90" - > - 删除 - - - - - - {/* 批量删除确认对话框 */} - - - - 确认批量删除 - - 您即将删除 {selectedIds.size} 个黑话,此操作无法撤销。确定要继续吗? - - - - 取消 - - 确认删除 - - - - -
    - ) -} - -// 黑话详情对话框 -function JargonDetailDialog({ - jargon, - open, - onOpenChange, -}: { - jargon: Jargon | null - open: boolean - onOpenChange: (open: boolean) => void -}) { - if (!jargon) return null - - return ( - - - - 黑话详情 - 查看黑话的完整信息 - - - -
    -
    - - -
    - -
    - -
    {jargon.content}
    -
    - - {jargon.raw_content && ( -
    - -
    - {(() => { - try { - const rawArray = JSON.parse(jargon.raw_content) - if (Array.isArray(rawArray)) { - return rawArray.map((item, index) => ( -
    - {index > 0 &&
    } -
    {item}
    -
    - )) - } - return
    {jargon.raw_content}
    - } catch { - return
    {jargon.raw_content}
    - } - })()} -
    -
    - )} - -
    - -
    - {jargon.meaning ? ( - - ) : ( - '-' - )} -
    -
    - -
    - -
    - -
    - {jargon.is_jargon === true && 是黑话} - {jargon.is_jargon === false && 非黑话} - {jargon.is_jargon === null && 未判定} - {jargon.is_global && 全局} - {jargon.is_complete && 推断完成} -
    -
    -
    - - {jargon.inference_with_context && ( -
    - -
    {jargon.inference_with_context}
    -
    - )} - - {jargon.inference_content_only && ( -
    - -
    {jargon.inference_content_only}
    -
    - )} -
    -
    - - - - -
    -
    - ) -} - -// 信息项组件 -function InfoItem({ - icon: Icon, - label, - value, - mono = false, -}: { - icon?: typeof Hash - label: string - value: string | null | undefined - mono?: boolean -}) { - return ( -
    - -
    - {value || '-'} -
    -
    - ) -} - -// 黑话创建对话框 -function JargonCreateDialog({ - open, - onOpenChange, - chatList, - onSuccess, -}: { - open: boolean - onOpenChange: (open: boolean) => void - chatList: JargonChatInfo[] - onSuccess: () => void -}) { - const [formData, setFormData] = useState({ - content: '', - meaning: '', - chat_id: '', - is_global: false, - }) - const [saving, setSaving] = useState(false) - const { toast } = useToast() - - const handleCreate = async () => { - if (!formData.content || !formData.chat_id) { - toast({ - title: '验证失败', - description: '请填写必填字段:内容和聊天', - variant: 'destructive', - }) - return - } - - try { - setSaving(true) - await createJargon(formData) - toast({ - title: '创建成功', - description: '黑话已创建', - }) - setFormData({ content: '', meaning: '', chat_id: '', is_global: false }) - onSuccess() - } catch (error) { - toast({ - title: '创建失败', - description: error instanceof Error ? error.message : '无法创建黑话', - variant: 'destructive', - }) - } finally { - setSaving(false) - } - } - - return ( - - - - 新增黑话 - 创建新的黑话记录 - - -
    -
    - - setFormData({ ...formData, content: e.target.value })} - placeholder="输入黑话内容" - /> -
    - -
    - -