Files
mai-bot/dashboard/src/routes/settings/AppearanceTab.tsx
DrSmoothl ba47069dfe refactor(types): eliminate all as any type assertions (30 → 0)
- Replace MouseEvent-only handlers with MouseEvent | KeyboardEvent unions in multi-select and ChatTabBar
- Define LegacyInstalledPlugin type for backward compatibility in plugin-api
- Create getTokenValue() helper for type-safe theme token access in AppearanceTab
- Define ModelConfig interface for model configuration type safety in modelProvider
- All modifications maintain exact business logic equivalence
- Build passes with zero TypeScript errors (bun run build )
- LSP diagnostics clean on all modified files
2026-03-01 21:33:40 +08:00

872 lines
36 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<ThemeTokens>
* @param section - 如 'typography', 'visual', 'layout', 'animation'
* @param key - token 键名,如 'font-family-base'
* @param defaultValue - 默认值
*/
function getTokenValue<T>(
overrides: Partial<ThemeTokens> | undefined,
section: keyof ThemeTokens,
key: string,
defaultValue: T
): T {
if (!overrides || !overrides[section]) return defaultValue
const sectionTokens = overrides[section] as Record<string, unknown> | 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<string[]>([])
const cssDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const bgDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const fileInputRef = useRef<HTMLInputElement>(null)
const updateTokenSection = useCallback(
<K extends keyof ThemeTokens>(section: K, partial: Partial<ThemeTokens[K]>) => {
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<ThemeTokens> = { ...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<HTMLInputElement>) => {
const hex = e.target.value
const hsl = hexToHSL(hex)
updateThemeConfig({ accentColor: hsl })
}
const handleResetAccent = () => {
updateThemeConfig({ accentColor: '' })
}
const handleExport = () => {
const json = exportThemeJSON()
const blob = new Blob([json], { type: 'application/json' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `maibot-theme-${Date.now()}.json`
a.click()
URL.revokeObjectURL(url)
}
const handleImport = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (!file) return
const reader = new FileReader()
reader.onload = (ev) => {
const json = ev.target?.result as string
const result = importThemeJSON(json)
if (result.success) {
// 导入成功后需要刷新页面使配置生效(因为 ThemeProvider 需要重新读取 localStorage
toast({ title: '导入成功', description: '主题配置已导入,页面将自动刷新' })
setTimeout(() => window.location.reload(), 1000)
} else {
toast({ title: '导入失败', description: result.errors.join('; '), variant: 'destructive' })
}
}
reader.readAsText(file)
// 重置 input允许重复选择同一文件
e.target.value = ''
}
const handleResetTheme = () => {
resetTheme()
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 (
<div className="space-y-6 sm:space-y-8">
{/* 主题模式 */}
<div>
<h3 className="text-base sm:text-lg font-semibold mb-3 sm:mb-4"></h3>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3 sm:gap-4">
<ThemeOption
value="light"
current={theme}
onChange={setTheme}
label="浅色"
description="始终使用浅色主题"
/>
<ThemeOption
value="dark"
current={theme}
onChange={setTheme}
label="深色"
description="始终使用深色主题"
/>
<ThemeOption
value="system"
current={theme}
onChange={setTheme}
label="跟随系统"
description="根据系统设置自动切换"
/>
</div>
</div>
{/* 主题色配置 */}
<div>
<div className="flex items-center justify-between mb-3 sm:mb-4">
<h3 className="text-base sm:text-lg font-semibold"></h3>
<Button
variant="outline"
size="sm"
onClick={handleResetAccent}
disabled={!themeConfig.accentColor}
className="h-8"
>
<RotateCcw className="mr-2 h-3.5 w-3.5" />
</Button>
</div>
<div className="space-y-6">
{/* 颜色选择器 */}
<div className="flex flex-col sm:flex-row gap-4 items-start sm:items-center p-4 rounded-lg border bg-card">
<div className="flex items-center gap-3">
<div className="h-10 w-10 rounded-full border-2 border-border overflow-hidden relative shadow-sm">
<input
type="color"
value={currentAccentHex}
onChange={handleAccentColorChange}
className="absolute inset-0 w-[150%] h-[150%] -top-1/4 -left-1/4 cursor-pointer p-0 border-0"
/>
</div>
<div className="space-y-1">
<Label htmlFor="accent-color-input" className="font-medium"></Label>
<p className="text-xs text-muted-foreground"> HEX </p>
</div>
</div>
<div className="flex-1 w-full sm:w-auto flex items-center gap-2">
<Input
id="accent-color-input"
type="text"
value={currentAccentHex}
onChange={handleAccentColorChange}
className="font-mono uppercase w-32"
maxLength={7}
/>
</div>
</div>
{/* 实时色板预览 */}
<div className="space-y-3">
<h4 className="text-sm font-medium text-muted-foreground"></h4>
<div className="grid grid-cols-2 sm:grid-cols-4 md:grid-cols-8 gap-3">
<ColorTokenPreview name="primary" value={previewTokens.primary} foreground={previewTokens['primary-foreground']} />
<ColorTokenPreview name="secondary" value={previewTokens.secondary} foreground={previewTokens['secondary-foreground']} />
<ColorTokenPreview name="muted" value={previewTokens.muted} foreground={previewTokens['muted-foreground']} />
<ColorTokenPreview name="accent" value={previewTokens.accent} foreground={previewTokens['accent-foreground']} />
<ColorTokenPreview name="destructive" value={previewTokens.destructive} foreground={previewTokens['destructive-foreground']} />
<ColorTokenPreview name="background" value={previewTokens.background} foreground={previewTokens.foreground} border />
<ColorTokenPreview name="card" value={previewTokens.card} foreground={previewTokens['card-foreground']} border />
<ColorTokenPreview name="border" value={previewTokens.border} />
</div>
</div>
</div>
</div>
{/* 样式微调 */}
<div>
<h3 className="text-base sm:text-lg font-semibold mb-3 sm:mb-4"></h3>
<Accordion type="single" collapsible className="w-full">
{/* 1. 字体排版 (Typography) */}
<AccordionItem value="typography">
<AccordionTrigger> (Typography)</AccordionTrigger>
<AccordionContent>
<div className="space-y-4 pt-2">
<div className="flex justify-end">
<Button
variant="ghost"
size="sm"
onClick={() => resetTokenSection('typography')}
disabled={!themeConfig.tokenOverrides?.typography}
className="h-8 text-xs"
>
<RotateCcw className="mr-2 h-3.5 w-3.5" />
</Button>
</div>
<div className="space-y-2">
<Label> (Font Family)</Label>
<Select
value={(() => {
const fontFamily = getTokenValue(themeConfig.tokenOverrides, 'typography', 'font-family-base', '')
if (fontFamily.includes('ui-serif')) return 'serif'
if (fontFamily.includes('ui-monospace')) return 'mono'
if (fontFamily) return 'sans'
return 'system'
})()}
onValueChange={(val) => {
let fontVal = defaultLightTokens.typography['font-family-base']
if (val === 'serif') fontVal = 'ui-serif, Georgia, Cambria, "Times New Roman", Times, serif'
else if (val === 'mono') fontVal = 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace'
else if (val === 'sans') fontVal = 'ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif'
updateTokenSection('typography', {
'font-family-base': fontVal,
})
}}
>
<SelectTrigger>
<SelectValue placeholder="选择字体族" />
</SelectTrigger>
<SelectContent>
<SelectItem value="system"> (System)</SelectItem>
<SelectItem value="sans">线 (Sans-serif)</SelectItem>
<SelectItem value="serif">线 (Serif)</SelectItem>
<SelectItem value="mono"> (Monospace)</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-4">
<div className="flex justify-between">
<Label> (Base Size)</Label>
<span className="text-sm text-muted-foreground">
{parseFloat(getTokenValue(themeConfig.tokenOverrides, 'typography', 'font-size-base', '1')) * 16}px
</span>
</div>
<Slider
defaultValue={[16]}
value={[parseFloat(getTokenValue(themeConfig.tokenOverrides, 'typography', 'font-size-base', '1')) * 16]}
min={12}
max={20}
step={1}
onValueChange={(vals) => {
updateTokenSection('typography', {
'font-size-base': `${vals[0] / 16}rem`,
})
}}
/>
</div>
<div className="space-y-2">
<Label> (Line Height)</Label>
<Select
value={String(getTokenValue(themeConfig.tokenOverrides, 'typography', 'line-height-normal', 1.5))}
onValueChange={(val) => {
updateTokenSection('typography', {
'line-height-normal': parseFloat(val),
})
}}
>
<SelectTrigger>
<SelectValue placeholder="选择行高" />
</SelectTrigger>
<SelectContent>
<SelectItem value="1.2"> (1.2)</SelectItem>
<SelectItem value="1.5"> (1.5)</SelectItem>
<SelectItem value="1.75"> (1.75)</SelectItem>
</SelectContent>
</Select>
</div>
</div>
</AccordionContent>
</AccordionItem>
{/* 2. 视觉效果 (Visual) */}
<AccordionItem value="visual">
<AccordionTrigger> (Visual)</AccordionTrigger>
<AccordionContent>
<div className="space-y-4 pt-2">
<div className="flex justify-end">
<Button
variant="ghost"
size="sm"
onClick={() => resetTokenSection('visual')}
disabled={!themeConfig.tokenOverrides?.visual}
className="h-8 text-xs"
>
<RotateCcw className="mr-2 h-3.5 w-3.5" />
</Button>
</div>
<div className="space-y-4">
<div className="flex justify-between">
<Label> (Radius)</Label>
<span className="text-sm text-muted-foreground">
{Math.round(parseFloat(getTokenValue(themeConfig.tokenOverrides, 'visual', 'radius-md', '0.375')) * 16)}px
</span>
</div>
<Slider
defaultValue={[6]}
value={[Math.round(parseFloat(getTokenValue(themeConfig.tokenOverrides, 'visual', 'radius-md', '0.375')) * 16)]}
min={0}
max={24}
step={1}
onValueChange={(vals) => {
updateTokenSection('visual', {
'radius-md': `${vals[0] / 16}rem`,
})
}}
/>
</div>
<div className="space-y-2">
<Label> (Shadow)</Label>
<Select
value={(() => {
const shadowMd = String(getTokenValue(themeConfig.tokenOverrides, 'visual', 'shadow-md', ''))
if (shadowMd === 'none') return 'none'
if (shadowMd === defaultLightTokens.visual['shadow-sm']) return 'sm'
if (shadowMd === defaultLightTokens.visual['shadow-lg']) return 'lg'
if (shadowMd === defaultLightTokens.visual['shadow-xl']) return 'xl'
return 'md'
})()}
onValueChange={(val) => {
let shadowVal = defaultLightTokens.visual['shadow-md']
if (val === 'none') shadowVal = 'none'
else if (val === 'sm') shadowVal = defaultLightTokens.visual['shadow-sm']
else if (val === 'lg') shadowVal = defaultLightTokens.visual['shadow-lg']
else if (val === 'xl') shadowVal = defaultLightTokens.visual['shadow-xl']
updateTokenSection('visual', {
'shadow-md': shadowVal,
})
}}
>
<SelectTrigger>
<SelectValue placeholder="选择阴影强度" />
</SelectTrigger>
<SelectContent>
<SelectItem value="none"> (None)</SelectItem>
<SelectItem value="sm"> (Small)</SelectItem>
<SelectItem value="md"> (Medium)</SelectItem>
<SelectItem value="lg"> (Large)</SelectItem>
<SelectItem value="xl"> (Extra Large)</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex items-center justify-between">
<Label htmlFor="blur-switch"> (Blur)</Label>
<Switch
id="blur-switch"
checked={getTokenValue(themeConfig.tokenOverrides, 'visual', 'blur-md', '0px') !== '0px'}
onCheckedChange={(checked) => {
updateTokenSection('visual', {
'blur-md': checked ? defaultLightTokens.visual['blur-md'] : '0px',
})
}}
/>
</div>
</div>
</AccordionContent>
</AccordionItem>
{/* 3. 布局 (Layout) */}
<AccordionItem value="layout">
<AccordionTrigger> (Layout)</AccordionTrigger>
<AccordionContent>
<div className="space-y-4 pt-2">
<div className="flex justify-end">
<Button
variant="ghost"
size="sm"
onClick={() => resetTokenSection('layout')}
disabled={!themeConfig.tokenOverrides?.layout}
className="h-8 text-xs"
>
<RotateCcw className="mr-2 h-3.5 w-3.5" />
</Button>
</div>
<div className="space-y-4">
<div className="flex justify-between">
<Label> (Sidebar Width)</Label>
<span className="text-sm text-muted-foreground">
{getTokenValue(themeConfig.tokenOverrides, 'layout', 'sidebar-width', '16rem')}
</span>
</div>
<Slider
defaultValue={[16]}
value={[parseFloat(getTokenValue(themeConfig.tokenOverrides, 'layout', 'sidebar-width', '16'))]}
min={12}
max={24}
step={0.5}
onValueChange={(vals) => {
updateTokenSection('layout', {
'sidebar-width': `${vals[0]}rem`,
})
}}
/>
</div>
<div className="space-y-4">
<div className="flex justify-between">
<Label> (Max Width)</Label>
<span className="text-sm text-muted-foreground">
{getTokenValue(themeConfig.tokenOverrides, 'layout', 'max-content-width', '1280px')}
</span>
</div>
<Slider
defaultValue={[1280]}
value={[parseFloat(getTokenValue(themeConfig.tokenOverrides, 'layout', 'max-content-width', '1280').replace('px', ''))]}
min={960}
max={1600}
step={10}
onValueChange={(vals) => {
updateTokenSection('layout', {
'max-content-width': `${vals[0]}px`,
})
}}
/>
</div>
<div className="space-y-4">
<div className="flex justify-between">
<Label> (Spacing Unit)</Label>
<span className="text-sm text-muted-foreground">
{getTokenValue(themeConfig.tokenOverrides, 'layout', 'space-unit', '0.25rem')}
</span>
</div>
<Slider
defaultValue={[0.25]}
value={[parseFloat(getTokenValue(themeConfig.tokenOverrides, 'layout', 'space-unit', '0.25').replace('rem', ''))]}
min={0.2}
max={0.4}
step={0.01}
onValueChange={(vals) => {
updateTokenSection('layout', {
'space-unit': `${vals[0]}rem`,
})
}}
/>
</div>
</div>
</AccordionContent>
</AccordionItem>
{/* 4. 动画 (Animation) */}
<AccordionItem value="animation">
<AccordionTrigger> (Animation)</AccordionTrigger>
<AccordionContent>
<div className="space-y-4 pt-2">
<div className="flex justify-end">
<Button
variant="ghost"
size="sm"
onClick={() => resetTokenSection('animation')}
disabled={!themeConfig.tokenOverrides?.animation}
className="h-8 text-xs"
>
<RotateCcw className="mr-2 h-3.5 w-3.5" />
</Button>
</div>
<div className="space-y-2">
<Label> (Speed)</Label>
<Select
value={(() => {
const duration = String(getTokenValue(themeConfig.tokenOverrides, 'animation', 'anim-duration-normal', '300ms'))
if (duration === '100ms') return 'fast'
if (duration === '500ms') return 'slow'
if (duration === '0ms') return 'off'
return 'normal'
})()}
onValueChange={(val) => {
let duration = '300ms'
if (val === 'fast') duration = '100ms'
else if (val === 'slow') duration = '500ms'
else if (val === 'off') duration = '0ms'
// 如果用户选了关闭,我们也应该同步更新 enableAnimations 开关
if (val === 'off' && enableAnimations) {
setEnableAnimations(false)
} else if (val !== 'off' && !enableAnimations) {
setEnableAnimations(true)
}
updateTokenSection('animation', {
'anim-duration-normal': duration,
})
}}
>
<SelectTrigger>
<SelectValue placeholder="选择动画速度" />
</SelectTrigger>
<SelectContent>
<SelectItem value="fast"> (100ms)</SelectItem>
<SelectItem value="normal"> (300ms)</SelectItem>
<SelectItem value="slow"> (500ms)</SelectItem>
<SelectItem value="off"> (0ms)</SelectItem>
</SelectContent>
</Select>
</div>
</div>
</AccordionContent>
</AccordionItem>
{/* 5. 背景设置 (Backgrounds) */}
<AccordionItem value="backgrounds">
<AccordionTrigger> (Backgrounds)</AccordionTrigger>
<AccordionContent>
<div className="pt-2">
<Tabs defaultValue="page">
<TabsList className="w-full grid grid-cols-5">
<TabsTrigger value="page"></TabsTrigger>
<TabsTrigger value="sidebar"></TabsTrigger>
<TabsTrigger value="header">Header</TabsTrigger>
<TabsTrigger value="card">Card</TabsTrigger>
<TabsTrigger value="dialog">Dialog</TabsTrigger>
</TabsList>
{(['page', 'sidebar', 'header', 'card', 'dialog'] as const).map((layerId) => (
<TabsContent key={layerId} value={layerId} className="space-y-4 mt-4">
{layerId !== 'page' && (
<div className="flex items-center justify-between rounded-lg border bg-muted/30 px-4 py-3">
<div className="space-y-0.5">
<Label className="text-sm font-medium"></Label>
<p className="text-xs text-muted-foreground">使</p>
</div>
<Switch
checked={bgConfig[layerId]?.inherit ?? false}
onCheckedChange={(v) => handleBgInheritChange(layerId, v)}
/>
</div>
)}
<BackgroundUploader
assetId={bgConfig[layerId]?.assetId}
onAssetSelect={(id) => handleBgAssetChange(layerId, id)}
/>
<BackgroundEffectsControls
effects={bgConfig[layerId]?.effects ?? defaultBackgroundEffects}
onChange={(effects) => handleBgEffectsChange(layerId, effects)}
/>
<ComponentCSSEditor
componentId={layerId}
value={bgConfig[layerId]?.customCSS ?? ''}
onChange={(css) => handleBgCSSChange(layerId, css)}
/>
</TabsContent>
))}
</Tabs>
</div>
</AccordionContent>
</AccordionItem>
</Accordion>
</div>
<div>
<div className="flex items-center justify-between mb-3 sm:mb-4">
<div>
<h3 className="text-base sm:text-lg font-semibold"> CSS</h3>
<p className="text-sm text-muted-foreground mt-1">
CSS CSS @importurl()
</p>
</div>
<Button
variant="outline"
size="sm"
onClick={() => {
setLocalCSS('')
updateThemeConfig({ customCSS: '' })
setCssWarnings([])
}}
disabled={!themeConfig.customCSS}
>
<Trash2 className="h-4 w-4 mr-1" />
</Button>
</div>
<div className="rounded-lg border bg-card p-3 sm:p-4 space-y-3">
<CodeEditor
value={localCSS}
language="css"
height="250px"
placeholder={`/* 在这里输入自定义 CSS */\n\n/* 例如: */\n/* .sidebar { background: #1a1a2e; } */`}
onChange={handleCSSChange}
/>
{cssWarnings.length > 0 && (
<div className="rounded-md bg-yellow-50 dark:bg-yellow-950/30 border border-yellow-200 dark:border-yellow-800 p-3">
<div className="flex items-center gap-2 text-yellow-800 dark:text-yellow-200 text-sm font-medium mb-1">
<AlertTriangle className="h-4 w-4" />
</div>
<ul className="text-xs text-yellow-700 dark:text-yellow-300 space-y-0.5 ml-6 list-disc">
{cssWarnings.map((w, i) => <li key={i}>{w}</li>)}
</ul>
</div>
)}
</div>
</div>
{/* 动效设置 */}
<div>
<h3 className="text-base sm:text-lg font-semibold mb-3 sm:mb-4"></h3>
<div className="space-y-2 sm:space-y-3">
{/* 全局动画开关 */}
<div className="rounded-lg border bg-card p-3 sm:p-4">
<div className="flex items-center justify-between">
<div className="space-y-0.5 flex-1">
<Label htmlFor="animations" className="text-base font-medium cursor-pointer">
</Label>
<p className="text-sm text-muted-foreground">
</p>
</div>
<Switch
id="animations"
checked={enableAnimations}
onCheckedChange={setEnableAnimations}
/>
</div>
</div>
{/* 波浪背景开关 */}
<div className="rounded-lg border bg-card p-4">
<div className="flex items-center justify-between">
<div className="space-y-0.5 flex-1">
<Label htmlFor="waves-background" className="text-base font-medium cursor-pointer">
</Label>
<p className="text-sm text-muted-foreground">
使
</p>
</div>
<Switch
id="waves-background"
checked={enableWavesBackground}
onCheckedChange={setEnableWavesBackground}
/>
</div>
</div>
</div>
</div>
{/* 主题导入/导出 */}
<div>
<h3 className="text-base sm:text-lg font-semibold mb-3 sm:mb-4">/</h3>
<div className="rounded-lg border bg-card p-3 sm:p-4 space-y-3">
<div className="grid grid-cols-1 sm:grid-cols-3 gap-2">
{/* 导出按钮 */}
<Button
onClick={handleExport}
variant="outline"
className="gap-2"
>
<Download className="h-4 w-4" />
</Button>
{/* 导入按钮 */}
<Button
onClick={() => fileInputRef.current?.click()}
variant="outline"
className="gap-2"
>
<Upload className="h-4 w-4" />
</Button>
{/* 重置按钮 */}
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
variant="outline"
className="gap-2"
>
<RotateCcw className="h-4 w-4" />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle></AlertDialogTitle>
<AlertDialogDescription>
CSS
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel>
<AlertDialogAction onClick={handleResetTheme}>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
{/* 隐藏的文件输入 */}
<input
ref={fileInputRef}
type="file"
accept=".json"
onChange={handleImport}
className="hidden"
/>
<p className="text-xs text-muted-foreground">
JSON 便
</p>
</div>
</div>
</div>
)
}
function ColorTokenPreview({ name, value, foreground, border }: { name: string, value: string, foreground?: string, border?: boolean }) {
return (
<div className="flex flex-col gap-1.5">
<div
className={cn("h-16 rounded-md shadow-sm flex items-center justify-center text-xs font-medium", border && "border border-border")}
style={{ backgroundColor: `hsl(${value})`, color: foreground ? `hsl(${foreground})` : undefined }}
>
Aa
</div>
<div className="text-xs text-muted-foreground text-center truncate" title={name}>
{name}
</div>
</div>
)
}