feat(theme): add CSS injection pipeline, presets, rewrite ThemeProvider, FOUC prevention

pull/1496/head
DrSmoothl 2026-02-19 17:26:53 +08:00
parent 6aa1132f4c
commit 8fb137a318
5 changed files with 284 additions and 114 deletions

View File

@ -11,6 +11,17 @@
<link rel="icon" type="image/x-icon" href="/maimai.ico" /> <link rel="icon" type="image/x-icon" href="/maimai.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>MaiBot Dashboard</title> <title>MaiBot Dashboard</title>
<script>
(function() {
const mode = localStorage.getItem('maibot-theme-mode')
|| localStorage.getItem('ui-theme')
|| localStorage.getItem('maibot-ui-theme');
const theme = mode === 'system' || !mode
? window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
: mode;
document.documentElement.classList.add(theme);
})();
</script>
</head> </head>
<body> <body>
<div id="root" class="notranslate"></div> <div id="root" class="notranslate"></div>

View File

@ -1,6 +1,16 @@
import { useEffect, useState } from 'react' import { useCallback, useEffect, useMemo, useState } from 'react'
import type { ReactNode } from 'react' import type { ReactNode } from 'react'
import { ThemeProviderContext } from '@/lib/theme-context' 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' type Theme = 'dark' | 'light' | 'system'
@ -13,126 +23,74 @@ type ThemeProviderProps = {
export function ThemeProvider({ export function ThemeProvider({
children, children,
defaultTheme = 'system', defaultTheme = 'system',
storageKey = 'ui-theme', storageKey: _storageKey,
...props
}: ThemeProviderProps) { }: ThemeProviderProps) {
const [theme, setTheme] = useState<Theme>( const [themeMode, setThemeMode] = useState<Theme>(() => {
() => (localStorage.getItem(storageKey) as Theme) || defaultTheme const saved = localStorage.getItem(THEME_STORAGE_KEYS.MODE) as Theme | null
) return saved || defaultTheme
})
const [themeConfig, setThemeConfig] = useState<UserThemeConfig>(() => 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(() => { useEffect(() => {
const root = window.document.documentElement migrateOldKeys()
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')
}
}
}
}, []) }, [])
const value = { useEffect(() => {
theme, const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)')
setTheme: (theme: Theme) => { const handleChange = () => {
localStorage.setItem(storageKey, theme) if (themeMode === 'system') {
setTheme(theme) 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<UserThemeConfig>) => {
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 ( return (
<ThemeProviderContext.Provider {...props} value={value}> <ThemeProviderContext.Provider value={value}>
{children} {children}
</ThemeProviderContext.Provider> </ThemeProviderContext.Provider>
) )

View File

@ -1,15 +1,30 @@
import { createContext } from 'react' import { createContext } from 'react'
import type { UserThemeConfig } from './theme/tokens'
type Theme = 'dark' | 'light' | 'system' type Theme = 'dark' | 'light' | 'system'
export type ThemeProviderState = { export type ThemeProviderState = {
theme: Theme theme: Theme
resolvedTheme: 'dark' | 'light'
setTheme: (theme: Theme) => void setTheme: (theme: Theme) => void
themeConfig: UserThemeConfig
updateThemeConfig: (partial: Partial<UserThemeConfig>) => void
resetTheme: () => void
} }
const initialState: ThemeProviderState = { const initialState: ThemeProviderState = {
theme: 'system', theme: 'system',
resolvedTheme: 'light',
setTheme: () => null, setTheme: () => null,
themeConfig: {
selectedPreset: 'light',
accentColor: '',
tokenOverrides: {},
customCSS: '',
},
updateThemeConfig: () => null,
resetTheme: () => null,
} }
export const ThemeProviderContext = createContext<ThemeProviderState>(initialState) export const ThemeProviderContext = createContext<ThemeProviderState>(initialState)

View File

@ -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>): 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()
}

View File

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