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 (
{/* 主题模式 */}
{/* 主题色配置 */}
主题色
{/* 颜色选择器 */}
{/* 实时色板预览 */}
{/* 样式微调 */}
界面样式微调
{/* 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 (
)
}
// 安全设置标签页
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
请妥善保管您的 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 会创建系统随机生成的 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 管理界面
{/* 作者信息 */}
{/* 技术栈 */}
技术栈
前端框架
- 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 框架 */}
{/* 路由与状态 */}
{/* 表单与验证 */}
{/* 工具库 */}
{/* 动画 */}
{/* 后端相关 */}
{/* 开发工具 */}
{/* 许可证 */}
开源许可
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 (
)
}
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 (
)
}