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)
+}