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 }