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 (
{/* 页面标题 */}

系统设置

管理您的应用偏好设置

{/* 标签页 */} 外观 安全 其他 关于
) } // 辅助函数:将 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([]) const cssDebounceRef = useRef | null>(null) const fileInputRef = useRef(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) => { 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() toast({ title: '重置成功', description: '主题已重置为默认值' }) } const previewTokens = useMemo(() => { return getComputedTokens(themeConfig, resolvedTheme === 'dark').color }, [themeConfig, resolvedTheme]) return (
{/* 主题模式 */}

主题模式

{/* 主题色配置 */}

主题色

{/* 颜色选择器 */}

点击色环选择或输入 HEX 值

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

实时色板预览

{/* 样式微调 */}

界面样式微调

{/* 1. 字体排版 (Typography) */} 字体排版 (Typography)
{parseFloat((themeConfig.tokenOverrides?.typography as any)?.['font-size-base'] || '1') * 16}px
{ updateThemeConfig({ tokenOverrides: { ...themeConfig.tokenOverrides, typography: { ...themeConfig.tokenOverrides?.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
{ updateThemeConfig({ tokenOverrides: { ...themeConfig.tokenOverrides, visual: { ...themeConfig.tokenOverrides?.visual, 'radius-md': `${vals[0] / 16}rem` } } }) }} />
{ updateThemeConfig({ tokenOverrides: { ...themeConfig.tokenOverrides, visual: { ...themeConfig.tokenOverrides?.visual, 'blur-md': checked ? defaultLightTokens.visual['blur-md'] : '0px' } } }) }} />
{/* 3. 布局 (Layout) */} 布局 (Layout)
{(themeConfig.tokenOverrides?.layout as any)?.['sidebar-width'] || '16rem'}
{ updateThemeConfig({ tokenOverrides: { ...themeConfig.tokenOverrides, layout: { ...themeConfig.tokenOverrides?.layout, 'sidebar-width': `${vals[0]}rem` } } }) }} />
{(themeConfig.tokenOverrides?.layout as any)?.['max-content-width'] || '1280px'}
{ updateThemeConfig({ tokenOverrides: { ...themeConfig.tokenOverrides, layout: { ...themeConfig.tokenOverrides?.layout, 'max-content-width': `${vals[0]}px` } } }) }} />
{(themeConfig.tokenOverrides?.layout as any)?.['space-unit'] || '0.25rem'}
{ updateThemeConfig({ tokenOverrides: { ...themeConfig.tokenOverrides, layout: { ...themeConfig.tokenOverrides?.layout, 'space-unit': `${vals[0]}rem` } } }) }} />
{/* 4. 动画 (Animation) */} 动画 (Animation)

自定义 CSS

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

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

动画效果

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

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

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

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

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

主题导入/导出

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

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

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

重要提示

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

当前 Access Token

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

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

{/* 更新 Token */}

自定义 Access Token

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

Token 安全要求:

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

安全提示

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

性能与存储

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

{storageUsage.items} 个存储项

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

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

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

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

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

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

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

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

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

导入/导出设置

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

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

配置向导

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

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

开发者工具

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

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

开源项目

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

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

关于 {APP_NAME}

版本: {APP_VERSION}

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

{/* 作者信息 */}

作者

MaiBot 核心

Mai-with-u

WebUI

Mai-with-u @MotricSeven

{/* 技术栈 */}

技术栈

前端框架

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

UI 组件

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

后端

  • Python 3.12+
  • FastAPI
  • Uvicorn
  • WebSocket

构建工具

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

开源库感谢

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

{/* UI 框架 */}

UI 框架与组件

{/* 路由与状态 */}

路由与状态管理

{/* 表单与验证 */}

表单处理

{/* 工具库 */}

工具库

{/* 动画 */}

动画效果

{/* 后端相关 */}

后端框架

{/* 开发工具 */}

开发工具

{/* 许可证 */}

开源许可

GPLv3

MaiBot WebUI

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

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

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

{name}

{description}

{license}
) } type ThemeOptionProps = { value: 'light' | 'dark' | 'system' current: 'light' | 'dark' | 'system' onChange: (theme: 'light' | 'dark' | 'system') => void label: string description: string } function ThemeOption({ value, current, onChange, label, description }: ThemeOptionProps) { const isSelected = current === value return ( ) } 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 ( ) }