mirror of https://github.com/Mai-with-u/MaiBot.git
feat(theme): add design token schema, palette derivation, CSS sanitizer, and storage manager
parent
0a572515ba
commit
6aa1132f4c
|
|
@ -0,0 +1,203 @@
|
||||||
|
import type { ColorTokens } from './tokens'
|
||||||
|
|
||||||
|
type HSL = {
|
||||||
|
h: number
|
||||||
|
s: number
|
||||||
|
l: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const clamp = (value: number, min: number, max: number): number => {
|
||||||
|
if (value < min) return min
|
||||||
|
if (value > max) return max
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
const roundToTenth = (value: number): number => Math.round(value * 10) / 10
|
||||||
|
|
||||||
|
const wrapHue = (value: number): number => ((value % 360) + 360) % 360
|
||||||
|
|
||||||
|
export const parseHSL = (hslStr: string): HSL => {
|
||||||
|
const cleaned = hslStr
|
||||||
|
.trim()
|
||||||
|
.replace(/^hsl\(/i, '')
|
||||||
|
.replace(/\)$/i, '')
|
||||||
|
.replace(/,/g, ' ')
|
||||||
|
const parts = cleaned.split(/\s+/).filter(Boolean)
|
||||||
|
const rawH = parts[0] ?? '0'
|
||||||
|
const rawS = parts[1] ?? '0%'
|
||||||
|
const rawL = parts[2] ?? '0%'
|
||||||
|
|
||||||
|
const h = Number.parseFloat(rawH)
|
||||||
|
const s = Number.parseFloat(rawS.replace('%', ''))
|
||||||
|
const l = Number.parseFloat(rawL.replace('%', ''))
|
||||||
|
|
||||||
|
return {
|
||||||
|
h: Number.isNaN(h) ? 0 : h,
|
||||||
|
s: Number.isNaN(s) ? 0 : s,
|
||||||
|
l: Number.isNaN(l) ? 0 : l,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const formatHSL = (h: number, s: number, l: number): string => {
|
||||||
|
const safeH = roundToTenth(wrapHue(h))
|
||||||
|
const safeS = roundToTenth(clamp(s, 0, 100))
|
||||||
|
const safeL = roundToTenth(clamp(l, 0, 100))
|
||||||
|
return `${safeH} ${safeS}% ${safeL}%`
|
||||||
|
}
|
||||||
|
|
||||||
|
export const hexToHSL = (hex: string): string => {
|
||||||
|
let cleaned = hex.trim().replace('#', '')
|
||||||
|
if (cleaned.length === 3) {
|
||||||
|
cleaned = cleaned
|
||||||
|
.split('')
|
||||||
|
.map((char) => `${char}${char}`)
|
||||||
|
.join('')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cleaned.length !== 6) {
|
||||||
|
return formatHSL(0, 0, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
const r = Number.parseInt(cleaned.slice(0, 2), 16) / 255
|
||||||
|
const g = Number.parseInt(cleaned.slice(2, 4), 16) / 255
|
||||||
|
const b = Number.parseInt(cleaned.slice(4, 6), 16) / 255
|
||||||
|
|
||||||
|
const max = Math.max(r, g, b)
|
||||||
|
const min = Math.min(r, g, b)
|
||||||
|
const delta = max - min
|
||||||
|
const l = (max + min) / 2
|
||||||
|
|
||||||
|
let h = 0
|
||||||
|
let s = 0
|
||||||
|
|
||||||
|
if (delta !== 0) {
|
||||||
|
s = l > 0.5 ? delta / (2 - max - min) : delta / (max + min)
|
||||||
|
switch (max) {
|
||||||
|
case r:
|
||||||
|
h = (g - b) / delta + (g < b ? 6 : 0)
|
||||||
|
break
|
||||||
|
case g:
|
||||||
|
h = (b - r) / delta + 2
|
||||||
|
break
|
||||||
|
case b:
|
||||||
|
h = (r - g) / delta + 4
|
||||||
|
break
|
||||||
|
default:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
h *= 60
|
||||||
|
}
|
||||||
|
|
||||||
|
return formatHSL(h, s * 100, l * 100)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const adjustLightness = (hsl: string, amount: number): string => {
|
||||||
|
const { h, s, l } = parseHSL(hsl)
|
||||||
|
return formatHSL(h, s, l + amount)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const adjustSaturation = (hsl: string, amount: number): string => {
|
||||||
|
const { h, s, l } = parseHSL(hsl)
|
||||||
|
return formatHSL(h, s + amount, l)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const rotateHue = (hsl: string, degrees: number): string => {
|
||||||
|
const { h, s, l } = parseHSL(hsl)
|
||||||
|
return formatHSL(h + degrees, s, l)
|
||||||
|
}
|
||||||
|
|
||||||
|
const setLightness = (hsl: string, lightness: number): string => {
|
||||||
|
const { h, s } = parseHSL(hsl)
|
||||||
|
return formatHSL(h, s, lightness)
|
||||||
|
}
|
||||||
|
|
||||||
|
const setSaturation = (hsl: string, saturation: number): string => {
|
||||||
|
const { h, l } = parseHSL(hsl)
|
||||||
|
return formatHSL(h, saturation, l)
|
||||||
|
}
|
||||||
|
|
||||||
|
const getReadableForeground = (hsl: string): string => {
|
||||||
|
const { h, s, l } = parseHSL(hsl)
|
||||||
|
const neutralSaturation = clamp(s * 0.15, 6, 20)
|
||||||
|
return l > 60
|
||||||
|
? formatHSL(h, neutralSaturation, 10)
|
||||||
|
: formatHSL(h, neutralSaturation, 96)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const generatePalette = (accentHSL: string, isDark: boolean): ColorTokens => {
|
||||||
|
const accent = parseHSL(accentHSL)
|
||||||
|
const primary = formatHSL(accent.h, accent.s, accent.l)
|
||||||
|
|
||||||
|
const background = isDark ? '222.2 84% 4.9%' : '0 0% 100%'
|
||||||
|
const foreground = isDark ? '210 40% 98%' : '222.2 84% 4.9%'
|
||||||
|
|
||||||
|
const secondary = formatHSL(
|
||||||
|
accent.h,
|
||||||
|
clamp(accent.s * 0.35, 8, 40),
|
||||||
|
isDark ? 17.5 : 96,
|
||||||
|
)
|
||||||
|
|
||||||
|
const muted = formatHSL(
|
||||||
|
accent.h,
|
||||||
|
clamp(accent.s * 0.12, 2, 18),
|
||||||
|
isDark ? 17.5 : 96,
|
||||||
|
)
|
||||||
|
|
||||||
|
const accentVariant = formatHSL(
|
||||||
|
accent.h + 35,
|
||||||
|
clamp(accent.s * 0.6, 20, 85),
|
||||||
|
isDark ? clamp(accent.l * 0.6 + 8, 25, 60) : clamp(accent.l * 0.8 + 14, 40, 75),
|
||||||
|
)
|
||||||
|
|
||||||
|
const destructive = formatHSL(
|
||||||
|
0,
|
||||||
|
clamp(accent.s, 60, 90),
|
||||||
|
isDark ? 30.6 : 60.2,
|
||||||
|
)
|
||||||
|
|
||||||
|
const border = formatHSL(
|
||||||
|
accent.h,
|
||||||
|
clamp(accent.s * 0.2, 5, 25),
|
||||||
|
isDark ? 17.5 : 91.4,
|
||||||
|
)
|
||||||
|
|
||||||
|
const mutedForeground = setSaturation(
|
||||||
|
setLightness(muted, isDark ? 65.1 : 46.9),
|
||||||
|
clamp(accent.s * 0.2, 10, 30),
|
||||||
|
)
|
||||||
|
|
||||||
|
const chartBase = formatHSL(accent.h, accent.s, accent.l)
|
||||||
|
const chartSteps = [0, 72, 144, 216, 288]
|
||||||
|
const charts = chartSteps.map((step) => rotateHue(chartBase, step))
|
||||||
|
|
||||||
|
const card = adjustLightness(background, isDark ? 2 : -1)
|
||||||
|
const popover = adjustLightness(background, isDark ? 3 : -0.5)
|
||||||
|
|
||||||
|
return {
|
||||||
|
primary,
|
||||||
|
'primary-foreground': getReadableForeground(primary),
|
||||||
|
'primary-gradient': 'none',
|
||||||
|
secondary,
|
||||||
|
'secondary-foreground': getReadableForeground(secondary),
|
||||||
|
muted,
|
||||||
|
'muted-foreground': mutedForeground,
|
||||||
|
accent: accentVariant,
|
||||||
|
'accent-foreground': getReadableForeground(accentVariant),
|
||||||
|
destructive,
|
||||||
|
'destructive-foreground': getReadableForeground(destructive),
|
||||||
|
background,
|
||||||
|
foreground,
|
||||||
|
card,
|
||||||
|
'card-foreground': foreground,
|
||||||
|
popover,
|
||||||
|
'popover-foreground': foreground,
|
||||||
|
border,
|
||||||
|
input: border,
|
||||||
|
ring: primary,
|
||||||
|
'chart-1': charts[0],
|
||||||
|
'chart-2': charts[1],
|
||||||
|
'chart-3': charts[2],
|
||||||
|
'chart-4': charts[3],
|
||||||
|
'chart-5': charts[4],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,111 @@
|
||||||
|
/**
|
||||||
|
* CSS 安全过滤器 - 用于过滤用户自定义 CSS 中的危险内容
|
||||||
|
* 防范外部资源加载和 XSS 注入
|
||||||
|
*/
|
||||||
|
|
||||||
|
interface SanitizeResult {
|
||||||
|
css: string
|
||||||
|
warnings: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 过滤规则:基于正则表达式的危险模式检测
|
||||||
|
* 与匹配的危险模式相关的警告消息
|
||||||
|
*/
|
||||||
|
interface FilterRule {
|
||||||
|
pattern: RegExp
|
||||||
|
message: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 定义所有过滤规则
|
||||||
|
*/
|
||||||
|
const filterRules: FilterRule[] = [
|
||||||
|
{
|
||||||
|
pattern: /@import\s+(?:url\()?['"]?(?:https?:|\/\/)?[^)'"]+['"]?\)?[;]?/gi,
|
||||||
|
message: '移除 @import 语句(禁止加载外部资源)',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
pattern: /url\s*\(\s*(?:https?:|\/\/|data:|javascript:)[^)]*\)/gi,
|
||||||
|
message: '移除 url() 调用(禁止外部请求)',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
pattern: /javascript:/gi,
|
||||||
|
message: '移除 javascript: 协议(XSS 防护)',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
pattern: /expression\s*\(\s*[^)]*\)/gi,
|
||||||
|
message: '移除 expression() 函数(IE 遗留 XSS 向量)',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
pattern: /-moz-binding\s*:\s*[^;]+/gi,
|
||||||
|
message: '移除 -moz-binding 属性(Firefox XSS 向量)',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
pattern: /behavior\s*:\s*[^;]+/gi,
|
||||||
|
message: '移除 behavior: 属性(IE HTC)',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将原始 CSS 按行分割并跟踪行号
|
||||||
|
*/
|
||||||
|
function splitCSSByLines(css: string): string[] {
|
||||||
|
return css.split(/\r?\n/)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 在 CSS 中查找模式匹配的行号
|
||||||
|
*/
|
||||||
|
function findMatchingLineNumbers(css: string, pattern: RegExp): number[] {
|
||||||
|
const lines = splitCSSByLines(css)
|
||||||
|
const matchingLines: number[] = []
|
||||||
|
|
||||||
|
lines.forEach((line, index) => {
|
||||||
|
if (pattern.test(line)) {
|
||||||
|
matchingLines.push(index + 1) // 行号从 1 开始
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return matchingLines
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 过滤 CSS 中的危险内容
|
||||||
|
* @param rawCSS 原始 CSS 字符串
|
||||||
|
* @returns 包含过滤后的 CSS 和警告列表的对象
|
||||||
|
*/
|
||||||
|
export function sanitizeCSS(rawCSS: string): SanitizeResult {
|
||||||
|
let sanitizedCSS = rawCSS
|
||||||
|
const warnings: string[] = []
|
||||||
|
|
||||||
|
// 应用所有过滤规则
|
||||||
|
filterRules.forEach((rule) => {
|
||||||
|
const lineNumbers = findMatchingLineNumbers(sanitizedCSS, rule.pattern)
|
||||||
|
|
||||||
|
// 对每个匹配的行生成警告
|
||||||
|
lineNumbers.forEach((lineNum) => {
|
||||||
|
warnings.push(`Line ${lineNum}: ${rule.message}`)
|
||||||
|
})
|
||||||
|
|
||||||
|
// 从 CSS 中移除匹配内容
|
||||||
|
sanitizedCSS = sanitizedCSS.replace(rule.pattern, '')
|
||||||
|
})
|
||||||
|
|
||||||
|
// 清理多余的空白行
|
||||||
|
sanitizedCSS = sanitizedCSS.replace(/\n\s*\n/g, '\n').trim()
|
||||||
|
|
||||||
|
return {
|
||||||
|
css: sanitizedCSS,
|
||||||
|
warnings,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 快速检查 CSS 是否包含危险模式
|
||||||
|
* @param css CSS 字符串
|
||||||
|
* @returns 如果包含危险模式返回 true,否则返回 false
|
||||||
|
*/
|
||||||
|
export function isCSSSafe(css: string): boolean {
|
||||||
|
return !filterRules.some((rule) => rule.pattern.test(css))
|
||||||
|
}
|
||||||
|
|
@ -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')
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,353 @@
|
||||||
|
/**
|
||||||
|
* Design Token Schema 定义
|
||||||
|
* 集中管理所有设计令牌(颜色、排版、间距、阴影、动画等)
|
||||||
|
*/
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Color Tokens 类型定义
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export type ColorTokens = {
|
||||||
|
primary: string
|
||||||
|
'primary-foreground': string
|
||||||
|
'primary-gradient': string
|
||||||
|
secondary: string
|
||||||
|
'secondary-foreground': string
|
||||||
|
muted: string
|
||||||
|
'muted-foreground': string
|
||||||
|
accent: string
|
||||||
|
'accent-foreground': string
|
||||||
|
destructive: string
|
||||||
|
'destructive-foreground': string
|
||||||
|
background: string
|
||||||
|
foreground: string
|
||||||
|
card: string
|
||||||
|
'card-foreground': string
|
||||||
|
popover: string
|
||||||
|
'popover-foreground': string
|
||||||
|
border: string
|
||||||
|
input: string
|
||||||
|
ring: string
|
||||||
|
'chart-1': string
|
||||||
|
'chart-2': string
|
||||||
|
'chart-3': string
|
||||||
|
'chart-4': string
|
||||||
|
'chart-5': string
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Typography Tokens 类型定义
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export type TypographyTokens = {
|
||||||
|
'font-family-base': string
|
||||||
|
'font-family-code': string
|
||||||
|
'font-size-xs': string
|
||||||
|
'font-size-sm': string
|
||||||
|
'font-size-base': string
|
||||||
|
'font-size-lg': string
|
||||||
|
'font-size-xl': string
|
||||||
|
'font-size-2xl': string
|
||||||
|
'font-weight-normal': number
|
||||||
|
'font-weight-medium': number
|
||||||
|
'font-weight-semibold': number
|
||||||
|
'font-weight-bold': number
|
||||||
|
'line-height-tight': number
|
||||||
|
'line-height-normal': number
|
||||||
|
'line-height-relaxed': number
|
||||||
|
'letter-spacing-tight': string
|
||||||
|
'letter-spacing-normal': string
|
||||||
|
'letter-spacing-wide': string
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Visual Tokens 类型定义
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export type VisualTokens = {
|
||||||
|
'radius-sm': string
|
||||||
|
'radius-md': string
|
||||||
|
'radius-lg': string
|
||||||
|
'radius-xl': string
|
||||||
|
'radius-full': string
|
||||||
|
'shadow-sm': string
|
||||||
|
'shadow-md': string
|
||||||
|
'shadow-lg': string
|
||||||
|
'shadow-xl': string
|
||||||
|
'blur-sm': string
|
||||||
|
'blur-md': string
|
||||||
|
'blur-lg': string
|
||||||
|
'opacity-disabled': number
|
||||||
|
'opacity-hover': number
|
||||||
|
'opacity-overlay': number
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Layout Tokens 类型定义
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export type LayoutTokens = {
|
||||||
|
'space-unit': string
|
||||||
|
'space-xs': string
|
||||||
|
'space-sm': string
|
||||||
|
'space-md': string
|
||||||
|
'space-lg': string
|
||||||
|
'space-xl': string
|
||||||
|
'space-2xl': string
|
||||||
|
'sidebar-width': string
|
||||||
|
'header-height': string
|
||||||
|
'max-content-width': string
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Animation Tokens 类型定义
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export type AnimationTokens = {
|
||||||
|
'anim-duration-fast': string
|
||||||
|
'anim-duration-normal': string
|
||||||
|
'anim-duration-slow': string
|
||||||
|
'anim-easing-default': string
|
||||||
|
'anim-easing-in': string
|
||||||
|
'anim-easing-out': string
|
||||||
|
'anim-easing-in-out': string
|
||||||
|
'transition-colors': string
|
||||||
|
'transition-transform': string
|
||||||
|
'transition-opacity': string
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Aggregated Theme Tokens
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export type ThemeTokens = {
|
||||||
|
color: ColorTokens
|
||||||
|
typography: TypographyTokens
|
||||||
|
visual: VisualTokens
|
||||||
|
layout: LayoutTokens
|
||||||
|
animation: AnimationTokens
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Theme Preset & Config Types
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export type ThemePreset = {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
description: string
|
||||||
|
tokens: ThemeTokens
|
||||||
|
isDark: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export type UserThemeConfig = {
|
||||||
|
selectedPreset: string
|
||||||
|
accentColor: string
|
||||||
|
tokenOverrides: Partial<ThemeTokens>
|
||||||
|
customCSS: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Default Light Tokens (from index.css :root)
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export const defaultLightTokens: ThemeTokens = {
|
||||||
|
color: {
|
||||||
|
primary: '221.2 83.2% 53.3%',
|
||||||
|
'primary-foreground': '210 40% 98%',
|
||||||
|
'primary-gradient': 'none',
|
||||||
|
secondary: '210 40% 96.1%',
|
||||||
|
'secondary-foreground': '222.2 47.4% 11.2%',
|
||||||
|
muted: '210 40% 96.1%',
|
||||||
|
'muted-foreground': '215.4 16.3% 46.9%',
|
||||||
|
accent: '210 40% 96.1%',
|
||||||
|
'accent-foreground': '222.2 47.4% 11.2%',
|
||||||
|
destructive: '0 84.2% 60.2%',
|
||||||
|
'destructive-foreground': '210 40% 98%',
|
||||||
|
background: '0 0% 100%',
|
||||||
|
foreground: '222.2 84% 4.9%',
|
||||||
|
card: '0 0% 100%',
|
||||||
|
'card-foreground': '222.2 84% 4.9%',
|
||||||
|
popover: '0 0% 100%',
|
||||||
|
'popover-foreground': '222.2 84% 4.9%',
|
||||||
|
border: '214.3 31.8% 91.4%',
|
||||||
|
input: '214.3 31.8% 91.4%',
|
||||||
|
ring: '221.2 83.2% 53.3%',
|
||||||
|
'chart-1': '221.2 83.2% 53.3%',
|
||||||
|
'chart-2': '160 60% 45%',
|
||||||
|
'chart-3': '30 80% 55%',
|
||||||
|
'chart-4': '280 65% 60%',
|
||||||
|
'chart-5': '340 75% 55%',
|
||||||
|
},
|
||||||
|
typography: {
|
||||||
|
'font-family-base': '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif',
|
||||||
|
'font-family-code': '"JetBrains Mono", "Monaco", "Courier New", monospace',
|
||||||
|
'font-size-xs': '0.75rem',
|
||||||
|
'font-size-sm': '0.875rem',
|
||||||
|
'font-size-base': '1rem',
|
||||||
|
'font-size-lg': '1.125rem',
|
||||||
|
'font-size-xl': '1.25rem',
|
||||||
|
'font-size-2xl': '1.5rem',
|
||||||
|
'font-weight-normal': 400,
|
||||||
|
'font-weight-medium': 500,
|
||||||
|
'font-weight-semibold': 600,
|
||||||
|
'font-weight-bold': 700,
|
||||||
|
'line-height-tight': 1.2,
|
||||||
|
'line-height-normal': 1.5,
|
||||||
|
'line-height-relaxed': 1.75,
|
||||||
|
'letter-spacing-tight': '-0.02em',
|
||||||
|
'letter-spacing-normal': '0em',
|
||||||
|
'letter-spacing-wide': '0.02em',
|
||||||
|
},
|
||||||
|
visual: {
|
||||||
|
'radius-sm': '0.25rem',
|
||||||
|
'radius-md': '0.375rem',
|
||||||
|
'radius-lg': '0.5rem',
|
||||||
|
'radius-xl': '0.75rem',
|
||||||
|
'radius-full': '9999px',
|
||||||
|
'shadow-sm': '0 1px 2px 0 rgba(0, 0, 0, 0.05)',
|
||||||
|
'shadow-md': '0 4px 6px -1px rgba(0, 0, 0, 0.1)',
|
||||||
|
'shadow-lg': '0 10px 15px -3px rgba(0, 0, 0, 0.1)',
|
||||||
|
'shadow-xl': '0 20px 25px -5px rgba(0, 0, 0, 0.1)',
|
||||||
|
'blur-sm': '4px',
|
||||||
|
'blur-md': '12px',
|
||||||
|
'blur-lg': '24px',
|
||||||
|
'opacity-disabled': 0.5,
|
||||||
|
'opacity-hover': 0.8,
|
||||||
|
'opacity-overlay': 0.75,
|
||||||
|
},
|
||||||
|
layout: {
|
||||||
|
'space-unit': '0.25rem',
|
||||||
|
'space-xs': '0.5rem',
|
||||||
|
'space-sm': '0.75rem',
|
||||||
|
'space-md': '1rem',
|
||||||
|
'space-lg': '1.5rem',
|
||||||
|
'space-xl': '2rem',
|
||||||
|
'space-2xl': '3rem',
|
||||||
|
'sidebar-width': '16rem',
|
||||||
|
'header-height': '3.5rem',
|
||||||
|
'max-content-width': '1280px',
|
||||||
|
},
|
||||||
|
animation: {
|
||||||
|
'anim-duration-fast': '150ms',
|
||||||
|
'anim-duration-normal': '300ms',
|
||||||
|
'anim-duration-slow': '500ms',
|
||||||
|
'anim-easing-default': 'cubic-bezier(0.4, 0, 0.2, 1)',
|
||||||
|
'anim-easing-in': 'cubic-bezier(0.4, 0, 1, 1)',
|
||||||
|
'anim-easing-out': 'cubic-bezier(0, 0, 0.2, 1)',
|
||||||
|
'anim-easing-in-out': 'cubic-bezier(0.4, 0, 0.2, 1)',
|
||||||
|
'transition-colors': 'color 300ms cubic-bezier(0.4, 0, 0.2, 1)',
|
||||||
|
'transition-transform': 'transform 300ms cubic-bezier(0.4, 0, 0.2, 1)',
|
||||||
|
'transition-opacity': 'opacity 300ms cubic-bezier(0.4, 0, 0.2, 1)',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Default Dark Tokens (from index.css .dark)
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export const defaultDarkTokens: ThemeTokens = {
|
||||||
|
color: {
|
||||||
|
primary: '217.2 91.2% 59.8%',
|
||||||
|
'primary-foreground': '210 40% 98%',
|
||||||
|
'primary-gradient': 'none',
|
||||||
|
secondary: '217.2 32.6% 17.5%',
|
||||||
|
'secondary-foreground': '210 40% 98%',
|
||||||
|
muted: '217.2 32.6% 17.5%',
|
||||||
|
'muted-foreground': '215 20.2% 65.1%',
|
||||||
|
accent: '217.2 32.6% 17.5%',
|
||||||
|
'accent-foreground': '210 40% 98%',
|
||||||
|
destructive: '0 62.8% 30.6%',
|
||||||
|
'destructive-foreground': '210 40% 98%',
|
||||||
|
background: '222.2 84% 4.9%',
|
||||||
|
foreground: '210 40% 98%',
|
||||||
|
card: '222.2 84% 4.9%',
|
||||||
|
'card-foreground': '210 40% 98%',
|
||||||
|
popover: '222.2 84% 4.9%',
|
||||||
|
'popover-foreground': '210 40% 98%',
|
||||||
|
border: '217.2 32.6% 17.5%',
|
||||||
|
input: '217.2 32.6% 17.5%',
|
||||||
|
ring: '224.3 76.3% 48%',
|
||||||
|
'chart-1': '217.2 91.2% 59.8%',
|
||||||
|
'chart-2': '160 60% 50%',
|
||||||
|
'chart-3': '30 80% 60%',
|
||||||
|
'chart-4': '280 65% 65%',
|
||||||
|
'chart-5': '340 75% 60%',
|
||||||
|
},
|
||||||
|
typography: {
|
||||||
|
'font-family-base': '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif',
|
||||||
|
'font-family-code': '"JetBrains Mono", "Monaco", "Courier New", monospace',
|
||||||
|
'font-size-xs': '0.75rem',
|
||||||
|
'font-size-sm': '0.875rem',
|
||||||
|
'font-size-base': '1rem',
|
||||||
|
'font-size-lg': '1.125rem',
|
||||||
|
'font-size-xl': '1.25rem',
|
||||||
|
'font-size-2xl': '1.5rem',
|
||||||
|
'font-weight-normal': 400,
|
||||||
|
'font-weight-medium': 500,
|
||||||
|
'font-weight-semibold': 600,
|
||||||
|
'font-weight-bold': 700,
|
||||||
|
'line-height-tight': 1.2,
|
||||||
|
'line-height-normal': 1.5,
|
||||||
|
'line-height-relaxed': 1.75,
|
||||||
|
'letter-spacing-tight': '-0.02em',
|
||||||
|
'letter-spacing-normal': '0em',
|
||||||
|
'letter-spacing-wide': '0.02em',
|
||||||
|
},
|
||||||
|
visual: {
|
||||||
|
'radius-sm': '0.25rem',
|
||||||
|
'radius-md': '0.375rem',
|
||||||
|
'radius-lg': '0.5rem',
|
||||||
|
'radius-xl': '0.75rem',
|
||||||
|
'radius-full': '9999px',
|
||||||
|
'shadow-sm': '0 1px 2px 0 rgba(0, 0, 0, 0.25)',
|
||||||
|
'shadow-md': '0 4px 6px -1px rgba(0, 0, 0, 0.3)',
|
||||||
|
'shadow-lg': '0 10px 15px -3px rgba(0, 0, 0, 0.4)',
|
||||||
|
'shadow-xl': '0 20px 25px -5px rgba(0, 0, 0, 0.5)',
|
||||||
|
'blur-sm': '4px',
|
||||||
|
'blur-md': '12px',
|
||||||
|
'blur-lg': '24px',
|
||||||
|
'opacity-disabled': 0.5,
|
||||||
|
'opacity-hover': 0.8,
|
||||||
|
'opacity-overlay': 0.75,
|
||||||
|
},
|
||||||
|
layout: {
|
||||||
|
'space-unit': '0.25rem',
|
||||||
|
'space-xs': '0.5rem',
|
||||||
|
'space-sm': '0.75rem',
|
||||||
|
'space-md': '1rem',
|
||||||
|
'space-lg': '1.5rem',
|
||||||
|
'space-xl': '2rem',
|
||||||
|
'space-2xl': '3rem',
|
||||||
|
'sidebar-width': '16rem',
|
||||||
|
'header-height': '3.5rem',
|
||||||
|
'max-content-width': '1280px',
|
||||||
|
},
|
||||||
|
animation: {
|
||||||
|
'anim-duration-fast': '150ms',
|
||||||
|
'anim-duration-normal': '300ms',
|
||||||
|
'anim-duration-slow': '500ms',
|
||||||
|
'anim-easing-default': 'cubic-bezier(0.4, 0, 0.2, 1)',
|
||||||
|
'anim-easing-in': 'cubic-bezier(0.4, 0, 1, 1)',
|
||||||
|
'anim-easing-out': 'cubic-bezier(0, 0, 0.2, 1)',
|
||||||
|
'anim-easing-in-out': 'cubic-bezier(0.4, 0, 0.2, 1)',
|
||||||
|
'transition-colors': 'color 300ms cubic-bezier(0.4, 0, 0.2, 1)',
|
||||||
|
'transition-transform': 'transform 300ms cubic-bezier(0.4, 0, 0.2, 1)',
|
||||||
|
'transition-opacity': 'opacity 300ms cubic-bezier(0.4, 0, 0.2, 1)',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Token Utility Functions
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将 Token 类别和 key 转换为 CSS 变量名
|
||||||
|
* @example tokenToCSSVarName('color', 'primary') => '--color-primary'
|
||||||
|
*/
|
||||||
|
export function tokenToCSSVarName(
|
||||||
|
category: keyof ThemeTokens | 'color' | 'typography' | 'visual' | 'layout' | 'animation',
|
||||||
|
key: string,
|
||||||
|
): string {
|
||||||
|
return `--${category}-${key}`
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue