feat(theme): add design token schema, palette derivation, CSS sanitizer, and storage manager

This commit is contained in:
DrSmoothl
2026-02-19 17:16:28 +08:00
parent 0a572515ba
commit 6aa1132f4c
4 changed files with 872 additions and 0 deletions

View File

@@ -0,0 +1,205 @@
/**
* 主题配置的 localStorage 存储管理模块
* 统一处理主题相关的存储操作,包括加载、保存、导出、导入和迁移旧 key
*/
import type { UserThemeConfig } from './tokens'
/**
* 主题存储 key 定义
* 统一使用 'maibot-theme-*' 前缀,替代现有的 'ui-theme'、'maibot-ui-theme' 和 'accent-color'
*/
export const THEME_STORAGE_KEYS = {
MODE: 'maibot-theme-mode',
PRESET: 'maibot-theme-preset',
ACCENT: 'maibot-theme-accent',
OVERRIDES: 'maibot-theme-overrides',
CUSTOM_CSS: 'maibot-theme-custom-css',
} as const
/**
* 默认主题配置
*/
const DEFAULT_THEME_CONFIG: UserThemeConfig = {
selectedPreset: 'light',
accentColor: 'blue',
tokenOverrides: {},
customCSS: '',
}
/**
* 从 localStorage 加载完整主题配置
* 缺失值使用合理默认值
*
* @returns 加载的主题配置对象
*/
export function loadThemeConfig(): UserThemeConfig {
const preset = localStorage.getItem(THEME_STORAGE_KEYS.PRESET)
const accent = localStorage.getItem(THEME_STORAGE_KEYS.ACCENT)
const overridesStr = localStorage.getItem(THEME_STORAGE_KEYS.OVERRIDES)
const customCSS = localStorage.getItem(THEME_STORAGE_KEYS.CUSTOM_CSS)
// 解析 tokenOverrides JSON
let tokenOverrides = {}
if (overridesStr) {
try {
tokenOverrides = JSON.parse(overridesStr)
} catch {
// JSON 解析失败,使用空对象
tokenOverrides = {}
}
}
return {
selectedPreset: preset || DEFAULT_THEME_CONFIG.selectedPreset,
accentColor: accent || DEFAULT_THEME_CONFIG.accentColor,
tokenOverrides,
customCSS: customCSS || DEFAULT_THEME_CONFIG.customCSS,
}
}
/**
* 保存完整主题配置到 localStorage
*
* @param config - 要保存的主题配置
*/
export function saveThemeConfig(config: UserThemeConfig): void {
localStorage.setItem(THEME_STORAGE_KEYS.PRESET, config.selectedPreset)
localStorage.setItem(THEME_STORAGE_KEYS.ACCENT, config.accentColor)
localStorage.setItem(THEME_STORAGE_KEYS.OVERRIDES, JSON.stringify(config.tokenOverrides))
localStorage.setItem(THEME_STORAGE_KEYS.CUSTOM_CSS, config.customCSS)
}
/**
* 部分更新主题配置
* 先加载现有配置,合并部分更新,再保存
*
* @param partial - 部分主题配置更新
*/
export function saveThemePartial(partial: Partial<UserThemeConfig>): void {
const current = loadThemeConfig()
const updated: UserThemeConfig = {
...current,
...partial,
}
saveThemeConfig(updated)
}
/**
* 导出主题配置为美化格式的 JSON 字符串
*
* @returns 格式化的 JSON 字符串
*/
export function exportThemeJSON(): string {
const config = loadThemeConfig()
return JSON.stringify(config, null, 2)
}
/**
* 从 JSON 字符串导入主题配置
* 包含基础的格式和字段校验
*
* @param json - JSON 字符串
* @returns 导入结果,包含成功状态和错误列表
*/
export function importThemeJSON(
json: string,
): { success: boolean; errors: string[] } {
const errors: string[] = []
// JSON 格式校验
let config: unknown
try {
config = JSON.parse(json)
} catch (error) {
return {
success: false,
errors: [`Invalid JSON format: ${error instanceof Error ? error.message : 'Unknown error'}`],
}
}
// 基本对象类型校验
if (typeof config !== 'object' || config === null) {
return {
success: false,
errors: ['Configuration must be a JSON object'],
}
}
const configObj = config as Record<string, unknown>
// 必要字段存在性校验
if (typeof configObj.selectedPreset !== 'string') {
errors.push('selectedPreset must be a string')
}
if (typeof configObj.accentColor !== 'string') {
errors.push('accentColor must be a string')
}
if (typeof configObj.customCSS !== 'string') {
errors.push('customCSS must be a string')
}
if (configObj.tokenOverrides !== undefined && typeof configObj.tokenOverrides !== 'object') {
errors.push('tokenOverrides must be an object')
}
if (errors.length > 0) {
return { success: false, errors }
}
// 校验通过,保存配置
const validConfig: UserThemeConfig = {
selectedPreset: configObj.selectedPreset as string,
accentColor: configObj.accentColor as string,
tokenOverrides: (configObj.tokenOverrides as Partial<any>) || {},
customCSS: configObj.customCSS as string,
}
saveThemeConfig(validConfig)
return { success: true, errors: [] }
}
/**
* 重置主题配置为默认值
* 删除所有 THEME_STORAGE_KEYS 对应的 localStorage 项
*/
export function resetThemeToDefault(): void {
Object.values(THEME_STORAGE_KEYS).forEach((key) => {
localStorage.removeItem(key)
})
}
/**
* 迁移旧的 localStorage key 到新 key
* 处理:
* - 'ui-theme' 或 'maibot-ui-theme' → 'maibot-theme-mode'
* - 'accent-color' → 'maibot-theme-accent'
* 迁移完成后删除旧 key避免重复迁移
*/
export function migrateOldKeys(): void {
// 迁移主题模式
// 优先使用 'ui-theme'(因为 ThemeProvider 默认使用它)
const uiTheme = localStorage.getItem('ui-theme')
const maiTheme = localStorage.getItem('maibot-ui-theme')
const newMode = localStorage.getItem(THEME_STORAGE_KEYS.MODE)
if (!newMode) {
if (uiTheme) {
localStorage.setItem(THEME_STORAGE_KEYS.MODE, uiTheme)
} else if (maiTheme) {
localStorage.setItem(THEME_STORAGE_KEYS.MODE, maiTheme)
}
}
// 迁移强调色
const accentColor = localStorage.getItem('accent-color')
const newAccent = localStorage.getItem(THEME_STORAGE_KEYS.ACCENT)
if (accentColor && !newAccent) {
localStorage.setItem(THEME_STORAGE_KEYS.ACCENT, accentColor)
}
// 删除旧 key
localStorage.removeItem('ui-theme')
localStorage.removeItem('maibot-ui-theme')
localStorage.removeItem('accent-color')
}