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' /** * 安全访问 tokenOverrides 中的子属性值 * @param overrides - Partial * @param section - 如 'typography', 'visual', 'layout', 'animation' * @param key - token 键名,如 'font-family-base' * @param defaultValue - 默认值 */ function getTokenValue( overrides: Partial | undefined, section: keyof ThemeTokens, key: string, defaultValue: T ): T { if (!overrides || !overrides[section]) return defaultValue const sectionTokens = overrides[section] as Record | undefined if (!sectionTokens || !(key in sectionTokens)) return defaultValue return (sectionTokens[key] ?? defaultValue) as T } 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(getTokenValue(themeConfig.tokenOverrides, 'typography', 'font-size-base', '1')) * 16}px
{ updateTokenSection('typography', { 'font-size-base': `${vals[0] / 16}rem`, }) }} />
{/* 2. 视觉效果 (Visual) */} 视觉效果 (Visual)
{Math.round(parseFloat(getTokenValue(themeConfig.tokenOverrides, 'visual', '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)
{getTokenValue(themeConfig.tokenOverrides, 'layout', 'sidebar-width', '16rem')}
{ updateTokenSection('layout', { 'sidebar-width': `${vals[0]}rem`, }) }} />
{getTokenValue(themeConfig.tokenOverrides, 'layout', 'max-content-width', '1280px')}
{ updateTokenSection('layout', { 'max-content-width': `${vals[0]}px`, }) }} />
{getTokenValue(themeConfig.tokenOverrides, 'layout', 'space-unit', '0.25rem')}
{ updateTokenSection('layout', { 'space-unit': `${vals[0]}rem`, }) }} />
{/* 4. 动画 (Animation) */} 动画 (Animation)
{/* 5. 背景设置 (Backgrounds) */} 背景设置 (Backgrounds)
页面 侧边栏 Header Card Dialog {(['page', 'sidebar', 'header', 'card', 'dialog'] as const).map((layerId) => ( {layerId !== 'page' && (

开启后将使用上级层级的背景配置

handleBgInheritChange(layerId, v)} />
)} handleBgAssetChange(layerId, id)} /> handleBgEffectsChange(layerId, effects)} /> handleBgCSSChange(layerId, css)} />
))}

自定义 CSS

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

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

动画效果

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

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

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

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

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

主题导入/导出

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

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

) } function ColorTokenPreview({ name, value, foreground, border }: { name: string, value: string, foreground?: string, border?: boolean }) { return (
Aa
{name}
) }