MaiBot/dashboard/src/routes/settings.tsx

2226 lines
88 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

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, useEffect } 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 { defaultLightTokens } from '@/lib/theme/tokens'
import { exportThemeJSON, importThemeJSON } from '@/lib/theme/storage'
import type { ThemeTokens } from '@/lib/theme/tokens'
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from '@/components/ui/accordion'
import { CodeEditor } from '@/components/CodeEditor'
import { sanitizeCSS } from '@/lib/theme/sanitizer'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
export function SettingsPage() {
return (
<div className="space-y-4 sm:space-y-6 p-4 sm:p-6">
{/* 页面标题 */}
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<div>
<h1 className="text-2xl sm:text-3xl font-bold"></h1>
<p className="text-muted-foreground mt-1 sm:mt-2 text-sm sm:text-base"></p>
</div>
</div>
{/* 标签页 */}
<Tabs defaultValue="appearance" className="w-full">
<TabsList className="grid w-full grid-cols-2 sm:grid-cols-4 gap-0.5 sm:gap-1 h-auto p-1">
<TabsTrigger value="appearance" className="gap-1 sm:gap-2 text-xs sm:text-sm px-2 sm:px-3 py-2">
<Palette className="h-3.5 w-3.5 sm:h-4 sm:w-4" strokeWidth={2} fill="none" />
<span></span>
</TabsTrigger>
<TabsTrigger value="security" className="gap-1 sm:gap-2 text-xs sm:text-sm px-2 sm:px-3 py-2">
<Shield className="h-3.5 w-3.5 sm:h-4 sm:w-4" strokeWidth={2} fill="none" />
<span></span>
</TabsTrigger>
<TabsTrigger value="other" className="gap-1 sm:gap-2 text-xs sm:text-sm px-2 sm:px-3 py-2">
<Settings className="h-3.5 w-3.5 sm:h-4 sm:w-4" strokeWidth={2} fill="none" />
<span></span>
</TabsTrigger>
<TabsTrigger value="about" className="gap-1 sm:gap-2 text-xs sm:text-sm px-2 sm:px-3 py-2">
<Info className="h-3.5 w-3.5 sm:h-4 sm:w-4" strokeWidth={2} fill="none" />
<span></span>
</TabsTrigger>
</TabsList>
<ScrollArea className="h-[calc(100vh-240px)] sm:h-[calc(100vh-280px)] mt-4 sm:mt-6">
<TabsContent value="appearance" className="mt-0">
<AppearanceTab />
</TabsContent>
<TabsContent value="security" className="mt-0">
<SecurityTab />
</TabsContent>
<TabsContent value="other" className="mt-0">
<OtherTab />
</TabsContent>
<TabsContent value="about" className="mt-0">
<AboutTab />
</TabsContent>
</ScrollArea>
</Tabs>
</div>
)
}
// 辅助函数:将 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 (0 <= h && h < 60) { r = c; g = x; b = 0 }
else if (60 <= h && h < 120) { r = x; g = c; b = 0 }
else if (120 <= h && h < 180) { r = 0; g = c; b = x }
else if (180 <= h && h < 240) { r = 0; g = x; b = c }
else if (240 <= h && h < 300) { r = x; g = 0; b = c }
else if (300 <= h && 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<string[]>([])
const cssDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const fileInputRef = useRef<HTMLInputElement>(null)
useEffect(() => {
setLocalCSS(themeConfig.customCSS || '')
}, [themeConfig.customCSS])
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<HTMLInputElement>) => {
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<HTMLInputElement>) => {
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()
toast({ title: '重置成功', description: '主题已重置为默认值' })
}
const previewTokens = useMemo(() => {
return getComputedTokens(themeConfig, resolvedTheme === 'dark').color
}, [themeConfig, resolvedTheme])
return (
<div className="space-y-6 sm:space-y-8">
{/* 主题模式 */}
<div>
<h3 className="text-base sm:text-lg font-semibold mb-3 sm:mb-4"></h3>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3 sm:gap-4">
<ThemeOption
value="light"
current={theme}
onChange={setTheme}
label="浅色"
description="始终使用浅色主题"
/>
<ThemeOption
value="dark"
current={theme}
onChange={setTheme}
label="深色"
description="始终使用深色主题"
/>
<ThemeOption
value="system"
current={theme}
onChange={setTheme}
label="跟随系统"
description="根据系统设置自动切换"
/>
</div>
</div>
{/* 主题色配置 */}
<div>
<div className="flex items-center justify-between mb-3 sm:mb-4">
<h3 className="text-base sm:text-lg font-semibold"></h3>
<Button
variant="outline"
size="sm"
onClick={handleResetAccent}
disabled={!themeConfig.accentColor}
className="h-8"
>
<RotateCcw className="mr-2 h-3.5 w-3.5" />
</Button>
</div>
<div className="space-y-6">
{/* 颜色选择器 */}
<div className="flex flex-col sm:flex-row gap-4 items-start sm:items-center p-4 rounded-lg border bg-card">
<div className="flex items-center gap-3">
<div className="h-10 w-10 rounded-full border-2 border-border overflow-hidden relative shadow-sm">
<input
type="color"
value={currentAccentHex}
onChange={handleAccentColorChange}
className="absolute inset-0 w-[150%] h-[150%] -top-1/4 -left-1/4 cursor-pointer p-0 border-0"
/>
</div>
<div className="space-y-1">
<Label htmlFor="accent-color-input" className="font-medium"></Label>
<p className="text-xs text-muted-foreground"> HEX </p>
</div>
</div>
<div className="flex-1 w-full sm:w-auto flex items-center gap-2">
<Input
id="accent-color-input"
type="text"
value={currentAccentHex}
onChange={handleAccentColorChange}
className="font-mono uppercase w-32"
maxLength={7}
/>
</div>
</div>
{/* 实时色板预览 */}
<div className="space-y-3">
<h4 className="text-sm font-medium text-muted-foreground"></h4>
<div className="grid grid-cols-2 sm:grid-cols-4 md:grid-cols-8 gap-3">
<ColorTokenPreview name="primary" value={previewTokens.primary} foreground={previewTokens['primary-foreground']} />
<ColorTokenPreview name="secondary" value={previewTokens.secondary} foreground={previewTokens['secondary-foreground']} />
<ColorTokenPreview name="muted" value={previewTokens.muted} foreground={previewTokens['muted-foreground']} />
<ColorTokenPreview name="accent" value={previewTokens.accent} foreground={previewTokens['accent-foreground']} />
<ColorTokenPreview name="destructive" value={previewTokens.destructive} foreground={previewTokens['destructive-foreground']} />
<ColorTokenPreview name="background" value={previewTokens.background} foreground={previewTokens.foreground} border />
<ColorTokenPreview name="card" value={previewTokens.card} foreground={previewTokens['card-foreground']} border />
<ColorTokenPreview name="border" value={previewTokens.border} />
</div>
</div>
</div>
</div>
{/* 样式微调 */}
<div>
<h3 className="text-base sm:text-lg font-semibold mb-3 sm:mb-4"></h3>
<Accordion type="single" collapsible className="w-full">
{/* 1. 字体排版 (Typography) */}
<AccordionItem value="typography">
<AccordionTrigger> (Typography)</AccordionTrigger>
<AccordionContent>
<div className="space-y-4 pt-2">
<div className="flex justify-end">
<Button
variant="ghost"
size="sm"
onClick={() => {
const newOverrides = { ...themeConfig.tokenOverrides }
delete newOverrides.typography
updateThemeConfig({ tokenOverrides: newOverrides })
}}
disabled={!themeConfig.tokenOverrides?.typography}
className="h-8 text-xs"
>
<RotateCcw className="mr-2 h-3.5 w-3.5" />
</Button>
</div>
<div className="space-y-2">
<Label> (Font Family)</Label>
<Select
value={(themeConfig.tokenOverrides?.typography as any)?.['font-family-base']?.includes('ui-serif') ? 'serif' :
(themeConfig.tokenOverrides?.typography as any)?.['font-family-base']?.includes('ui-monospace') ? 'mono' :
(themeConfig.tokenOverrides?.typography as any)?.['font-family-base'] ? 'sans' : 'system'}
onValueChange={(val) => {
let fontVal = defaultLightTokens.typography['font-family-base']
if (val === 'serif') fontVal = 'ui-serif, Georgia, Cambria, "Times New Roman", Times, serif'
else if (val === 'mono') fontVal = 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace'
else if (val === 'sans') fontVal = 'ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif'
updateThemeConfig({
tokenOverrides: {
...themeConfig.tokenOverrides,
typography: {
...themeConfig.tokenOverrides?.typography,
'font-family-base': fontVal
}
}
})
}}
>
<SelectTrigger>
<SelectValue placeholder="选择字体族" />
</SelectTrigger>
<SelectContent>
<SelectItem value="system"> (System)</SelectItem>
<SelectItem value="sans">线 (Sans-serif)</SelectItem>
<SelectItem value="serif">线 (Serif)</SelectItem>
<SelectItem value="mono"> (Monospace)</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-4">
<div className="flex justify-between">
<Label> (Base Size)</Label>
<span className="text-sm text-muted-foreground">
{parseFloat((themeConfig.tokenOverrides?.typography as any)?.['font-size-base'] || '1') * 16}px
</span>
</div>
<Slider
defaultValue={[16]}
value={[parseFloat((themeConfig.tokenOverrides?.typography as any)?.['font-size-base'] || '1') * 16]}
min={12}
max={20}
step={1}
onValueChange={(vals) => {
updateThemeConfig({
tokenOverrides: {
...themeConfig.tokenOverrides,
typography: {
...themeConfig.tokenOverrides?.typography,
'font-size-base': `${vals[0] / 16}rem`
}
}
})
}}
/>
</div>
<div className="space-y-2">
<Label> (Line Height)</Label>
<Select
value={String((themeConfig.tokenOverrides?.typography as any)?.['line-height-normal'] || '1.5')}
onValueChange={(val) => {
updateThemeConfig({
tokenOverrides: {
...themeConfig.tokenOverrides,
typography: {
...themeConfig.tokenOverrides?.typography,
'line-height-normal': parseFloat(val)
}
}
})
}}
>
<SelectTrigger>
<SelectValue placeholder="选择行高" />
</SelectTrigger>
<SelectContent>
<SelectItem value="1.2"> (1.2)</SelectItem>
<SelectItem value="1.5"> (1.5)</SelectItem>
<SelectItem value="1.75"> (1.75)</SelectItem>
</SelectContent>
</Select>
</div>
</div>
</AccordionContent>
</AccordionItem>
{/* 2. 视觉效果 (Visual) */}
<AccordionItem value="visual">
<AccordionTrigger> (Visual)</AccordionTrigger>
<AccordionContent>
<div className="space-y-4 pt-2">
<div className="flex justify-end">
<Button
variant="ghost"
size="sm"
onClick={() => {
const newOverrides = { ...themeConfig.tokenOverrides }
delete newOverrides.visual
updateThemeConfig({ tokenOverrides: newOverrides })
}}
disabled={!themeConfig.tokenOverrides?.visual}
className="h-8 text-xs"
>
<RotateCcw className="mr-2 h-3.5 w-3.5" />
</Button>
</div>
<div className="space-y-4">
<div className="flex justify-between">
<Label> (Radius)</Label>
<span className="text-sm text-muted-foreground">
{Math.round(parseFloat((themeConfig.tokenOverrides?.visual as any)?.['radius-md'] || '0.375') * 16)}px
</span>
</div>
<Slider
defaultValue={[6]}
value={[Math.round(parseFloat((themeConfig.tokenOverrides?.visual as any)?.['radius-md'] || '0.375') * 16)]}
min={0}
max={24}
step={1}
onValueChange={(vals) => {
updateThemeConfig({
tokenOverrides: {
...themeConfig.tokenOverrides,
visual: {
...themeConfig.tokenOverrides?.visual,
'radius-md': `${vals[0] / 16}rem`
}
}
})
}}
/>
</div>
<div className="space-y-2">
<Label> (Shadow)</Label>
<Select
value={(themeConfig.tokenOverrides?.visual as any)?.['shadow-md'] === 'none' ? 'none' :
(themeConfig.tokenOverrides?.visual as any)?.['shadow-md'] === defaultLightTokens.visual['shadow-sm'] ? 'sm' :
(themeConfig.tokenOverrides?.visual as any)?.['shadow-md'] === defaultLightTokens.visual['shadow-lg'] ? 'lg' :
(themeConfig.tokenOverrides?.visual as any)?.['shadow-md'] === defaultLightTokens.visual['shadow-xl'] ? 'xl' : 'md'}
onValueChange={(val) => {
let shadowVal = defaultLightTokens.visual['shadow-md']
if (val === 'none') shadowVal = 'none'
else if (val === 'sm') shadowVal = defaultLightTokens.visual['shadow-sm']
else if (val === 'lg') shadowVal = defaultLightTokens.visual['shadow-lg']
else if (val === 'xl') shadowVal = defaultLightTokens.visual['shadow-xl']
updateThemeConfig({
tokenOverrides: {
...themeConfig.tokenOverrides,
visual: {
...themeConfig.tokenOverrides?.visual,
'shadow-md': shadowVal
}
}
})
}}
>
<SelectTrigger>
<SelectValue placeholder="选择阴影强度" />
</SelectTrigger>
<SelectContent>
<SelectItem value="none"> (None)</SelectItem>
<SelectItem value="sm"> (Small)</SelectItem>
<SelectItem value="md"> (Medium)</SelectItem>
<SelectItem value="lg"> (Large)</SelectItem>
<SelectItem value="xl"> (Extra Large)</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex items-center justify-between">
<Label htmlFor="blur-switch"> (Blur)</Label>
<Switch
id="blur-switch"
checked={(themeConfig.tokenOverrides?.visual as any)?.['blur-md'] !== '0px'}
onCheckedChange={(checked) => {
updateThemeConfig({
tokenOverrides: {
...themeConfig.tokenOverrides,
visual: {
...themeConfig.tokenOverrides?.visual,
'blur-md': checked ? defaultLightTokens.visual['blur-md'] : '0px'
}
}
})
}}
/>
</div>
</div>
</AccordionContent>
</AccordionItem>
{/* 3. 布局 (Layout) */}
<AccordionItem value="layout">
<AccordionTrigger> (Layout)</AccordionTrigger>
<AccordionContent>
<div className="space-y-4 pt-2">
<div className="flex justify-end">
<Button
variant="ghost"
size="sm"
onClick={() => {
const newOverrides = { ...themeConfig.tokenOverrides }
delete newOverrides.layout
updateThemeConfig({ tokenOverrides: newOverrides })
}}
disabled={!themeConfig.tokenOverrides?.layout}
className="h-8 text-xs"
>
<RotateCcw className="mr-2 h-3.5 w-3.5" />
</Button>
</div>
<div className="space-y-4">
<div className="flex justify-between">
<Label> (Sidebar Width)</Label>
<span className="text-sm text-muted-foreground">
{(themeConfig.tokenOverrides?.layout as any)?.['sidebar-width'] || '16rem'}
</span>
</div>
<Slider
defaultValue={[16]}
value={[parseFloat((themeConfig.tokenOverrides?.layout as any)?.['sidebar-width'] || '16')]}
min={12}
max={24}
step={0.5}
onValueChange={(vals) => {
updateThemeConfig({
tokenOverrides: {
...themeConfig.tokenOverrides,
layout: {
...themeConfig.tokenOverrides?.layout,
'sidebar-width': `${vals[0]}rem`
}
}
})
}}
/>
</div>
<div className="space-y-4">
<div className="flex justify-between">
<Label> (Max Width)</Label>
<span className="text-sm text-muted-foreground">
{(themeConfig.tokenOverrides?.layout as any)?.['max-content-width'] || '1280px'}
</span>
</div>
<Slider
defaultValue={[1280]}
value={[parseFloat(((themeConfig.tokenOverrides?.layout as any)?.['max-content-width'] || '1280').replace('px', ''))]}
min={960}
max={1600}
step={10}
onValueChange={(vals) => {
updateThemeConfig({
tokenOverrides: {
...themeConfig.tokenOverrides,
layout: {
...themeConfig.tokenOverrides?.layout,
'max-content-width': `${vals[0]}px`
}
}
})
}}
/>
</div>
<div className="space-y-4">
<div className="flex justify-between">
<Label> (Spacing Unit)</Label>
<span className="text-sm text-muted-foreground">
{(themeConfig.tokenOverrides?.layout as any)?.['space-unit'] || '0.25rem'}
</span>
</div>
<Slider
defaultValue={[0.25]}
value={[parseFloat(((themeConfig.tokenOverrides?.layout as any)?.['space-unit'] || '0.25').replace('rem', ''))]}
min={0.2}
max={0.4}
step={0.01}
onValueChange={(vals) => {
updateThemeConfig({
tokenOverrides: {
...themeConfig.tokenOverrides,
layout: {
...themeConfig.tokenOverrides?.layout,
'space-unit': `${vals[0]}rem`
}
}
})
}}
/>
</div>
</div>
</AccordionContent>
</AccordionItem>
{/* 4. 动画 (Animation) */}
<AccordionItem value="animation">
<AccordionTrigger> (Animation)</AccordionTrigger>
<AccordionContent>
<div className="space-y-4 pt-2">
<div className="flex justify-end">
<Button
variant="ghost"
size="sm"
onClick={() => {
const newOverrides = { ...themeConfig.tokenOverrides }
delete newOverrides.animation
updateThemeConfig({ tokenOverrides: newOverrides })
}}
disabled={!themeConfig.tokenOverrides?.animation}
className="h-8 text-xs"
>
<RotateCcw className="mr-2 h-3.5 w-3.5" />
</Button>
</div>
<div className="space-y-2">
<Label> (Speed)</Label>
<Select
value={(themeConfig.tokenOverrides?.animation as any)?.['anim-duration-normal'] === '100ms' ? 'fast' :
(themeConfig.tokenOverrides?.animation as any)?.['anim-duration-normal'] === '500ms' ? 'slow' :
(themeConfig.tokenOverrides?.animation as any)?.['anim-duration-normal'] === '0ms' ? 'off' : 'normal'}
onValueChange={(val) => {
let duration = '300ms'
if (val === 'fast') duration = '100ms'
else if (val === 'slow') duration = '500ms'
else if (val === 'off') duration = '0ms'
// 如果用户选了关闭,我们也应该同步更新 enableAnimations 开关
if (val === 'off' && enableAnimations) {
setEnableAnimations(false)
} else if (val !== 'off' && !enableAnimations) {
setEnableAnimations(true)
}
updateThemeConfig({
tokenOverrides: {
...themeConfig.tokenOverrides,
animation: {
...themeConfig.tokenOverrides?.animation,
'anim-duration-normal': duration
}
}
})
}}
>
<SelectTrigger>
<SelectValue placeholder="选择动画速度" />
</SelectTrigger>
<SelectContent>
<SelectItem value="fast"> (100ms)</SelectItem>
<SelectItem value="normal"> (300ms)</SelectItem>
<SelectItem value="slow"> (500ms)</SelectItem>
<SelectItem value="off"> (0ms)</SelectItem>
</SelectContent>
</Select>
</div>
</div>
</AccordionContent>
</AccordionItem>
</Accordion>
</div>
<div>
<div className="flex items-center justify-between mb-3 sm:mb-4">
<div>
<h3 className="text-base sm:text-lg font-semibold"> CSS</h3>
<p className="text-sm text-muted-foreground mt-1">
CSS CSS @importurl()
</p>
</div>
<Button
variant="outline"
size="sm"
onClick={() => {
updateThemeConfig({ customCSS: '' })
setCssWarnings([])
}}
disabled={!themeConfig.customCSS}
>
<Trash2 className="h-4 w-4 mr-1" />
</Button>
</div>
<div className="rounded-lg border bg-card p-3 sm:p-4 space-y-3">
<CodeEditor
value={localCSS}
language="css"
height="250px"
placeholder={`/* 在这里输入自定义 CSS */\n\n/* 例如: */\n/* .sidebar { background: #1a1a2e; } */`}
onChange={handleCSSChange}
/>
{cssWarnings.length > 0 && (
<div className="rounded-md bg-yellow-50 dark:bg-yellow-950/30 border border-yellow-200 dark:border-yellow-800 p-3">
<div className="flex items-center gap-2 text-yellow-800 dark:text-yellow-200 text-sm font-medium mb-1">
<AlertTriangle className="h-4 w-4" />
</div>
<ul className="text-xs text-yellow-700 dark:text-yellow-300 space-y-0.5 ml-6 list-disc">
{cssWarnings.map((w, i) => <li key={i}>{w}</li>)}
</ul>
</div>
)}
</div>
</div>
{/* 动效设置 */}
<div>
<h3 className="text-base sm:text-lg font-semibold mb-3 sm:mb-4"></h3>
<div className="space-y-2 sm:space-y-3">
{/* 全局动画开关 */}
<div className="rounded-lg border bg-card p-3 sm:p-4">
<div className="flex items-center justify-between">
<div className="space-y-0.5 flex-1">
<Label htmlFor="animations" className="text-base font-medium cursor-pointer">
</Label>
<p className="text-sm text-muted-foreground">
</p>
</div>
<Switch
id="animations"
checked={enableAnimations}
onCheckedChange={setEnableAnimations}
/>
</div>
</div>
{/* 波浪背景开关 */}
<div className="rounded-lg border bg-card p-4">
<div className="flex items-center justify-between">
<div className="space-y-0.5 flex-1">
<Label htmlFor="waves-background" className="text-base font-medium cursor-pointer">
</Label>
<p className="text-sm text-muted-foreground">
使
</p>
</div>
<Switch
id="waves-background"
checked={enableWavesBackground}
onCheckedChange={setEnableWavesBackground}
/>
</div>
</div>
</div>
</div>
{/* 主题导入/导出 */}
<div>
<h3 className="text-base sm:text-lg font-semibold mb-3 sm:mb-4">/</h3>
<div className="rounded-lg border bg-card p-3 sm:p-4 space-y-3">
<div className="grid grid-cols-1 sm:grid-cols-3 gap-2">
{/* 导出按钮 */}
<Button
onClick={handleExport}
variant="outline"
className="gap-2"
>
<Download className="h-4 w-4" />
</Button>
{/* 导入按钮 */}
<Button
onClick={() => fileInputRef.current?.click()}
variant="outline"
className="gap-2"
>
<Upload className="h-4 w-4" />
</Button>
{/* 重置按钮 */}
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
variant="outline"
className="gap-2"
>
<RotateCcw className="h-4 w-4" />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle></AlertDialogTitle>
<AlertDialogDescription>
CSS
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel>
<AlertDialogAction onClick={handleResetTheme}>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
{/* 隐藏的文件输入 */}
<input
ref={fileInputRef}
type="file"
accept=".json"
onChange={handleImport}
className="hidden"
/>
<p className="text-xs text-muted-foreground">
JSON 便
</p>
</div>
</div>
</div>
)
}
function ColorTokenPreview({ name, value, foreground, border }: { name: string, value: string, foreground?: string, border?: boolean }) {
return (
<div className="flex flex-col gap-1.5">
<div
className={cn("h-16 rounded-md shadow-sm flex items-center justify-center text-xs font-medium", border && "border border-border")}
style={{ backgroundColor: `hsl(${value})`, color: foreground ? `hsl(${foreground})` : undefined }}
>
Aa
</div>
<div className="text-xs text-muted-foreground text-center truncate" title={name}>
{name}
</div>
</div>
)
}
// 安全设置标签页
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 (
<div className="space-y-4 sm:space-y-6">
{/* Token 生成成功弹窗 */}
<Dialog open={showTokenDialog} onOpenChange={handleDialogOpenChange}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<AlertTriangle className="h-5 w-5 text-yellow-500" />
Access Token
</DialogTitle>
<DialogDescription>
Token
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
{/* Token 显示区域 */}
<div className="rounded-lg border-2 border-primary/20 bg-primary/5 p-4">
<Label className="text-xs text-muted-foreground mb-2 block">
Token (64)
</Label>
<div className="font-mono text-sm break-all select-all bg-background p-3 rounded border">
{generatedToken}
</div>
</div>
{/* 警告提示 */}
<div className="rounded-lg border border-yellow-200 dark:border-yellow-900 bg-yellow-50 dark:bg-yellow-950/30 p-3">
<div className="flex gap-2">
<AlertTriangle className="h-4 w-4 text-yellow-600 dark:text-yellow-500 flex-shrink-0 mt-0.5" />
<div className="text-sm text-yellow-800 dark:text-yellow-300 space-y-1">
<p className="font-semibold"></p>
<ul className="list-disc list-inside space-y-0.5 text-xs">
<li> Token </li>
<li></li>
<li></li>
<li>使 Token </li>
</ul>
</div>
</div>
</div>
</div>
<DialogFooter className="gap-2 sm:gap-0">
<Button
variant="outline"
onClick={copyGeneratedToken}
className="gap-2"
>
{tokenCopied ? (
<>
<Check className="h-4 w-4 text-green-500" />
</>
) : (
<>
<Copy className="h-4 w-4" />
Token
</>
)}
</Button>
<Button onClick={handleCloseDialog}>
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* 当前 Token */}
<div className="rounded-lg border bg-card p-4 sm:p-6">
<h3 className="text-base sm:text-lg font-semibold mb-3 sm:mb-4"> Access Token</h3>
<div className="space-y-3 sm:space-y-4">
<div className="space-y-2">
<Label htmlFor="current-token" className="text-sm">访</Label>
<div className="flex flex-col sm:flex-row gap-2">
<div className="relative flex-1">
<Input
id="current-token"
type={showCurrentToken ? 'text' : 'password'}
value={currentToken || '••••••••••••••••••••••••••••••••'}
readOnly
className="pr-10 font-mono text-sm"
placeholder="Token 存储在安全 Cookie 中"
/>
<button
onClick={() => {
if (currentToken) {
setShowCurrentToken(!showCurrentToken)
} else {
toast({
title: '无法查看',
description: 'Token 存储在安全 Cookie 中,如需新 Token 请点击"重新生成"',
})
}
}}
className="absolute right-2 top-1/2 -translate-y-1/2 p-1.5 hover:bg-accent rounded"
title={showCurrentToken ? '隐藏' : '显示'}
>
{showCurrentToken ? (
<EyeOff className="h-4 w-4 text-muted-foreground" />
) : (
<Eye className="h-4 w-4 text-muted-foreground" />
)}
</button>
</div>
<div className="flex gap-2 w-full sm:w-auto">
<Button
variant="outline"
size="icon"
onClick={() => copyToClipboard(currentToken)}
title="复制到剪贴板"
className="flex-shrink-0"
disabled={!currentToken}
>
{copied ? (
<Check className="h-4 w-4 text-green-500" />
) : (
<Copy className="h-4 w-4" />
)}
</Button>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
variant="outline"
disabled={isRegenerating}
className="gap-2 flex-1 sm:flex-none"
>
<RefreshCw className={cn('h-4 w-4', isRegenerating && 'animate-spin')} />
<span className="hidden sm:inline"></span>
<span className="sm:hidden"></span>
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle> Token</AlertDialogTitle>
<AlertDialogDescription>
64 使 Token
使 Token
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel>
<AlertDialogAction onClick={executeRegenerateToken}>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
</div>
<p className="text-[10px] sm:text-xs text-muted-foreground">
Access Token
</p>
</div>
</div>
</div>
{/* 更新 Token */}
<div className="rounded-lg border bg-card p-4 sm:p-6">
<h3 className="text-base sm:text-lg font-semibold mb-3 sm:mb-4"> Access Token</h3>
<div className="space-y-3 sm:space-y-4">
<div className="space-y-2">
<Label htmlFor="new-token" className="text-sm">访</Label>
<div className="relative">
<Input
id="new-token"
type={showNewToken ? 'text' : 'password'}
value={newToken}
onChange={(e) => setNewToken(e.target.value)}
className="pr-10 font-mono text-sm"
placeholder="输入自定义 Token"
/>
<button
onClick={() => setShowNewToken(!showNewToken)}
className="absolute right-2 top-1/2 -translate-y-1/2 p-1.5 hover:bg-accent rounded"
title={showNewToken ? '隐藏' : '显示'}
>
{showNewToken ? (
<EyeOff className="h-4 w-4 text-muted-foreground" />
) : (
<Eye className="h-4 w-4 text-muted-foreground" />
)}
</button>
</div>
{/* Token 验证规则显示 */}
{newToken && (
<div className="mt-3 space-y-2 p-3 rounded-lg bg-muted/50">
<p className="text-sm font-medium text-foreground">Token :</p>
<div className="space-y-1.5">
{tokenValidation.rules.map((rule) => (
<div key={rule.id} className="flex items-center gap-2 text-sm">
{rule.passed ? (
<CheckCircle2 className="h-4 w-4 text-green-500 flex-shrink-0" />
) : (
<XCircle className="h-4 w-4 text-muted-foreground flex-shrink-0" />
)}
<span className={cn(
rule.passed ? 'text-green-600 dark:text-green-400' : 'text-muted-foreground'
)}>
{rule.label}
</span>
</div>
))}
</div>
{tokenValidation.isValid && (
<div className="mt-2 pt-2 border-t border-border">
<div className="flex items-center gap-2 text-sm text-green-600 dark:text-green-400">
<Check className="h-4 w-4" />
<span className="font-medium">Token 使</span>
</div>
</div>
)}
</div>
)}
</div>
<Button
onClick={handleUpdateToken}
disabled={isUpdating || !tokenValidation.isValid || !newToken}
className="w-full sm:w-auto"
>
{isUpdating ? '更新中...' : '更新自定义 Token'}
</Button>
</div>
</div>
{/* 安全提示 */}
<div className="rounded-lg border border-yellow-200 dark:border-yellow-900 bg-yellow-50 dark:bg-yellow-950/30 p-3 sm:p-4">
<h4 className="text-sm sm:text-base font-semibold text-yellow-900 dark:text-yellow-200 mb-2"></h4>
<ul className="text-xs sm:text-sm text-yellow-800 dark:text-yellow-300 space-y-1 list-disc list-inside">
<li> Token 64 </li>
<li> Token 使</li>
<li> Token Token </li>
<li> Token</li>
<li>怀 Token </li>
<li>使 Token </li>
</ul>
</div>
</div>
)
}
// 其他设置标签页
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<HTMLInputElement>(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<HTMLInputElement>) => {
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 (
<div className="space-y-4 sm:space-y-6">
{/* 性能与存储 */}
<div className="rounded-lg border bg-card p-4 sm:p-6">
<h3 className="text-base sm:text-lg font-semibold mb-3 sm:mb-4 flex items-center gap-2">
<Database className="h-5 w-5" />
</h3>
<div className="space-y-4 sm:space-y-5">
{/* 存储使用情况 */}
<div className="rounded-lg bg-muted/50 p-3 sm:p-4">
<div className="flex items-center justify-between mb-2">
<span className="text-sm font-medium flex items-center gap-2">
<HardDrive className="h-4 w-4" />
使
</span>
<Button variant="ghost" size="sm" onClick={refreshStorageUsage} className="h-7 px-2">
<RefreshCw className="h-3 w-3" />
</Button>
</div>
<div className="text-2xl font-bold text-primary">{formatBytes(storageUsage.used)}</div>
<p className="text-xs text-muted-foreground mt-1">{storageUsage.items} </p>
</div>
{/* 日志缓存大小 */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label className="text-sm font-medium"></Label>
<span className="text-sm text-muted-foreground">{logCacheSize} </span>
</div>
<Slider
value={[logCacheSize]}
onValueChange={handleLogCacheSizeChange}
min={100}
max={5000}
step={100}
className="w-full"
/>
<p className="text-xs text-muted-foreground">
</p>
</div>
{/* 数据刷新间隔 */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label className="text-sm font-medium"></Label>
<span className="text-sm text-muted-foreground">{dataSyncInterval} </span>
</div>
<Slider
value={[dataSyncInterval]}
onValueChange={handleDataSyncIntervalChange}
min={10}
max={120}
step={5}
className="w-full"
/>
<p className="text-xs text-muted-foreground">
</p>
</div>
{/* WebSocket 重连间隔 */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label className="text-sm font-medium">WebSocket </Label>
<span className="text-sm text-muted-foreground">{wsReconnectInterval / 1000} </span>
</div>
<Slider
value={[wsReconnectInterval]}
onValueChange={handleWsReconnectIntervalChange}
min={1000}
max={10000}
step={500}
className="w-full"
/>
<p className="text-xs text-muted-foreground">
WebSocket
</p>
</div>
{/* WebSocket 最大重连次数 */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label className="text-sm font-medium">WebSocket </Label>
<span className="text-sm text-muted-foreground">{wsMaxReconnectAttempts} </span>
</div>
<Slider
value={[wsMaxReconnectAttempts]}
onValueChange={handleWsMaxReconnectAttemptsChange}
min={3}
max={30}
step={1}
className="w-full"
/>
<p className="text-xs text-muted-foreground">
</p>
</div>
{/* 清理按钮 */}
<div className="flex flex-wrap gap-2 pt-2">
<Button variant="outline" size="sm" onClick={handleClearLogCache} className="gap-2">
<Trash2 className="h-4 w-4" />
</Button>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="outline" size="sm" className="gap-2">
<Trash2 className="h-4 w-4" />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle></AlertDialogTitle>
<AlertDialogDescription>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel>
<AlertDialogAction onClick={handleClearLocalCache}>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
</div>
</div>
{/* 导入/导出设置 */}
<div className="rounded-lg border bg-card p-4 sm:p-6">
<h3 className="text-base sm:text-lg font-semibold mb-3 sm:mb-4 flex items-center gap-2">
<Download className="h-5 w-5" />
/
</h3>
<div className="space-y-4">
<p className="text-xs sm:text-sm text-muted-foreground">
便
</p>
<div className="flex flex-wrap gap-2">
<Button
variant="outline"
onClick={handleExportSettings}
disabled={isExporting}
className="gap-2"
>
<Download className="h-4 w-4" />
{isExporting ? '导出中...' : '导出设置'}
</Button>
<input
ref={fileInputRef}
type="file"
accept=".json"
onChange={handleImportSettings}
className="hidden"
/>
<Button
variant="outline"
onClick={() => fileInputRef.current?.click()}
disabled={isImporting}
className="gap-2"
>
<Upload className="h-4 w-4" />
{isImporting ? '导入中...' : '导入设置'}
</Button>
</div>
{/* 重置所有设置 */}
<div className="pt-2 border-t">
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="outline" size="sm" className="gap-2 text-destructive hover:text-destructive">
<RotateCcw className="h-4 w-4" />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle></AlertDialogTitle>
<AlertDialogDescription>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel>
<AlertDialogAction onClick={handleResetAllSettings}>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
</div>
</div>
{/* 配置向导 */}
<div className="rounded-lg border bg-card p-4 sm:p-6">
<h3 className="text-base sm:text-lg font-semibold mb-3 sm:mb-4"></h3>
<div className="space-y-3 sm:space-y-4">
<div className="space-y-2">
<p className="text-xs sm:text-sm text-muted-foreground">
</p>
</div>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="outline" disabled={isResetting} className="gap-2">
<RotateCcw className={cn('h-4 w-4', isResetting && 'animate-spin')} />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle></AlertDialogTitle>
<AlertDialogDescription>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel>
<AlertDialogAction onClick={handleResetSetup}>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
</div>
{/* 开发者工具 */}
<div className="rounded-lg border border-dashed border-yellow-500/50 bg-yellow-500/5 p-4 sm:p-6">
<h3 className="text-base sm:text-lg font-semibold mb-3 sm:mb-4 flex items-center gap-2">
<AlertTriangle className="h-5 w-5 text-yellow-500" />
</h3>
<div className="space-y-3 sm:space-y-4">
<div className="space-y-2">
<p className="text-xs sm:text-sm text-muted-foreground">
使
</p>
</div>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="destructive" className="gap-2">
<AlertTriangle className="h-4 w-4" />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle></AlertDialogTitle>
<AlertDialogDescription>
React
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel>
<AlertDialogAction
onClick={() => setShouldThrowError(true)}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
</div>
</div>
)
}
// 关于标签页
function AboutTab() {
return (
<div className="space-y-4 sm:space-y-6">
{/* GitHub 开源地址 */}
<div className="rounded-lg border-2 border-primary/30 bg-gradient-to-r from-primary/5 to-primary/10 p-4 sm:p-6">
<div className="flex items-start gap-3 sm:gap-4">
<div className="flex-shrink-0 rounded-lg bg-primary/10 p-2 sm:p-3">
<svg
className="h-6 w-6 sm:h-8 sm:w-8 text-primary"
fill="currentColor"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path
fillRule="evenodd"
d="M12 2C6.477 2 2 6.484 2 12.017c0 4.425 2.865 8.18 6.839 9.504.5.092.682-.217.682-.483 0-.237-.008-.868-.013-1.703-2.782.605-3.369-1.343-3.369-1.343-.454-1.158-1.11-1.466-1.11-1.466-.908-.62.069-.608.069-.608 1.003.07 1.531 1.032 1.531 1.032.892 1.53 2.341 1.088 2.91.832.092-.647.35-1.088.636-1.338-2.22-.253-4.555-1.113-4.555-4.951 0-1.093.39-1.988 1.029-2.688-.103-.253-.446-1.272.098-2.65 0 0 .84-.27 2.75 1.026A9.564 9.564 0 0112 6.844c.85.004 1.705.115 2.504.337 1.909-1.296 2.747-1.027 2.747-1.027.546 1.379.202 2.398.1 2.651.64.7 1.028 1.595 1.028 2.688 0 3.848-2.339 4.695-4.566 4.943.359.309.678.92.678 1.855 0 1.338-.012 2.419-.012 2.747 0 .268.18.58.688.482A10.019 10.019 0 0022 12.017C22 6.484 17.522 2 12 2z"
clipRule="evenodd"
/>
</svg>
</div>
<div className="flex-1 min-w-0">
<h3 className="text-lg sm:text-xl font-bold text-foreground mb-2">
</h3>
<p className="text-sm sm:text-base text-muted-foreground mb-3">
GitHub Star
</p>
<a
href="https://github.com/Mai-with-u/MaiBot-Dashboard"
target="_blank"
rel="noopener noreferrer"
className={cn(
"inline-flex items-center gap-2 px-4 py-2 rounded-lg",
"bg-primary text-primary-foreground font-medium text-sm",
"hover:bg-primary/90 transition-colors",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
)}
>
<svg
className="h-4 w-4"
fill="currentColor"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path
fillRule="evenodd"
d="M12 2C6.477 2 2 6.484 2 12.017c0 4.425 2.865 8.18 6.839 9.504.5.092.682-.217.682-.483 0-.237-.008-.868-.013-1.703-2.782.605-3.369-1.343-3.369-1.343-.454-1.158-1.11-1.466-1.11-1.466-.908-.62.069-.608.069-.608 1.003.07 1.531 1.032 1.531 1.032.892 1.53 2.341 1.088 2.91.832.092-.647.35-1.088.636-1.338-2.22-.253-4.555-1.113-4.555-4.951 0-1.093.39-1.988 1.029-2.688-.103-.253-.446-1.272.098-2.65 0 0 .84-.27 2.75 1.026A9.564 9.564 0 0112 6.844c.85.004 1.705.115 2.504.337 1.909-1.296 2.747-1.027 2.747-1.027.546 1.379.202 2.398.1 2.651.64.7 1.028 1.595 1.028 2.688 0 3.848-2.339 4.695-4.566 4.943.359.309.678.92.678 1.855 0 1.338-.012 2.419-.012 2.747 0 .268.18.58.688.482A10.019 10.019 0 0022 12.017C22 6.484 17.522 2 12 2z"
clipRule="evenodd"
/>
</svg>
GitHub
<svg
className="h-4 w-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"
/>
</svg>
</a>
</div>
</div>
</div>
{/* 应用信息 */}
<div className="rounded-lg border bg-card p-4 sm:p-6">
<h3 className="text-base sm:text-lg font-semibold mb-3 sm:mb-4"> {APP_NAME}</h3>
<div className="space-y-2 text-xs sm:text-sm text-muted-foreground">
<p>: {APP_VERSION}</p>
<p>MaiBot Web </p>
</div>
</div>
{/* 作者信息 */}
<div className="rounded-lg border bg-card p-4 sm:p-6">
<h3 className="text-base sm:text-lg font-semibold mb-3 sm:mb-4"></h3>
<div className="space-y-3">
<div className="space-y-1">
<p className="text-sm font-medium">MaiBot </p>
<p className="text-xs sm:text-sm text-muted-foreground">Mai-with-u</p>
</div>
<div className="space-y-1">
<p className="text-sm font-medium">WebUI</p>
<p className="text-xs sm:text-sm text-muted-foreground">Mai-with-u <a href="https://github.com/DrSmoothl" target="_blank" rel="noopener noreferrer" className="text-primary underline">@MotricSeven</a></p>
</div>
</div>
</div>
{/* 技术栈 */}
<div className="rounded-lg border bg-card p-4 sm:p-6">
<h3 className="text-base sm:text-lg font-semibold mb-3 sm:mb-4"></h3>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 text-xs sm:text-sm text-muted-foreground">
<div className="space-y-1.5">
<p className="font-medium text-foreground"></p>
<ul className="space-y-0.5 list-disc list-inside">
<li>React 19.2.0</li>
<li>TypeScript 5.7.2</li>
<li>Vite 6.0.7</li>
<li>TanStack Router 1.94.2</li>
</ul>
</div>
<div className="space-y-1.5">
<p className="font-medium text-foreground">UI </p>
<ul className="space-y-0.5 list-disc list-inside">
<li>shadcn/ui</li>
<li>Radix UI</li>
<li>Tailwind CSS 3.4.17</li>
<li>Lucide Icons</li>
</ul>
</div>
<div className="space-y-1.5">
<p className="font-medium text-foreground"></p>
<ul className="space-y-0.5 list-disc list-inside">
<li>Python 3.12+</li>
<li>FastAPI</li>
<li>Uvicorn</li>
<li>WebSocket</li>
</ul>
</div>
<div className="space-y-1.5">
<p className="font-medium text-foreground"></p>
<ul className="space-y-0.5 list-disc list-inside">
<li>Bun / npm</li>
<li>ESLint 9.17.0</li>
<li>PostCSS</li>
</ul>
</div>
</div>
</div>
{/* 开源感谢 */}
<div className="rounded-lg border bg-card p-4 sm:p-6">
<h3 className="text-base sm:text-lg font-semibold mb-3 sm:mb-4"></h3>
<p className="text-xs sm:text-sm text-muted-foreground mb-3">
使
</p>
<ScrollArea className="h-[300px] sm:h-[400px]">
<div className="space-y-4 pr-4">
{/* UI 框架 */}
<div className="space-y-2">
<p className="text-sm font-medium text-foreground">UI </p>
<div className="grid gap-2 text-xs sm:text-sm">
<LibraryItem name="React" description="用户界面构建库" license="MIT" />
<LibraryItem name="shadcn/ui" description="优雅的 React 组件库" license="MIT" />
<LibraryItem name="Radix UI" description="无样式的可访问组件库" license="MIT" />
<LibraryItem name="Tailwind CSS" description="实用优先的 CSS 框架" license="MIT" />
<LibraryItem name="Lucide React" description="精美的图标库" license="ISC" />
</div>
</div>
{/* 路由与状态 */}
<div className="space-y-2">
<p className="text-sm font-medium text-foreground"></p>
<div className="grid gap-2 text-xs sm:text-sm">
<LibraryItem name="TanStack Router" description="类型安全的路由库" license="MIT" />
<LibraryItem name="Zustand" description="轻量级状态管理" license="MIT" />
</div>
</div>
{/* 表单与验证 */}
<div className="space-y-2">
<p className="text-sm font-medium text-foreground"></p>
<div className="grid gap-2 text-xs sm:text-sm">
<LibraryItem name="React Hook Form" description="高性能表单库" license="MIT" />
<LibraryItem name="Zod" description="TypeScript 优先的 schema 验证" license="MIT" />
</div>
</div>
{/* 工具库 */}
<div className="space-y-2">
<p className="text-sm font-medium text-foreground"></p>
<div className="grid gap-2 text-xs sm:text-sm">
<LibraryItem name="clsx" description="条件 className 构建工具" license="MIT" />
<LibraryItem name="tailwind-merge" description="Tailwind 类名合并工具" license="MIT" />
<LibraryItem name="class-variance-authority" description="组件变体管理" license="Apache-2.0" />
<LibraryItem name="date-fns" description="现代化日期处理库" license="MIT" />
</div>
</div>
{/* 动画 */}
<div className="space-y-2">
<p className="text-sm font-medium text-foreground"></p>
<div className="grid gap-2 text-xs sm:text-sm">
<LibraryItem name="Framer Motion" description="React 动画库" license="MIT" />
<LibraryItem name="vaul" description="抽屉组件动画" license="MIT" />
</div>
</div>
{/* 后端相关 */}
<div className="space-y-2">
<p className="text-sm font-medium text-foreground"></p>
<div className="grid gap-2 text-xs sm:text-sm">
<LibraryItem name="FastAPI" description="现代化 Python Web 框架" license="MIT" />
<LibraryItem name="Uvicorn" description="ASGI 服务器" license="BSD-3-Clause" />
<LibraryItem name="Pydantic" description="数据验证库" license="MIT" />
<LibraryItem name="python-multipart" description="文件上传支持" license="Apache-2.0" />
</div>
</div>
{/* 开发工具 */}
<div className="space-y-2">
<p className="text-sm font-medium text-foreground"></p>
<div className="grid gap-2 text-xs sm:text-sm">
<LibraryItem name="TypeScript" description="JavaScript 的超集" license="Apache-2.0" />
<LibraryItem name="Vite" description="下一代前端构建工具" license="MIT" />
<LibraryItem name="ESLint" description="JavaScript 代码检查工具" license="MIT" />
<LibraryItem name="PostCSS" description="CSS 转换工具" license="MIT" />
</div>
</div>
</div>
</ScrollArea>
</div>
{/* 许可证 */}
<div className="rounded-lg border bg-card p-4 sm:p-6">
<h3 className="text-base sm:text-lg font-semibold mb-3 sm:mb-4"></h3>
<div className="space-y-3">
<div className="rounded-lg bg-primary/5 border border-primary/20 p-3 sm:p-4">
<div className="flex items-start gap-2 sm:gap-3">
<div className="flex-shrink-0 mt-0.5">
<div className="rounded-md bg-primary/10 px-2 py-1">
<span className="text-xs sm:text-sm font-bold text-primary">GPLv3</span>
</div>
</div>
<div className="flex-1 min-w-0">
<p className="text-sm sm:text-base font-semibold text-foreground mb-1">
MaiBot WebUI
</p>
<p className="text-xs sm:text-sm text-muted-foreground">
GNU General Public License v3.0
使
</p>
</div>
</div>
</div>
<p className="text-xs sm:text-sm text-muted-foreground">
MITApache-2.0BSD
</p>
</div>
</div>
</div>
)
}
// 库信息组件
type LibraryItemProps = {
name: string
description: string
license: string
}
function LibraryItem({ name, description, license }: LibraryItemProps) {
return (
<div className="flex items-start justify-between gap-2 rounded-lg border bg-muted/30 p-2.5 sm:p-3">
<div className="flex-1 min-w-0">
<p className="font-medium text-foreground truncate">{name}</p>
<p className="text-muted-foreground text-xs mt-0.5">{description}</p>
</div>
<span className="inline-flex items-center rounded-full bg-primary/10 px-2 py-0.5 text-[10px] font-medium text-primary flex-shrink-0">
{license}
</span>
</div>
)
}
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 (
<button
onClick={() => onChange(value)}
className={cn(
'relative rounded-lg border-2 p-3 sm:p-4 text-left transition-all',
'hover:border-primary/50 hover:bg-accent/50',
isSelected ? 'border-primary bg-accent' : 'border-border'
)}
>
{/* 选中指示器 */}
{isSelected && (
<div className="absolute top-2 right-2 sm:top-3 sm:right-3 h-2 w-2 rounded-full bg-primary" />
)}
<div className="space-y-1">
<div className="text-sm sm:text-base font-medium">{label}</div>
<div className="text-[10px] sm:text-xs text-muted-foreground">{description}</div>
</div>
{/* 主题预览 */}
<div className="mt-2 sm:mt-3 flex gap-1">
{value === 'light' && (
<>
<div className="h-2 w-2 rounded-full bg-slate-200" />
<div className="h-2 w-2 rounded-full bg-slate-300" />
<div className="h-2 w-2 rounded-full bg-slate-400" />
</>
)}
{value === 'dark' && (
<>
<div className="h-2 w-2 rounded-full bg-slate-700" />
<div className="h-2 w-2 rounded-full bg-slate-800" />
<div className="h-2 w-2 rounded-full bg-slate-900" />
</>
)}
{value === 'system' && (
<>
<div className="h-2 w-2 rounded-full bg-gradient-to-r from-slate-200 to-slate-700" />
<div className="h-2 w-2 rounded-full bg-gradient-to-r from-slate-300 to-slate-800" />
<div className="h-2 w-2 rounded-full bg-gradient-to-r from-slate-400 to-slate-900" />
</>
)}
</div>
</button>
)
}
type ColorPresetOptionProps = {
value: string
current: string
onChange: (color: string) => void
label: string
colorClass: string
}
function ColorPresetOption({ value, current, onChange, label, colorClass }: ColorPresetOptionProps) {
const isSelected = current === value
return (
<button
onClick={() => onChange(value)}
className={cn(
'relative rounded-lg border-2 p-2 sm:p-3 text-left transition-all',
'hover:border-primary/50 hover:bg-accent/50',
isSelected ? 'border-primary bg-accent' : 'border-border'
)}
>
{/* 选中指示器 */}
{isSelected && (
<div className="absolute top-1.5 right-1.5 sm:top-2 sm:right-2 h-1.5 w-1.5 sm:h-2 sm:w-2 rounded-full bg-primary" />
)}
<div className="flex flex-col items-center gap-1.5 sm:gap-2">
<div className={cn('h-8 w-8 sm:h-10 sm:w-10 rounded-full', colorClass)} />
<div className="text-[10px] sm:text-xs font-medium text-center">{label}</div>
</div>
</button>
)
}