feat(theme): extend settings UI with token controls and custom CSS editor

pull/1496/head
DrSmoothl 2026-02-19 18:22:35 +08:00
parent 06a88a877f
commit bb556dc7ae
3 changed files with 490 additions and 3 deletions

View File

@ -13,6 +13,7 @@
"test:ui": "vitest --ui"
},
"dependencies": {
"@codemirror/lang-css": "^6.3.1",
"@codemirror/lang-javascript": "^6.2.4",
"@codemirror/lang-json": "^6.0.2",
"@codemirror/lang-python": "^6.2.1",

View File

@ -1,7 +1,8 @@
import { useEffect, useState } from 'react'
import CodeMirror from '@uiw/react-codemirror'
import { python } from '@codemirror/lang-python'
import { css } from '@codemirror/lang-css'
import { json, jsonParseLinter } from '@codemirror/lang-json'
import { python } from '@codemirror/lang-python'
import { oneDark } from '@codemirror/theme-one-dark'
import { EditorView } from '@codemirror/view'
import { StreamLanguage } from '@codemirror/language'
@ -9,10 +10,11 @@ import { toml as tomlMode } from '@codemirror/legacy-modes/mode/toml'
import { useTheme } from '@/components/use-theme'
export type Language = 'python' | 'json' | 'toml' | 'text'
export type Language = 'python' | 'json' | 'toml' | 'css' | 'text'
interface CodeEditorProps {
value: string
onChange?: (value: string) => void
language?: Language
readOnly?: boolean
@ -29,6 +31,7 @@ const languageExtensions: Record<Language, any[]> = {
python: [python()],
json: [json(), jsonParseLinter()],
toml: [StreamLanguage.define(tomlMode)],
css: [css()],
text: [],
}

View File

@ -1,7 +1,7 @@
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 } from 'react'
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'
@ -49,6 +49,23 @@ import {
import { getComputedTokens } from '@/lib/theme/pipeline'
import { hexToHSL } from '@/lib/theme/palette'
import { defaultDarkTokens, defaultLightTokens } from '@/lib/theme/tokens'
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 (
@ -265,6 +282,25 @@ function hslToHex(hsl: string): string {
function AppearanceTab() {
const { theme, setTheme, themeConfig, updateThemeConfig, resolvedTheme } = useTheme()
const { enableAnimations, setEnableAnimations, enableWavesBackground, setEnableWavesBackground } = useAnimation()
const [localCSS, setLocalCSS] = useState(themeConfig.customCSS || '')
const [cssWarnings, setCssWarnings] = useState<string[]>([])
const cssDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(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) {
@ -380,6 +416,453 @@ function AppearanceTab() {
</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={() => {
const newOverrides = { ...themeConfig.tokenOverrides }
delete newOverrides.typography
updateThemeConfig({ tokenOverrides: newOverrides })
}}
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={(themeConfig.tokenOverrides?.typography as any)?.['font-family-base']?.includes('ui-serif') ? 'serif' :
(themeConfig.tokenOverrides?.typography as any)?.['font-family-base']?.includes('ui-monospace') ? 'mono' :
(themeConfig.tokenOverrides?.typography as any)?.['font-family-base'] ? 'sans' : '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'
updateThemeConfig({
tokenOverrides: {
...themeConfig.tokenOverrides,
typography: {
...themeConfig.tokenOverrides?.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((themeConfig.tokenOverrides?.typography as any)?.['font-size-base'] || '1') * 16}px
</span>
</div>
<Slider
defaultValue={[16]}
value={[parseFloat((themeConfig.tokenOverrides?.typography as any)?.['font-size-base'] || '1') * 16]}
min={12}
max={20}
step={1}
onValueChange={(vals) => {
updateThemeConfig({
tokenOverrides: {
...themeConfig.tokenOverrides,
typography: {
...themeConfig.tokenOverrides?.typography,
'font-size-base': `${vals[0] / 16}rem`
}
}
})
}}
/>
</div>
<div className="space-y-2">
<Label> (Line Height)</Label>
<Select
value={String((themeConfig.tokenOverrides?.typography as any)?.['line-height-normal'] || '1.5')}
onValueChange={(val) => {
updateThemeConfig({
tokenOverrides: {
...themeConfig.tokenOverrides,
typography: {
...themeConfig.tokenOverrides?.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={() => {
const newOverrides = { ...themeConfig.tokenOverrides }
delete newOverrides.visual
updateThemeConfig({ tokenOverrides: newOverrides })
}}
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((themeConfig.tokenOverrides?.visual as any)?.['radius-md'] || '0.375') * 16)}px
</span>
</div>
<Slider
defaultValue={[6]}
value={[Math.round(parseFloat((themeConfig.tokenOverrides?.visual as any)?.['radius-md'] || '0.375') * 16)]}
min={0}
max={24}
step={1}
onValueChange={(vals) => {
updateThemeConfig({
tokenOverrides: {
...themeConfig.tokenOverrides,
visual: {
...themeConfig.tokenOverrides?.visual,
'radius-md': `${vals[0] / 16}rem`
}
}
})
}}
/>
</div>
<div className="space-y-2">
<Label> (Shadow)</Label>
<Select
value={(themeConfig.tokenOverrides?.visual as any)?.['shadow-md'] === 'none' ? 'none' :
(themeConfig.tokenOverrides?.visual as any)?.['shadow-md'] === defaultLightTokens.visual['shadow-sm'] ? 'sm' :
(themeConfig.tokenOverrides?.visual as any)?.['shadow-md'] === defaultLightTokens.visual['shadow-lg'] ? 'lg' :
(themeConfig.tokenOverrides?.visual as any)?.['shadow-md'] === defaultLightTokens.visual['shadow-xl'] ? 'xl' : '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']
updateThemeConfig({
tokenOverrides: {
...themeConfig.tokenOverrides,
visual: {
...themeConfig.tokenOverrides?.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={(themeConfig.tokenOverrides?.visual as any)?.['blur-md'] !== '0px'}
onCheckedChange={(checked) => {
updateThemeConfig({
tokenOverrides: {
...themeConfig.tokenOverrides,
visual: {
...themeConfig.tokenOverrides?.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={() => {
const newOverrides = { ...themeConfig.tokenOverrides }
delete newOverrides.layout
updateThemeConfig({ tokenOverrides: newOverrides })
}}
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">
{(themeConfig.tokenOverrides?.layout as any)?.['sidebar-width'] || '16rem'}
</span>
</div>
<Slider
defaultValue={[16]}
value={[parseFloat((themeConfig.tokenOverrides?.layout as any)?.['sidebar-width'] || '16')]}
min={12}
max={24}
step={0.5}
onValueChange={(vals) => {
updateThemeConfig({
tokenOverrides: {
...themeConfig.tokenOverrides,
layout: {
...themeConfig.tokenOverrides?.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">
{(themeConfig.tokenOverrides?.layout as any)?.['max-content-width'] || '1280px'}
</span>
</div>
<Slider
defaultValue={[1280]}
value={[parseFloat(((themeConfig.tokenOverrides?.layout as any)?.['max-content-width'] || '1280').replace('px', ''))]}
min={960}
max={1600}
step={10}
onValueChange={(vals) => {
updateThemeConfig({
tokenOverrides: {
...themeConfig.tokenOverrides,
layout: {
...themeConfig.tokenOverrides?.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">
{(themeConfig.tokenOverrides?.layout as any)?.['space-unit'] || '0.25rem'}
</span>
</div>
<Slider
defaultValue={[0.25]}
value={[parseFloat(((themeConfig.tokenOverrides?.layout as any)?.['space-unit'] || '0.25').replace('rem', ''))]}
min={0.2}
max={0.4}
step={0.01}
onValueChange={(vals) => {
updateThemeConfig({
tokenOverrides: {
...themeConfig.tokenOverrides,
layout: {
...themeConfig.tokenOverrides?.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={() => {
const newOverrides = { ...themeConfig.tokenOverrides }
delete newOverrides.animation
updateThemeConfig({ tokenOverrides: newOverrides })
}}
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={(themeConfig.tokenOverrides?.animation as any)?.['anim-duration-normal'] === '100ms' ? 'fast' :
(themeConfig.tokenOverrides?.animation as any)?.['anim-duration-normal'] === '500ms' ? 'slow' :
(themeConfig.tokenOverrides?.animation as any)?.['anim-duration-normal'] === '0ms' ? 'off' : '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)
}
updateThemeConfig({
tokenOverrides: {
...themeConfig.tokenOverrides,
animation: {
...themeConfig.tokenOverrides?.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>
</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={() => {
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>