mirror of https://github.com/Mai-with-u/MaiBot.git
feat(theme): add CSS injection pipeline, presets, rewrite ThemeProvider, FOUC prevention
parent
6aa1132f4c
commit
8fb137a318
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue