From bddc6087cdf23c0e5502c6cb962534f0181fa353 Mon Sep 17 00:00:00 2001
From: DrSmoothl <1787882683@qq.com>
Date: Sun, 1 Mar 2026 19:03:09 +0800
Subject: [PATCH] refactor(T11): split settings.tsx into settings/ directory
---
dashboard/src/routes/settings.tsx | 2227 -----------------
dashboard/src/routes/settings/AboutTab.tsx | 256 ++
.../src/routes/settings/AppearanceTab.tsx | 840 +++++++
dashboard/src/routes/settings/LibraryItem.tsx | 15 +
dashboard/src/routes/settings/OtherTab.tsx | 513 ++++
dashboard/src/routes/settings/SecurityTab.tsx | 486 ++++
dashboard/src/routes/settings/ThemeOption.tsx | 51 +
dashboard/src/routes/settings/index.tsx | 63 +
dashboard/src/routes/settings/types.ts | 51 +
9 files changed, 2275 insertions(+), 2227 deletions(-)
delete mode 100644 dashboard/src/routes/settings.tsx
create mode 100644 dashboard/src/routes/settings/AboutTab.tsx
create mode 100644 dashboard/src/routes/settings/AppearanceTab.tsx
create mode 100644 dashboard/src/routes/settings/LibraryItem.tsx
create mode 100644 dashboard/src/routes/settings/OtherTab.tsx
create mode 100644 dashboard/src/routes/settings/SecurityTab.tsx
create mode 100644 dashboard/src/routes/settings/ThemeOption.tsx
create mode 100644 dashboard/src/routes/settings/index.tsx
create mode 100644 dashboard/src/routes/settings/types.ts
diff --git a/dashboard/src/routes/settings.tsx b/dashboard/src/routes/settings.tsx
deleted file mode 100644
index 159ffb43..00000000
--- a/dashboard/src/routes/settings.tsx
+++ /dev/null
@@ -1,2227 +0,0 @@
-import { Palette, Info, Shield, Eye, EyeOff, Copy, RefreshCw, Check, CheckCircle2, XCircle, AlertTriangle, Settings, RotateCcw, Database, Download, Upload, Trash2, HardDrive } from 'lucide-react'
-import { useTheme } from '@/components/use-theme'
-import { useAnimation } from '@/hooks/use-animation'
-import { useState, useMemo, useRef, useCallback } from 'react'
-import { useNavigate } from '@tanstack/react-router'
-import { cn } from '@/lib/utils'
-import { fetchWithAuth } from '@/lib/fetch-with-auth'
-import { Switch } from '@/components/ui/switch'
-import { Label } from '@/components/ui/label'
-import { Input } from '@/components/ui/input'
-import { Button } from '@/components/ui/button'
-import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
-import { ScrollArea } from '@/components/ui/scroll-area'
-import { useToast } from '@/hooks/use-toast'
-import { validateToken } from '@/lib/token-validator'
-import { APP_VERSION, APP_NAME } from '@/lib/version'
-import {
- getSetting,
- setSetting,
- exportSettings,
- importSettings,
- resetAllSettings,
- clearLocalCache,
- getStorageUsage,
- formatBytes,
- DEFAULT_SETTINGS,
-} from '@/lib/settings-manager'
-import { Slider } from '@/components/ui/slider'
-import { logWebSocket } from '@/lib/log-websocket'
-import {
- Dialog,
- DialogContent,
- DialogDescription,
- DialogFooter,
- DialogHeader,
- DialogTitle,
-} from '@/components/ui/dialog'
-import {
- AlertDialog,
- AlertDialogAction,
- AlertDialogCancel,
- AlertDialogContent,
- AlertDialogDescription,
- AlertDialogFooter,
- AlertDialogHeader,
- AlertDialogTitle,
- AlertDialogTrigger,
-} from '@/components/ui/alert-dialog'
-
-import { getComputedTokens } from '@/lib/theme/pipeline'
-import { hexToHSL } from '@/lib/theme/palette'
-import { defaultBackgroundConfig, defaultBackgroundEffects, defaultLightTokens } from '@/lib/theme/tokens'
-import { exportThemeJSON, importThemeJSON } from '@/lib/theme/storage'
-import type { BackgroundConfigMap, BackgroundEffects, ThemeTokens } from '@/lib/theme/tokens'
-import {
- Accordion,
- AccordionContent,
- AccordionItem,
- AccordionTrigger,
-} from '@/components/ui/accordion'
-import { CodeEditor } from '@/components/CodeEditor'
-import { BackgroundEffectsControls } from '@/components/background-effects-controls'
-import { BackgroundUploader } from '@/components/background-uploader'
-import { ComponentCSSEditor } from '@/components/component-css-editor'
-import { sanitizeCSS } from '@/lib/theme/sanitizer'
-import {
- Select,
- SelectContent,
- SelectItem,
- SelectTrigger,
- SelectValue,
-} from '@/components/ui/select'
-
-export function SettingsPage() {
- return (
-
- {/* 页面标题 */}
-
-
- {/* 标签页 */}
-
-
-
-
- 外观
-
-
-
- 安全
-
-
-
- 其他
-
-
-
- 关于
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- )
-}
-
-// 辅助函数:将 HSL 字符串转换为 HEX
-function hslToHex(hsl: string): string {
- if (!hsl) return '#000000'
-
- // 解析 "221.2 83.2% 53.3%" 格式
- const parts = hsl.split(' ').filter(Boolean)
- if (parts.length < 3) return '#000000'
-
- const h = parseFloat(parts[0])
- const s = parseFloat(parts[1].replace('%', ''))
- const l = parseFloat(parts[2].replace('%', ''))
-
- const sDecimal = s / 100
- const lDecimal = l / 100
-
- const c = (1 - Math.abs(2 * lDecimal - 1)) * sDecimal
- const x = c * (1 - Math.abs(((h / 60) % 2) - 1))
- const m = lDecimal - c / 2
-
- let r = 0, g = 0, b = 0
-
- if (h >= 0 && h < 60) { r = c; g = x; b = 0 }
- else if (h >= 60 && h < 120) { r = x; g = c; b = 0 }
- else if (h >= 120 && h < 180) { r = 0; g = c; b = x }
- else if (h >= 180 && h < 240) { r = 0; g = x; b = c }
- else if (h >= 240 && h < 300) { r = x; g = 0; b = c }
- else if (h >= 300 && h < 360) { r = c; g = 0; b = x }
-
- const toHex = (n: number) => {
- const hex = Math.round((n + m) * 255).toString(16)
- return hex.length === 1 ? '0' + hex : hex
- }
-
- return `#${toHex(r)}${toHex(g)}${toHex(b)}`
-}
-
-// 外观设置标签页
-function AppearanceTab() {
- const { theme, setTheme, themeConfig, updateThemeConfig, resolvedTheme, resetTheme } = useTheme()
- const { enableAnimations, setEnableAnimations, enableWavesBackground, setEnableWavesBackground } = useAnimation()
- const { toast } = useToast()
-
- const [localCSS, setLocalCSS] = useState(themeConfig.customCSS || '')
- const [cssWarnings, setCssWarnings] = useState([])
- const cssDebounceRef = useRef | null>(null)
-const bgDebounceRef = useRef | null>(null)
- const fileInputRef = useRef(null)
-
- const updateTokenSection = useCallback(
- (section: K, partial: Partial) => {
- updateThemeConfig({
- tokenOverrides: {
- ...themeConfig.tokenOverrides,
- [section]: {
- ...defaultLightTokens[section],
- ...themeConfig.tokenOverrides?.[section],
- ...partial,
- } as ThemeTokens[K],
- },
- })
- },
- [themeConfig.tokenOverrides, updateThemeConfig]
- )
-
- const resetTokenSection = useCallback(
- (section: keyof ThemeTokens) => {
- const newOverrides: Partial = { ...themeConfig.tokenOverrides }
- delete newOverrides[section]
- updateThemeConfig({ tokenOverrides: newOverrides })
- },
- [themeConfig.tokenOverrides, updateThemeConfig]
- )
-
- const handleCSSChange = useCallback((val: string) => {
- setLocalCSS(val)
- const result = sanitizeCSS(val)
- setCssWarnings(result.warnings)
-
- if (cssDebounceRef.current) clearTimeout(cssDebounceRef.current)
- cssDebounceRef.current = setTimeout(() => {
- updateThemeConfig({ customCSS: val })
- }, 500)
- }, [updateThemeConfig])
-
- const currentAccentHex = useMemo(() => {
- if (themeConfig.accentColor) {
- return hslToHex(themeConfig.accentColor)
- }
- return '#3b82f6' // 默认蓝色
- }, [themeConfig.accentColor])
-
- const handleAccentColorChange = (e: React.ChangeEvent) => {
- const hex = e.target.value
- const hsl = hexToHSL(hex)
- updateThemeConfig({ accentColor: hsl })
- }
-
- const handleResetAccent = () => {
- updateThemeConfig({ accentColor: '' })
- }
-
- const handleExport = () => {
- const json = exportThemeJSON()
- const blob = new Blob([json], { type: 'application/json' })
- const url = URL.createObjectURL(blob)
- const a = document.createElement('a')
- a.href = url
- a.download = `maibot-theme-${Date.now()}.json`
- a.click()
- URL.revokeObjectURL(url)
- }
-
- const handleImport = (e: React.ChangeEvent) => {
- const file = e.target.files?.[0]
- if (!file) return
- const reader = new FileReader()
- reader.onload = (ev) => {
- const json = ev.target?.result as string
- const result = importThemeJSON(json)
- if (result.success) {
- // 导入成功后需要刷新页面使配置生效(因为 ThemeProvider 需要重新读取 localStorage)
- toast({ title: '导入成功', description: '主题配置已导入,页面将自动刷新' })
- setTimeout(() => window.location.reload(), 1000)
- } else {
- toast({ title: '导入失败', description: result.errors.join('; '), variant: 'destructive' })
- }
- }
- reader.readAsText(file)
- // 重置 input,允许重复选择同一文件
- e.target.value = ''
- }
-
- const handleResetTheme = () => {
- resetTheme()
- setLocalCSS('')
- setCssWarnings([])
- toast({ title: '重置成功', description: '主题已重置为默认值' })
- }
-
- const previewTokens = useMemo(() => {
- return getComputedTokens(themeConfig, resolvedTheme === 'dark').color
- }, [themeConfig, resolvedTheme])
-
- const bgConfig: BackgroundConfigMap = themeConfig.backgroundConfig ?? {}
-
- const handleBgAssetChange = (layerId: keyof BackgroundConfigMap, assetId: string | undefined) => {
- const current = bgConfig[layerId] ?? defaultBackgroundConfig
- const newMap: BackgroundConfigMap = {
- ...bgConfig,
- [layerId]: { ...current, assetId, type: assetId ? 'image' : 'none' },
- }
- if (bgDebounceRef.current) clearTimeout(bgDebounceRef.current)
- bgDebounceRef.current = setTimeout(() => updateThemeConfig({ backgroundConfig: newMap }), 500)
- }
-
- const handleBgEffectsChange = (layerId: keyof BackgroundConfigMap, effects: BackgroundEffects) => {
- const current = bgConfig[layerId] ?? defaultBackgroundConfig
- const newMap: BackgroundConfigMap = { ...bgConfig, [layerId]: { ...current, effects } }
- if (bgDebounceRef.current) clearTimeout(bgDebounceRef.current)
- bgDebounceRef.current = setTimeout(() => updateThemeConfig({ backgroundConfig: newMap }), 500)
- }
-
- const handleBgCSSChange = (layerId: keyof BackgroundConfigMap, css: string) => {
- const current = bgConfig[layerId] ?? defaultBackgroundConfig
- const newMap: BackgroundConfigMap = { ...bgConfig, [layerId]: { ...current, customCSS: css } }
- if (bgDebounceRef.current) clearTimeout(bgDebounceRef.current)
- bgDebounceRef.current = setTimeout(() => updateThemeConfig({ backgroundConfig: newMap }), 500)
- }
-
- const handleBgInheritChange = (layerId: keyof BackgroundConfigMap, inherit: boolean) => {
- const current = bgConfig[layerId] ?? defaultBackgroundConfig
- const newMap: BackgroundConfigMap = { ...bgConfig, [layerId]: { ...current, inherit } }
- if (bgDebounceRef.current) clearTimeout(bgDebounceRef.current)
- bgDebounceRef.current = setTimeout(() => updateThemeConfig({ backgroundConfig: newMap }), 500)
- }
-
- return (
-
- {/* 主题模式 */}
-
-
- {/* 主题色配置 */}
-
-
-
主题色
-
-
-
-
- {/* 颜色选择器 */}
-
-
-
-
-
-
-
-
点击色环选择或输入 HEX 值
-
-
-
-
-
-
-
-
- {/* 实时色板预览 */}
-
-
实时色板预览
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- {/* 样式微调 */}
-
-
界面样式微调
-
-
-
- {/* 1. 字体排版 (Typography) */}
-
- 字体排版 (Typography)
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- {parseFloat((themeConfig.tokenOverrides?.typography as any)?.['font-size-base'] || '1') * 16}px
-
-
-
{
- updateTokenSection('typography', {
- 'font-size-base': `${vals[0] / 16}rem`,
- })
- }}
- />
-
-
-
-
-
-
-
-
-
-
- {/* 2. 视觉效果 (Visual) */}
-
- 视觉效果 (Visual)
-
-
-
-
-
-
-
-
-
-
- {Math.round(parseFloat((themeConfig.tokenOverrides?.visual as any)?.['radius-md'] || '0.375') * 16)}px
-
-
-
{
- updateTokenSection('visual', {
- 'radius-md': `${vals[0] / 16}rem`,
- })
- }}
- />
-
-
-
-
-
-
-
-
-
- {
- updateTokenSection('visual', {
- 'blur-md': checked ? defaultLightTokens.visual['blur-md'] : '0px',
- })
- }}
- />
-
-
-
-
-
- {/* 3. 布局 (Layout) */}
-
- 布局 (Layout)
-
-
-
-
-
-
-
-
-
-
- {(themeConfig.tokenOverrides?.layout as any)?.['sidebar-width'] || '16rem'}
-
-
-
{
- updateTokenSection('layout', {
- 'sidebar-width': `${vals[0]}rem`,
- })
- }}
- />
-
-
-
-
-
-
- {(themeConfig.tokenOverrides?.layout as any)?.['max-content-width'] || '1280px'}
-
-
-
{
- updateTokenSection('layout', {
- 'max-content-width': `${vals[0]}px`,
- })
- }}
- />
-
-
-
-
-
-
- {(themeConfig.tokenOverrides?.layout as any)?.['space-unit'] || '0.25rem'}
-
-
-
{
- updateTokenSection('layout', {
- 'space-unit': `${vals[0]}rem`,
- })
- }}
- />
-
-
-
-
-
- {/* 4. 动画 (Animation) */}
-
- 动画 (Animation)
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- {/* 5. 背景设置 (Backgrounds) */}
-
- 背景设置 (Backgrounds)
-
-
-
-
- 页面
- 侧边栏
- Header
- Card
- Dialog
-
-
- {(['page', 'sidebar', 'header', 'card', 'dialog'] as const).map((layerId) => (
-
- {layerId !== 'page' && (
-
-
-
-
开启后将使用上级层级的背景配置
-
-
handleBgInheritChange(layerId, v)}
- />
-
- )}
- handleBgAssetChange(layerId, id)}
- />
- handleBgEffectsChange(layerId, effects)}
- />
- handleBgCSSChange(layerId, css)}
- />
-
- ))}
-
-
-
-
-
-
-
-
-
-
-
自定义 CSS
-
- 编写自定义 CSS 来进一步个性化界面。危险的 CSS(如 @import、url())将被自动过滤。
-
-
-
-
-
-
-
-
- {cssWarnings.length > 0 && (
-
-
-
- {cssWarnings.map((w, i) => - {w}
)}
-
-
- )}
-
-
-
- {/* 动效设置 */}
-
-
动画效果
-
- {/* 全局动画开关 */}
-
-
-
-
-
- 关闭后将禁用所有过渡动画和特效,提升性能
-
-
-
-
-
-
- {/* 波浪背景开关 */}
-
-
-
-
-
- 关闭后登录页将使用纯色背景,适合低性能设备
-
-
-
-
-
-
-
-
- {/* 主题导入/导出 */}
-
-
主题导入/导出
-
-
- {/* 导出按钮 */}
-
-
- {/* 导入按钮 */}
-
-
- {/* 重置按钮 */}
-
-
-
-
-
-
- 确认重置主题
-
- 这将重置所有主题设置为默认值,包括颜色、字体、布局和自定义 CSS。此操作不可撤销,确定要继续吗?
-
-
-
- 取消
-
- 确认重置
-
-
-
-
-
-
- {/* 隐藏的文件输入 */}
-
-
-
- 导出主题为 JSON 文件便于分享或备份,导入时会自动应用所有配置。
-
-
-
-
- )
-}
-
-function ColorTokenPreview({ name, value, foreground, border }: { name: string, value: string, foreground?: string, border?: boolean }) {
- return (
-
- )
-}
-
-// 安全设置标签页
-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 生成成功弹窗 */}
-
-
- {/* 当前 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 开源地址 */}
-
-
- {/* 应用信息 */}
-
-
关于 {APP_NAME}
-
-
版本: {APP_VERSION}
-
麦麦(MaiBot)的现代化 Web 管理界面
-
-
-
- {/* 作者信息 */}
-
-
作者
-
-
-
MaiBot 核心
-
Mai-with-u
-
-
-
-
-
- {/* 技术栈 */}
-
-
技术栈
-
-
-
前端框架
-
- - 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 框架与组件
-
-
-
-
-
-
-
-
-
- {/* 路由与状态 */}
-
-
- {/* 表单与验证 */}
-
-
- {/* 工具库 */}
-
-
- {/* 动画 */}
-
-
- {/* 后端相关 */}
-
-
- {/* 开发工具 */}
-
-
-
-
-
- {/* 许可证 */}
-
-
开源许可
-
-
-
-
-
-
- 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 开源地址 */}
+
+
+ {/* 应用信息 */}
+
+
关于 {APP_NAME}
+
+
版本: {APP_VERSION}
+
麦麦(MaiBot)的现代化 Web 管理界面
+
+
+
+ {/* 作者信息 */}
+
+
作者
+
+
+
MaiBot 核心
+
Mai-with-u
+
+
+
+
+
+ {/* 技术栈 */}
+
+
技术栈
+
+
+
前端框架
+
+ - 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 框架与组件
+
+
+
+
+
+
+
+
+
+ {/* 路由与状态 */}
+
+
+ {/* 表单与验证 */}
+
+
+ {/* 工具库 */}
+
+
+ {/* 动画 */}
+
+
+ {/* 后端相关 */}
+
+
+ {/* 开发工具 */}
+
+
+
+
+
+ {/* 许可证 */}
+
+
开源许可
+
+
+
+
+
+
+ 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 (
+
+ )
+}
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 生成成功弹窗 */}
+
+
+ {/* 当前 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 }