From 8fb137a318ab76c3e414011dcad73b60d248d5c9 Mon Sep 17 00:00:00 2001 From: DrSmoothl <1787882683@qq.com> Date: Thu, 19 Feb 2026 17:26:53 +0800 Subject: [PATCH] feat(theme): add CSS injection pipeline, presets, rewrite ThemeProvider, FOUC prevention --- dashboard/index.html | 11 ++ dashboard/src/components/theme-provider.tsx | 186 ++++++++------------ dashboard/src/lib/theme-context.ts | 15 ++ dashboard/src/lib/theme/pipeline.ts | 124 +++++++++++++ dashboard/src/lib/theme/presets.ts | 62 +++++++ 5 files changed, 284 insertions(+), 114 deletions(-) create mode 100644 dashboard/src/lib/theme/pipeline.ts create mode 100644 dashboard/src/lib/theme/presets.ts diff --git a/dashboard/index.html b/dashboard/index.html index 98489824..21c3be1a 100644 --- a/dashboard/index.html +++ b/dashboard/index.html @@ -11,6 +11,17 @@ MaiBot Dashboard +
diff --git a/dashboard/src/components/theme-provider.tsx b/dashboard/src/components/theme-provider.tsx index 467ffef6..79d65f02 100644 --- a/dashboard/src/components/theme-provider.tsx +++ b/dashboard/src/components/theme-provider.tsx @@ -1,6 +1,16 @@ -import { useEffect, useState } from 'react' +import { useCallback, useEffect, useMemo, useState } from 'react' import type { ReactNode } from 'react' + import { ThemeProviderContext } from '@/lib/theme-context' +import type { UserThemeConfig } from '@/lib/theme/tokens' +import { + THEME_STORAGE_KEYS, + loadThemeConfig, + migrateOldKeys, + resetThemeToDefault, + saveThemePartial, +} from '@/lib/theme/storage' +import { applyThemePipeline, removeCustomCSS } from '@/lib/theme/pipeline' type Theme = 'dark' | 'light' | 'system' @@ -13,126 +23,74 @@ type ThemeProviderProps = { export function ThemeProvider({ children, defaultTheme = 'system', - storageKey = 'ui-theme', - ...props + storageKey: _storageKey, }: ThemeProviderProps) { - const [theme, setTheme] = useState( - () => (localStorage.getItem(storageKey) as Theme) || defaultTheme - ) + const [themeMode, setThemeMode] = useState(() => { + const saved = localStorage.getItem(THEME_STORAGE_KEYS.MODE) as Theme | null + return saved || defaultTheme + }) + const [themeConfig, setThemeConfig] = useState(() => loadThemeConfig()) + const [systemThemeTick, setSystemThemeTick] = useState(0) + + const resolvedTheme = useMemo<'dark' | 'light'>(() => { + if (themeMode !== 'system') return themeMode + return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light' + }, [themeMode, systemThemeTick]) useEffect(() => { - const root = window.document.documentElement - - root.classList.remove('light', 'dark') - - if (theme === 'system') { - const systemTheme = window.matchMedia('(prefers-color-scheme: dark)').matches - ? 'dark' - : 'light' - - root.classList.add(systemTheme) - return - } - - root.classList.add(theme) - }, [theme]) - - // 应用保存的主题色 - useEffect(() => { - const savedAccentColor = localStorage.getItem('accent-color') - if (savedAccentColor) { - const root = document.documentElement - const colors = { - blue: { - hsl: '221.2 83.2% 53.3%', - darkHsl: '217.2 91.2% 59.8%', - gradient: null - }, - purple: { - hsl: '271 91% 65%', - darkHsl: '270 95% 75%', - gradient: null - }, - green: { - hsl: '142 71% 45%', - darkHsl: '142 76% 36%', - gradient: null - }, - orange: { - hsl: '25 95% 53%', - darkHsl: '20 90% 48%', - gradient: null - }, - pink: { - hsl: '330 81% 60%', - darkHsl: '330 85% 70%', - gradient: null - }, - red: { - hsl: '0 84% 60%', - darkHsl: '0 90% 70%', - gradient: null - }, - - // 渐变色 - 'gradient-sunset': { - hsl: '15 95% 60%', - darkHsl: '15 95% 65%', - gradient: 'linear-gradient(135deg, hsl(25 95% 53%) 0%, hsl(330 81% 60%) 100%)' - }, - 'gradient-ocean': { - hsl: '200 90% 55%', - darkHsl: '200 90% 60%', - gradient: 'linear-gradient(135deg, hsl(221.2 83.2% 53.3%) 0%, hsl(189 94% 43%) 100%)' - }, - 'gradient-forest': { - hsl: '150 70% 45%', - darkHsl: '150 75% 40%', - gradient: 'linear-gradient(135deg, hsl(142 71% 45%) 0%, hsl(158 64% 52%) 100%)' - }, - 'gradient-aurora': { - hsl: '310 85% 65%', - darkHsl: '310 90% 70%', - gradient: 'linear-gradient(135deg, hsl(271 91% 65%) 0%, hsl(330 81% 60%) 100%)' - }, - 'gradient-fire': { - hsl: '15 95% 55%', - darkHsl: '15 95% 60%', - gradient: 'linear-gradient(135deg, hsl(0 84% 60%) 0%, hsl(25 95% 53%) 100%)' - }, - 'gradient-twilight': { - hsl: '250 90% 60%', - darkHsl: '250 95% 65%', - gradient: 'linear-gradient(135deg, hsl(239 84% 67%) 0%, hsl(271 91% 65%) 100%)' - }, - } - - const selectedColor = colors[savedAccentColor as keyof typeof colors] - if (selectedColor) { - root.style.setProperty('--primary', selectedColor.hsl) - - // 设置渐变(如果有) - if (selectedColor.gradient) { - root.style.setProperty('--primary-gradient', selectedColor.gradient) - root.classList.add('has-gradient') - } else { - root.style.removeProperty('--primary-gradient') - root.classList.remove('has-gradient') - } - } - } + migrateOldKeys() }, []) - const value = { - theme, - setTheme: (theme: Theme) => { - localStorage.setItem(storageKey, theme) - setTheme(theme) - }, - } + useEffect(() => { + const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)') + const handleChange = () => { + if (themeMode === 'system') { + setSystemThemeTick((prev) => prev + 1) + } + } + mediaQuery.addEventListener('change', handleChange) + return () => mediaQuery.removeEventListener('change', handleChange) + }, [themeMode]) + + useEffect(() => { + const root = document.documentElement + root.classList.remove('light', 'dark') + root.classList.add(resolvedTheme) + + const isDark = resolvedTheme === 'dark' + applyThemePipeline(themeConfig, isDark) + }, [resolvedTheme, themeConfig]) + + const setTheme = useCallback((mode: Theme) => { + localStorage.setItem(THEME_STORAGE_KEYS.MODE, mode) + setThemeMode(mode) + }, []) + + const updateThemeConfig = useCallback((partial: Partial) => { + saveThemePartial(partial) + setThemeConfig((prev) => ({ ...prev, ...partial })) + }, []) + + const resetTheme = useCallback(() => { + resetThemeToDefault() + removeCustomCSS() + setThemeConfig(loadThemeConfig()) + }, []) + + const value = useMemo( + () => ({ + theme: themeMode, + resolvedTheme, + setTheme, + themeConfig, + updateThemeConfig, + resetTheme, + }), + [themeMode, resolvedTheme, setTheme, themeConfig, updateThemeConfig, resetTheme], + ) return ( - + {children} ) diff --git a/dashboard/src/lib/theme-context.ts b/dashboard/src/lib/theme-context.ts index 19358e7a..9e5b50bc 100644 --- a/dashboard/src/lib/theme-context.ts +++ b/dashboard/src/lib/theme-context.ts @@ -1,15 +1,30 @@ import { createContext } from 'react' +import type { UserThemeConfig } from './theme/tokens' + type Theme = 'dark' | 'light' | 'system' export type ThemeProviderState = { theme: Theme + resolvedTheme: 'dark' | 'light' setTheme: (theme: Theme) => void + themeConfig: UserThemeConfig + updateThemeConfig: (partial: Partial) => void + resetTheme: () => void } const initialState: ThemeProviderState = { theme: 'system', + resolvedTheme: 'light', setTheme: () => null, + themeConfig: { + selectedPreset: 'light', + accentColor: '', + tokenOverrides: {}, + customCSS: '', + }, + updateThemeConfig: () => null, + resetTheme: () => null, } export const ThemeProviderContext = createContext(initialState) diff --git a/dashboard/src/lib/theme/pipeline.ts b/dashboard/src/lib/theme/pipeline.ts new file mode 100644 index 00000000..61df6e94 --- /dev/null +++ b/dashboard/src/lib/theme/pipeline.ts @@ -0,0 +1,124 @@ +import type { ThemeTokens, UserThemeConfig } from './tokens' + +import { generatePalette } from './palette' +import { getPresetById } from './presets' +import { sanitizeCSS } from './sanitizer' +import { defaultDarkTokens, defaultLightTokens, tokenToCSSVarName } from './tokens' + +const CUSTOM_CSS_ID = 'maibot-custom-css' + +const mergeTokens = (base: ThemeTokens, overrides: Partial): ThemeTokens => { + return { + color: { + ...base.color, + ...(overrides.color ?? {}), + }, + typography: { + ...base.typography, + ...(overrides.typography ?? {}), + }, + visual: { + ...base.visual, + ...(overrides.visual ?? {}), + }, + layout: { + ...base.layout, + ...(overrides.layout ?? {}), + }, + animation: { + ...base.animation, + ...(overrides.animation ?? {}), + }, + } +} + +const buildTokens = (config: UserThemeConfig, isDark: boolean): ThemeTokens => { + const baseTokens = isDark ? defaultDarkTokens : defaultLightTokens + let mergedTokens = mergeTokens(baseTokens, {}) + + if (config.accentColor) { + const paletteTokens = generatePalette(config.accentColor, isDark) + mergedTokens = mergeTokens(mergedTokens, { color: paletteTokens }) + } + + if (config.selectedPreset) { + const preset = getPresetById(config.selectedPreset) + if (preset?.tokens) { + mergedTokens = mergeTokens(mergedTokens, preset.tokens) + } + } + + if (config.tokenOverrides) { + mergedTokens = mergeTokens(mergedTokens, config.tokenOverrides) + } + + return mergedTokens +} + +export function getComputedTokens(config: UserThemeConfig, isDark: boolean): ThemeTokens { + return buildTokens(config, isDark) +} + +export function injectTokensAsCSS(tokens: ThemeTokens, target: HTMLElement): void { + Object.entries(tokens.color).forEach(([key, value]) => { + target.style.setProperty(tokenToCSSVarName('color', key), String(value)) + }) + + Object.entries(tokens.typography).forEach(([key, value]) => { + target.style.setProperty(tokenToCSSVarName('typography', key), String(value)) + }) + + Object.entries(tokens.visual).forEach(([key, value]) => { + target.style.setProperty(tokenToCSSVarName('visual', key), String(value)) + }) + + Object.entries(tokens.layout).forEach(([key, value]) => { + target.style.setProperty(tokenToCSSVarName('layout', key), String(value)) + }) + + Object.entries(tokens.animation).forEach(([key, value]) => { + target.style.setProperty(tokenToCSSVarName('animation', key), String(value)) + }) +} + +export function injectCustomCSS(css: string): void { + if (css.trim().length === 0) { + removeCustomCSS() + return + } + + const existing = document.getElementById(CUSTOM_CSS_ID) + if (existing) { + existing.textContent = css + return + } + + const style = document.createElement('style') + style.id = CUSTOM_CSS_ID + style.textContent = css + document.head.appendChild(style) +} + +export function removeCustomCSS(): void { + const existing = document.getElementById(CUSTOM_CSS_ID) + if (existing) { + existing.remove() + } +} + +export function applyThemePipeline(config: UserThemeConfig, isDark: boolean): void { + const root = document.documentElement + const tokens = buildTokens(config, isDark) + + injectTokensAsCSS(tokens, root) + + if (config.customCSS) { + const sanitized = sanitizeCSS(config.customCSS) + if (sanitized.css.trim().length > 0) { + injectCustomCSS(sanitized.css) + return + } + } + + removeCustomCSS() +} diff --git a/dashboard/src/lib/theme/presets.ts b/dashboard/src/lib/theme/presets.ts new file mode 100644 index 00000000..36cc8a53 --- /dev/null +++ b/dashboard/src/lib/theme/presets.ts @@ -0,0 +1,62 @@ +/** + * Theme Presets 定义 + * 提供内置的亮色和暗色主题预设 + */ + +import { + defaultDarkTokens, + defaultLightTokens, +} from './tokens' +import type { ThemePreset } from './tokens' + +// ============================================================================ +// Default Light Preset +// ============================================================================ + +export const defaultLightPreset: ThemePreset = { + id: 'light', + name: '默认亮色', + description: '默认亮色主题', + tokens: defaultLightTokens, + isDark: false, +} + +// ============================================================================ +// Default Dark Preset +// ============================================================================ + +export const defaultDarkPreset: ThemePreset = { + id: 'dark', + name: '默认暗色', + description: '默认暗色主题', + tokens: defaultDarkTokens, + isDark: true, +} + +// ============================================================================ +// Built-in Presets Collection +// ============================================================================ + +export const builtInPresets: ThemePreset[] = [ + defaultLightPreset, + defaultDarkPreset, +] + +// ============================================================================ +// Default Preset ID +// ============================================================================ + +export const DEFAULT_PRESET_ID = 'light' + +// ============================================================================ +// Preset Utility Functions +// ============================================================================ + +/** + * 根据 ID 获取预设 + * @param id - 预设 ID + * @returns 对应的预设,如果不存在则返回 undefined + */ +export function getPresetById(id: string): ThemePreset | undefined { + return builtInPresets.find((preset) => preset.id === id) +}