暗黑模式与自适应主题
在现代 Web 应用程序中,主题切换已成为增强用户体验的重要功能。Element Plus 提供了一个全面的主题系统,支持亮色模式、暗色模式和自定义主题。本章将探讨如何实现灵活的主题管理系统。
1. 主题系统架构
1.1 主题管理器设计
typescript
// theme/ThemeManager.ts
export interface ThemeConfig {
name: string
displayName: string
colors: {
primary: string
success: string
warning: string
danger: string
info: string
textPrimary: string
textRegular: string
textSecondary: string
textPlaceholder: string
textDisabled: string
borderBase: string
borderLight: string
borderLighter: string
borderExtraLight: string
fillBase: string
fillLight: string
fillLighter: string
fillExtraLight: string
bgBase: string
bgPage: string
bgOverlay: string
}
shadows: {
base: string
light: string
lighter: string
dark: string
}
borderRadius: {
base: string
small: string
round: string
circle: string
}
fontSize: {
extraLarge: string
large: string
medium: string
base: string
small: string
extraSmall: string
}
spacing: {
none: string
xs: string
sm: string
md: string
lg: string
xl: string
xxl: string
}
zIndex: {
normal: number
top: number
popper: number
}
animation: {
duration: {
slow: string
base: string
fast: string
}
easing: {
easeInOut: string
easeOut: string
easeIn: string
}
}
}
export type ThemeMode = 'light' | 'dark' | 'auto'
export interface ThemeSetOptions {
animated?: boolean
duration?: number
type?: 'fade' | 'slide' | 'zoom' | 'ripple'
saveToStorage?: boolean
}
export interface ThemeObserver {
(theme: ThemeConfig, mode: ThemeMode): void
}
export class ThemeManager {
private currentTheme: ThemeConfig | null = null
private currentMode: ThemeMode = 'auto'
private themes: Map<string, ThemeConfig> = new Map()
private observers: Set<ThemeObserver> = new Set()
private mediaQuery: MediaQueryList
private storageKey = 'element-plus-theme'
private modeStorageKey = 'element-plus-theme-mode'
constructor() {
this.mediaQuery = window.matchMedia('(prefers-color-scheme: dark)')
this.mediaQuery.addEventListener('change', this.handleSystemThemeChange.bind(this))
this.initializeDefaultThemes()
this.loadFromStorage()
}
private initializeDefaultThemes(): void {
// 亮色主题
this.registerTheme({
name: 'light',
displayName: '亮色模式',
colors: {
primary: '#409eff',
success: '#67c23a',
warning: '#e6a23c',
danger: '#f56c6c',
info: '#909399',
textPrimary: '#303133',
textRegular: '#606266',
textSecondary: '#909399',
textPlaceholder: '#a8abb2',
textDisabled: '#c0c4cc',
borderBase: '#dcdfe6',
borderLight: '#e4e7ed',
borderLighter: '#ebeef5',
borderExtraLight: '#f2f6fc',
fillBase: '#f0f2f5',
fillLight: '#f5f7fa',
fillLighter: '#fafafa',
fillExtraLight: '#fafcff',
bgBase: '#ffffff',
bgPage: '#f2f3f5',
bgOverlay: 'rgba(255, 255, 255, 0.8)'
},
shadows: {
base: '0 2px 4px rgba(0, 0, 0, 0.12), 0 0 6px rgba(0, 0, 0, 0.04)',
light: '0 2px 12px 0 rgba(0, 0, 0, 0.1)',
lighter: '0 2px 4px rgba(0, 0, 0, 0.12), 0 0 6px rgba(0, 0, 0, 0.04)',
dark: '0 2px 4px rgba(0, 0, 0, 0.12), 0 0 6px rgba(0, 0, 0, 0.12)'
},
borderRadius: {
base: '4px',
small: '2px',
round: '20px',
circle: '100%'
},
fontSize: {
extraLarge: '20px',
large: '18px',
medium: '16px',
base: '14px',
small: '13px',
extraSmall: '12px'
},
spacing: {
none: '0',
xs: '4px',
sm: '8px',
md: '12px',
lg: '16px',
xl: '20px',
xxl: '24px'
},
zIndex: {
normal: 1,
top: 1000,
popper: 2000
},
animation: {
duration: {
slow: '0.3s',
base: '0.2s',
fast: '0.1s'
},
easing: {
easeInOut: 'cubic-bezier(0.645, 0.045, 0.355, 1)',
easeOut: 'cubic-bezier(0.23, 1, 0.32, 1)',
easeIn: 'cubic-bezier(0.55, 0.055, 0.675, 0.19)'
}
}
})
// 暗色主题
this.registerTheme({
name: 'dark',
displayName: '暗色模式',
colors: {
primary: '#409eff',
success: '#67c23a',
warning: '#e6a23c',
danger: '#f56c6c',
info: '#909399',
textPrimary: '#e5eaf3',
textRegular: '#cfd3dc',
textSecondary: '#a3a6ad',
textPlaceholder: '#8d9095',
textDisabled: '#6c6e72',
borderBase: '#4c4d4f',
borderLight: '#414243',
borderLighter: '#363637',
borderExtraLight: '#2b2b2c',
fillBase: '#303133',
fillLight: '#262727',
fillLighter: '#1d1d1d',
fillExtraLight: '#191919',
bgBase: '#141414',
bgPage: '#0a0a0a',
bgOverlay: 'rgba(0, 0, 0, 0.8)'
},
shadows: {
base: '0 2px 4px rgba(0, 0, 0, 0.12), 0 0 6px rgba(0, 0, 0, 0.04)',
light: '0 2px 12px 0 rgba(0, 0, 0, 0.1)',
lighter: '0 2px 4px rgba(0, 0, 0, 0.12), 0 0 6px rgba(0, 0, 0, 0.04)',
dark: '0 2px 4px rgba(0, 0, 0, 0.12), 0 0 6px rgba(0, 0, 0, 0.12)'
},
borderRadius: {
base: '4px',
small: '2px',
round: '20px',
circle: '100%'
},
fontSize: {
extraLarge: '20px',
large: '18px',
medium: '16px',
base: '14px',
small: '13px',
extraSmall: '12px'
},
spacing: {
none: '0',
xs: '4px',
sm: '8px',
md: '12px',
lg: '16px',
xl: '20px',
xxl: '24px'
},
zIndex: {
normal: 1,
top: 1000,
popper: 2000
},
animation: {
duration: {
slow: '0.3s',
base: '0.2s',
fast: '0.1s'
},
easing: {
easeInOut: 'cubic-bezier(0.645, 0.045, 0.355, 1)',
easeOut: 'cubic-bezier(0.23, 1, 0.32, 1)',
easeIn: 'cubic-bezier(0.55, 0.055, 0.675, 0.19)'
}
}
})
}
public registerTheme(theme: ThemeConfig): void {
this.themes.set(theme.name, theme)
}
public getTheme(name: string): ThemeConfig | undefined {
return this.themes.get(name)
}
public getAllThemes(): ThemeConfig[] {
return Array.from(this.themes.values())
}
public getCurrentTheme(): ThemeConfig | null {
return this.currentTheme
}
public getCurrentMode(): ThemeMode {
return this.currentMode
}
public async setTheme(name: string, options: ThemeSetOptions = {}): Promise<void> {
const theme = this.themes.get(name)
if (!theme) {
throw new Error(`主题 '${name}' 未找到`)
}
const {
animated = true,
duration = 300,
type = 'fade',
saveToStorage = true
} = options
if (animated) {
await this.animateThemeChange(theme, { duration, type })
} else {
this.applyTheme(theme)
}
this.currentTheme = theme
if (saveToStorage) {
localStorage.setItem(this.storageKey, name)
}
this.notifyObservers(theme, this.currentMode)
}
public async setMode(mode: ThemeMode, options: ThemeSetOptions = {}): Promise<void> {
this.currentMode = mode
localStorage.setItem(this.modeStorageKey, mode)
const effectiveTheme = this.getEffectiveTheme(mode)
if (effectiveTheme) {
await this.setTheme(effectiveTheme.name, { ...options, saveToStorage: false })
}
}
public async toggleTheme(options: ThemeSetOptions = {}): Promise<void> {
const currentName = this.currentTheme?.name || 'light'
const nextTheme = currentName === 'light' ? 'dark' : 'light'
await this.setTheme(nextTheme, options)
}
private getEffectiveTheme(mode: ThemeMode): ThemeConfig | null {
if (mode === 'auto') {
const prefersDark = this.mediaQuery.matches
return this.themes.get(prefersDark ? 'dark' : 'light') || null
}
return this.themes.get(mode) || null
}
private handleSystemThemeChange(): void {
if (this.currentMode === 'auto') {
const effectiveTheme = this.getEffectiveTheme('auto')
if (effectiveTheme && effectiveTheme !== this.currentTheme) {
this.setTheme(effectiveTheme.name, { animated: true })
}
}
}
private applyTheme(theme: ThemeConfig): void {
const root = document.documentElement
// 应用颜色变量
Object.entries(theme.colors).forEach(([key, value]) => {
root.style.setProperty(`--el-color-${this.kebabCase(key)}`, value)
})
// 应用阴影变量
Object.entries(theme.shadows).forEach(([key, value]) => {
root.style.setProperty(`--el-box-shadow-${key}`, value)
})
// 应用边框圆角变量
Object.entries(theme.borderRadius).forEach(([key, value]) => {
root.style.setProperty(`--el-border-radius-${key}`, value)
})
// 应用字体大小变量
Object.entries(theme.fontSize).forEach(([key, value]) => {
root.style.setProperty(`--el-font-size-${this.kebabCase(key)}`, value)
})
// 应用间距变量
Object.entries(theme.spacing).forEach(([key, value]) => {
root.style.setProperty(`--el-spacing-${key}`, value)
})
// 应用层级变量
Object.entries(theme.zIndex).forEach(([key, value]) => {
root.style.setProperty(`--el-z-index-${this.kebabCase(key)}`, value.toString())
})
// 应用动画变量
Object.entries(theme.animation.duration).forEach(([key, value]) => {
root.style.setProperty(`--el-transition-duration-${key}`, value)
})
Object.entries(theme.animation.easing).forEach(([key, value]) => {
root.style.setProperty(`--el-transition-${this.kebabCase(key)}`, value)
})
// 设置主题类名
root.className = root.className.replace(/theme-\w+/g, '')
root.classList.add(`theme-${theme.name}`)
}
private async animateThemeChange(
theme: ThemeConfig,
options: { duration: number; type: string }
): Promise<void> {
const { duration, type } = options
switch (type) {
case 'fade':
await this.fadeAnimation(duration)
break
case 'slide':
await this.slideAnimation(duration)
break
case 'zoom':
await this.zoomAnimation(duration)
break
case 'ripple':
await this.rippleAnimation(duration)
break
default:
break
}
this.applyTheme(theme)
// 恢复动画
await new Promise(resolve => setTimeout(resolve, 50))
document.body.style.transition = ''
document.body.style.transform = ''
document.body.style.opacity = ''
}
private async fadeAnimation(duration: number): Promise<void> {
document.body.style.transition = `opacity ${duration}ms ease-in-out`
document.body.style.opacity = '0'
await new Promise(resolve => setTimeout(resolve, duration / 2))
document.body.style.opacity = '1'
}
private async slideAnimation(duration: number): Promise<void> {
document.body.style.transition = `transform ${duration}ms ease-in-out`
document.body.style.transform = 'translateX(-100%)'
await new Promise(resolve => setTimeout(resolve, duration / 2))
document.body.style.transform = 'translateX(0)'
}
private async zoomAnimation(duration: number): Promise<void> {
document.body.style.transition = `transform ${duration}ms ease-in-out`
document.body.style.transform = 'scale(0.95)'
await new Promise(resolve => setTimeout(resolve, duration / 2))
document.body.style.transform = 'scale(1)'
}
private async rippleAnimation(duration: number): Promise<void> {
const ripple = document.createElement('div')
ripple.style.cssText = `
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: radial-gradient(circle, transparent 0%, rgba(0,0,0,0.3) 100%);
pointer-events: none;
z-index: 9999;
animation: ripple ${duration}ms ease-out;
`
const style = document.createElement('style')
style.textContent = `
@keyframes ripple {
0% { transform: scale(0); opacity: 1; }
100% { transform: scale(1); opacity: 0; }
}
`
document.head.appendChild(style)
document.body.appendChild(ripple)
await new Promise(resolve => setTimeout(resolve, duration))
document.body.removeChild(ripple)
document.head.removeChild(style)
}
private kebabCase(str: string): string {
return str.replace(/([a-z0-9])([A-Z])/g, '$1-$2').toLowerCase()
}
private loadFromStorage(): void {
const savedTheme = localStorage.getItem(this.storageKey)
const savedMode = localStorage.getItem(this.modeStorageKey) as ThemeMode
if (savedMode) {
this.currentMode = savedMode
}
const effectiveTheme = savedTheme
? this.themes.get(savedTheme)
: this.getEffectiveTheme(this.currentMode)
if (effectiveTheme) {
this.setTheme(effectiveTheme.name, { animated: false, saveToStorage: false })
}
}
public subscribe(observer: ThemeObserver): () => void {
this.observers.add(observer)
return () => this.observers.delete(observer)
}
private notifyObservers(theme: ThemeConfig, mode: ThemeMode): void {
this.observers.forEach(observer => observer(theme, mode))
}
public createThemeVariant(baseName: string, variantName: string, modifications: Partial<ThemeConfig>): void {
const baseTheme = this.themes.get(baseName)
if (!baseTheme) {
throw new Error(`基础主题 '${baseName}' 未找到`)
}
const variantTheme: ThemeConfig = {
...baseTheme,
...modifications,
name: variantName,
colors: {
...baseTheme.colors,
...modifications.colors
}
}
this.registerTheme(variantTheme)
}
public exportTheme(name: string): string {
const theme = this.themes.get(name)
if (!theme) {
throw new Error(`主题 '${name}' 未找到`)
}
return JSON.stringify(theme, null, 2)
}
public importTheme(themeJson: string): void {
try {
const theme = JSON.parse(themeJson) as ThemeConfig
this.registerTheme(theme)
} catch (error) {
throw new Error('无效的主题JSON格式')
}
}
public destroy(): void {
this.mediaQuery.removeEventListener('change', this.handleSystemThemeChange.bind(this))
this.observers.clear()
}
}
1.2 主题模式管理
typescript
// theme/ThemeModeManager.ts
export class ThemeModeManager {
private mode: ThemeMode = 'auto'
private listeners: Set<(theme: ThemeMode) => void> = new Set()
private mediaQuery: MediaQueryList
private storageKey = 'theme-mode'
constructor() {
this.mediaQuery = window.matchMedia('(prefers-color-scheme: dark)')
this.mediaQuery.addEventListener('change', this.handleSystemChange.bind(this))
this.loadFromStorage()
}
public getMode(): ThemeMode {
return this.mode
}
public setMode(mode: ThemeMode): void {
this.mode = mode
this.saveToStorage()
this.notifyListeners()
}
public getEffectiveMode(): 'light' | 'dark' {
if (this.mode === 'auto') {
return this.mediaQuery.matches ? 'dark' : 'light'
}
return this.mode as 'light' | 'dark'
}
public subscribe(listener: (mode: ThemeMode) => void): () => void {
this.listeners.add(listener)
return () => this.listeners.delete(listener)
}
private handleSystemChange(): void {
if (this.mode === 'auto') {
this.notifyListeners()
}
}
private notifyListeners(): void {
this.listeners.forEach(listener => listener(this.mode))
}
private saveToStorage(): void {
localStorage.setItem(this.storageKey, this.mode)
}
private loadFromStorage(): void {
const saved = localStorage.getItem(this.storageKey) as ThemeMode
if (saved && ['light', 'dark', 'auto'].includes(saved)) {
this.mode = saved
}
}
}
2. 主题存储与持久化
2.1 本地存储管理
typescript
// theme/ThemeStorage.ts
export interface ThemeStorageData {
currentTheme: string
mode: ThemeMode
customThemes: Record<string, ThemeConfig>
preferences: {
animationEnabled: boolean
animationType: string
animationDuration: number
}
}
export class ThemeStorage {
private storageKey = 'element-plus-theme-data'
private fallbackStorage: Map<string, string> = new Map()
public save(data: Partial<ThemeStorageData>): void {
try {
const existing = this.load()
const merged = { ...existing, ...data }
const serialized = JSON.stringify(merged)
if (this.isLocalStorageAvailable()) {
localStorage.setItem(this.storageKey, serialized)
} else {
this.fallbackStorage.set(this.storageKey, serialized)
}
} catch (error) {
console.warn('保存主题数据失败:', error)
}
}
public load(): ThemeStorageData {
try {
let data: string | null = null
if (this.isLocalStorageAvailable()) {
data = localStorage.getItem(this.storageKey)
} else {
data = this.fallbackStorage.get(this.storageKey) || null
}
if (data) {
return JSON.parse(data)
}
} catch (error) {
console.warn('加载主题数据失败:', error)
}
return this.getDefaultData()
}
public clear(): void {
try {
if (this.isLocalStorageAvailable()) {
localStorage.removeItem(this.storageKey)
} else {
this.fallbackStorage.delete(this.storageKey)
}
} catch (error) {
console.warn('清除主题数据失败:', error)
}
}
private isLocalStorageAvailable(): boolean {
try {
const test = '__theme_storage_test__'
localStorage.setItem(test, test)
localStorage.removeItem(test)
return true
} catch {
return false
}
}
private getDefaultData(): ThemeStorageData {
return {
currentTheme: 'light',
mode: 'auto',
customThemes: {},
preferences: {
animationEnabled: true,
animationType: 'fade',
animationDuration: 300
}
}
}
}
2.2 主题同步服务
typescript
// theme/ThemeSyncService.ts
export interface ThemeSyncOptions {
endpoint: string
apiKey?: string
userId?: string
syncInterval?: number
}
export class ThemeSyncService {
private options: ThemeSyncOptions
private syncTimer: NodeJS.Timeout | null = null
private storage: ThemeStorage
constructor(options: ThemeSyncOptions, storage: ThemeStorage) {
this.options = options
this.storage = storage
if (options.syncInterval) {
this.startAutoSync()
}
}
public async syncToServer(): Promise<void> {
try {
const data = this.storage.load()
const response = await fetch(`${this.options.endpoint}/sync`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...(this.options.apiKey && { 'Authorization': `Bearer ${this.options.apiKey}` })
},
body: JSON.stringify({
userId: this.options.userId,
themeData: data
})
})
if (!response.ok) {
throw new Error(`同步失败: ${response.statusText}`)
}
} catch (error) {
console.error('同步主题到服务器失败:', error)
throw error
}
}
public async syncFromServer(): Promise<void> {
try {
const response = await fetch(
`${this.options.endpoint}/sync?userId=${this.options.userId}`,
{
headers: {
...(this.options.apiKey && { 'Authorization': `Bearer ${this.options.apiKey}` })
}
}
)
if (!response.ok) {
throw new Error(`同步失败: ${response.statusText}`)
}
const { themeData } = await response.json()
this.storage.save(themeData)
} catch (error) {
console.error('从服务器同步主题失败:', error)
throw error
}
}
public startAutoSync(): void {
if (this.syncTimer) {
clearInterval(this.syncTimer)
}
this.syncTimer = setInterval(() => {
this.syncToServer().catch(console.error)
}, this.options.syncInterval || 300000) // 默认5分钟
}
public stopAutoSync(): void {
if (this.syncTimer) {
clearInterval(this.syncTimer)
this.syncTimer = null
}
}
public destroy(): void {
this.stopAutoSync()
}
}
3. 主题动画系统
3.1 动画管理器
typescript
// theme/ThemeAnimationManager.ts
export interface AnimationConfig {
type: 'fade' | 'slide' | 'zoom' | 'ripple' | 'morph'
duration: number
easing: string
direction?: 'left' | 'right' | 'up' | 'down'
}
export class ThemeAnimationManager {
private isAnimating = false
private animationQueue: Array<() => Promise<void>> = []
public async animate(
config: AnimationConfig,
applyTheme: () => void
): Promise<void> {
if (this.isAnimating) {
return new Promise(resolve => {
this.animationQueue.push(async () => {
await this.performAnimation(config, applyTheme)
resolve()
})
})
}
await this.performAnimation(config, applyTheme)
await this.processQueue()
}
private async performAnimation(
config: AnimationConfig,
applyTheme: () => void
): Promise<void> {
this.isAnimating = true
try {
switch (config.type) {
case 'fade':
await this.fadeAnimation(config.duration, config.easing, applyTheme)
break
case 'slide':
await this.slideAnimation(
config.duration,
config.easing,
config.direction || 'left',
applyTheme
)
break
case 'zoom':
await this.zoomAnimation(config.duration, config.easing, applyTheme)
break
case 'ripple':
await this.rippleAnimation(
config.duration,
config.easing,
applyTheme
)
break
case 'morph':
await this.morphAnimation(
config.duration,
config.easing,
applyTheme
)
break
}
} finally {
this.isAnimating = false
}
}
private async fadeAnimation(duration: number, easing: string, applyTheme: () => void): Promise<void> {
const body = document.body
body.style.transition = `opacity ${duration}ms ${easing}`
body.style.opacity = '0'
await new Promise(resolve => setTimeout(resolve, duration / 2))
applyTheme()
body.style.opacity = '1'
await new Promise(resolve => setTimeout(resolve, duration / 2))
body.style.transition = ''
}
private async slideAnimation(
duration: number,
easing: string,
direction: string,
applyTheme: () => void
): Promise<void> {
const body = document.body
const transforms = {
left: 'translateX(-100%)',
right: 'translateX(100%)',
up: 'translateY(-100%)',
down: 'translateY(100%)'
}
body.style.transition = `transform ${duration}ms ${easing}`
body.style.transform = transforms[direction as keyof typeof transforms]
await new Promise(resolve => setTimeout(resolve, duration / 2))
applyTheme()
body.style.transform = 'translate(0, 0)'
await new Promise(resolve => setTimeout(resolve, duration / 2))
body.style.transition = ''
body.style.transform = ''
}
private async zoomAnimation(duration: number, easing: string, applyTheme: () => void): Promise<void> {
const body = document.body
body.style.transition = `transform ${duration}ms ${easing}`
body.style.transform = 'scale(0.95)'
await new Promise(resolve => setTimeout(resolve, duration / 2))
applyTheme()
body.style.transform = 'scale(1)'
await new Promise(resolve => setTimeout(resolve, duration / 2))
body.style.transition = ''
body.style.transform = ''
}
private async rippleAnimation(
duration: number,
easing: string,
applyTheme: () => void
): Promise<void> {
const overlay = this.createRippleOverlay(duration, easing)
document.body.appendChild(overlay)
await new Promise(resolve => setTimeout(resolve, duration / 3))
applyTheme()
await new Promise(resolve => setTimeout(resolve, (duration * 2) / 3))
document.body.removeChild(overlay)
}
private async morphAnimation(
duration: number,
easing: string,
applyTheme: () => void
): Promise<void> {
const canvas = this.createMorphCanvas()
document.body.appendChild(canvas)
await this.performMorphTransition(canvas, duration, easing, applyTheme)
document.body.removeChild(canvas)
}
private createRippleOverlay(duration: number, easing: string): HTMLElement {
const overlay = document.createElement('div')
overlay.style.cssText = `
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: radial-gradient(circle, transparent 0%, rgba(0,0,0,0.3) 100%);
pointer-events: none;
z-index: 9999;
animation: ripple-expand ${duration}ms ${easing};
`
const style = document.createElement('style')
style.textContent = `
@keyframes ripple-expand {
0% { transform: scale(0); opacity: 1; }
50% { transform: scale(1); opacity: 0.8; }
100% { transform: scale(1.2); opacity: 0; }
}
`
document.head.appendChild(style)
setTimeout(() => {
if (document.head.contains(style)) {
document.head.removeChild(style)
}
}, duration)
return overlay
}
private createMorphCanvas(): HTMLCanvasElement {
const canvas = document.createElement('canvas')
canvas.style.cssText = `
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
pointer-events: none;
z-index: 9999;
`
canvas.width = window.innerWidth
canvas.height = window.innerHeight
return canvas
}
private async performMorphTransition(
canvas: HTMLCanvasElement,
duration: number,
easing: string,
applyTheme: () => void
): Promise<void> {
const ctx = canvas.getContext('2d')!
const startTime = Date.now()
return new Promise(resolve => {
const animate = () => {
const elapsed = Date.now() - startTime
const progress = Math.min(elapsed / duration, 1)
const easedProgress = this.applyEasing(progress, easing)
ctx.clearRect(0, 0, canvas.width, canvas.height)
this.drawMorphFrame(ctx, easedProgress)
if (progress >= 0.5 && !applyTheme.called) {
applyTheme()
applyTheme.called = true
}
if (progress < 1) {
requestAnimationFrame(animate)
} else {
resolve()
}
}
animate()
})
}
private drawMorphFrame(ctx: CanvasRenderingContext2D, progress: number): void {
const { width, height } = ctx.canvas
const centerX = width / 2
const centerY = height / 2
const maxRadius = Math.sqrt(centerX * centerX + centerY * centerY)
const radius = maxRadius * progress
ctx.fillStyle = `rgba(0, 0, 0, ${0.8 * (1 - progress)})`
ctx.fillRect(0, 0, width, height)
ctx.globalCompositeOperation = 'destination-out'
ctx.beginPath()
ctx.arc(centerX, centerY, radius, 0, Math.PI * 2)
ctx.fill()
ctx.globalCompositeOperation = 'source-over'
}
private applyEasing(progress: number, easing: string): number {
switch (easing) {
case 'ease-in':
return progress * progress
case 'ease-out':
return 1 - (1 - progress) * (1 - progress)
case 'ease-in-out':
return progress < 0.5
? 2 * progress * progress
: 1 - 2 * (1 - progress) * (1 - progress)
default:
return progress
}
}
private async processQueue(): Promise<void> {
if (this.animationQueue.length > 0) {
const next = this.animationQueue.shift()!
await next()
await this.processQueue()
}
}
}
4. Vue组件实现
4.1 主题切换组件
vue
<!-- ThemeSwitcher.vue - 主题切换组件 -->
<template>
<div class="theme-switcher" :class="switcherClasses">
<!-- 简单切换按钮 -->
<el-button
v-if="mode === 'button'"
:type="type"
:size="size"
:circle="circle"
:loading="isLoading"
:disabled="disabled"
@click="toggleTheme"
>
<template v-if="!isLoading">
<el-icon v-if="showIcon"><component :is="currentIcon" /></el-icon>
<span v-if="showText && !circle">{{ currentLabel }}</span>
</template>
</el-button>
<!-- 下拉选择器 -->
<el-select
v-else-if="mode === 'select'"
v-model="currentTheme"
:placeholder="placeholder"
:size="size"
:disabled="disabled || isLoading"
@change="setTheme"
>
<el-option
v-for="theme in availableThemes"
:key="theme"
:label="getThemeLabel(theme)"
:value="theme"
>
<div class="theme-option">
<div
class="theme-preview"
:style="{ backgroundColor: getThemePreview(theme) }"
/>
<span>{{ getThemeLabel(theme) }}</span>
</div>
</el-option>
</el-select>
<!-- 分段控制器 -->
<el-radio-group
v-else-if="mode === 'segmented'"
v-model="currentTheme"
:size="size"
:disabled="disabled || isLoading"
@change="setTheme"
>
<el-radio-button v-for="theme in availableThemes" :key="theme" :label="theme">
{{ getThemeLabel(theme) }}
</el-radio-button>
</el-radio-group>
<!-- 开关切换 -->
<div v-else-if="mode === 'switch'" class="theme-switch">
<el-icon class="theme-icon light"><Sunny /></el-icon>
<el-switch
v-model="isDark"
:size="size"
:disabled="disabled || isLoading"
@change="handleSwitchChange"
/>
<el-icon class="theme-icon dark"><Moon /></el-icon>
</div>
<!-- 自定义切换 -->
<div v-else-if="mode === 'custom'" class="theme-custom">
<slot
:current-theme="currentTheme"
:available-themes="availableThemes"
:is-loading="isLoading"
:toggle-theme="toggleTheme"
:set-theme="setTheme"
:get-theme-label="getThemeLabel"
:get-theme-preview="getThemePreview"
/>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, inject, onMounted, watch } from 'vue'
import { Sunny, Moon, Monitor, Palette } from '@element-plus/icons-vue'
import type { ThemeManager } from '../theme/ThemeManager'
// Props
interface Props {
mode?: 'button' | 'select' | 'segmented' | 'switch' | 'custom'
size?: 'large' | 'default' | 'small'
type?: 'primary' | 'success' | 'warning' | 'danger' | 'info' | 'text' | 'default'
circle?: boolean
showIcon?: boolean
showText?: boolean
placeholder?: string
disabled?: boolean
animated?: boolean
animationType?: 'fade' | 'slide' | 'zoom' | 'ripple' | 'morph'
animationDuration?: number
themes?: string[]
labels?: Record<string, string>
icons?: Record<string, any>
previews?: Record<string, string>
}
const props = withDefaults(defineProps<Props>(), {
mode: 'button',
size: 'default',
type: 'default',
circle: false,
showIcon: true,
showText: true,
placeholder: '选择主题',
disabled: false,
animated: true,
animationType: 'fade',
animationDuration: 300,
themes: () => ['light', 'dark', 'auto'],
labels: () => ({
light: '亮色模式',
dark: '暗色模式',
auto: '跟随系统'
}),
icons: () => ({
light: Sunny,
dark: Moon,
auto: Monitor
}),
previews: () => ({
light: '#409eff',
dark: '#303133',
auto: 'linear-gradient(45deg, #409eff 50%, #303133 50%)'
})
})
// Emits
interface Emits {
(e: 'change', theme: string): void
(e: 'before-change', theme: string): void
}
const emit = defineEmits<Emits>()
// 注入主题管理器
const themeManager = inject<ThemeManager>('themeManager')
if (!themeManager) {
throw new Error('未提供ThemeManager')
}
// 响应式状态
const isLoading = ref(false)
const currentTheme = ref('light')
// 计算属性
const switcherClasses = computed(() => ({
[`theme-switcher--${props.size}`]: true,
[`theme-switcher--${props.mode}`]: true,
'theme-switcher--loading': isLoading.value,
'theme-switcher--disabled': props.disabled
}))
const availableThemes = computed(() => {
return props.themes || ['light', 'dark', 'auto']
})
const currentIcon = computed(() => {
return props.icons[currentTheme.value] || Palette
})
const currentLabel = computed(() => {
return props.labels[currentTheme.value] || currentTheme.value
})
const isDark = computed({
get: () => currentTheme.value === 'dark',
set: (value: boolean) => {
currentTheme.value = value ? 'dark' : 'light'
}
})
// 方法
const getThemeLabel = (theme: string): string => {
return props.labels[theme] || theme
}
const getThemePreview = (theme: string): string => {
return props.previews[theme] || '#409eff'
}
const setTheme = async (theme: string) => {
if (isLoading.value || props.disabled || theme === currentTheme.value) return
emit('before-change', theme)
isLoading.value = true
try {
await themeManager.setTheme(theme, {
animated: props.animated,
duration: props.animationDuration,
type: props.animationType
})
currentTheme.value = theme
emit('change', theme)
} catch (error) {
console.error('设置主题失败:', error)
} finally {
isLoading.value = false
}
}
const toggleTheme = async () => {
const currentIndex = availableThemes.value.indexOf(currentTheme.value)
const nextIndex = (currentIndex + 1) % availableThemes.value.length
const nextTheme = availableThemes.value[nextIndex]
await setTheme(nextTheme)
}
const handleSwitchChange = (value: boolean) => {
setTheme(value ? 'dark' : 'light')
}
// 生命周期
onMounted(() => {
const current = themeManager.getCurrentTheme()
if (current) {
currentTheme.value = current.name
}
// 监听主题变化
themeManager.subscribe((theme) => {
currentTheme.value = theme.name
})
})
// 监听器
watch(
() => props.themes,
(newThemes) => {
if (newThemes && !newThemes.includes(currentTheme.value)) {
currentTheme.value = newThemes[0] || 'light'
}
}
)
// 暴露方法
defineExpose({
toggleTheme,
setTheme,
getCurrentTheme: () => currentTheme.value,
getAvailableThemes: () => availableThemes.value
})
</script>
<style scoped>
.theme-switcher {
display: inline-flex;
align-items: center;
}
.theme-option {
display: flex;
align-items: center;
gap: 8px;
}
.theme-preview {
width: 16px;
height: 16px;
border-radius: 2px;
border: 1px solid var(--el-border-color);
}
.theme-switch {
display: flex;
align-items: center;
gap: 8px;
}
.theme-icon {
color: var(--el-text-color-regular);
transition: color 0.3s;
}
.theme-icon.light {
color: #f39c12;
}
.theme-icon.dark {
color: #34495e;
}
.theme-switcher--loading {
pointer-events: none;
}
.theme-switcher--disabled {
opacity: 0.6;
pointer-events: none;
}
.theme-switcher--small {
font-size: 12px;
}
.theme-switcher--large {
font-size: 16px;
}
.theme-switcher--custom {
/* 自定义样式 */
}
</style>
4.2 主题预览组件
vue
<!-- ThemePreview.vue - 主题预览组件 -->
<template>
<div class="theme-preview" :class="previewClasses">
<div class="preview-header">
<h3>{{ title || `${themeName} 主题预览` }}</h3>
<div class="preview-actions">
<el-button
v-if="showApplyButton"
type="primary"
size="small"
:loading="isApplying"
@click="applyTheme"
>
应用主题
</el-button>
<el-button
v-if="showExportButton"
size="small"
@click="exportTheme"
>
导出主题
</el-button>
</div>
</div>
<div class="preview-content" :style="previewStyles">
<!-- 颜色预览 -->
<div class="color-preview">
<h4>主要颜色</h4>
<div class="color-grid">
<div
v-for="(color, name) in mainColors"
:key="name"
class="color-item"
>
<div
class="color-swatch"
:style="{ backgroundColor: color }"
@click="copyColor(color)"
/>
<span class="color-name">{{ colorLabels[name] || name }}</span>
<span class="color-value">{{ color }}</span>
</div>
</div>
</div>
<!-- 组件预览 -->
<div class="component-preview">
<h4>组件预览</h4>
<div class="component-grid">
<!-- 按钮预览 -->
<div class="component-section">
<h5>按钮</h5>
<div class="button-group">
<el-button>默认按钮</el-button>
<el-button type="primary">主要按钮</el-button>
<el-button type="success">成功按钮</el-button>
<el-button type="warning">警告按钮</el-button>
<el-button type="danger">危险按钮</el-button>
</div>
</div>
<!-- 输入框预览 -->
<div class="component-section">
<h5>输入框</h5>
<el-input v-model="sampleText" placeholder="请输入内容" />
</div>
<!-- 卡片预览 -->
<div class="component-section">
<h5>卡片</h5>
<el-card class="sample-card">
<template #header>
<span>卡片标题</span>
</template>
<p>这是卡片内容,用于展示主题效果。</p>
</el-card>
</div>
<!-- 表格预览 -->
<div class="component-section">
<h5>表格</h5>
<el-table :data="sampleTableData" size="small">
<el-table-column prop="name" label="姓名" />
<el-table-column prop="age" label="年龄" />
<el-table-column prop="city" label="城市" />
</el-table>
</div>
</div>
</div>
<!-- 文本预览 -->
<div class="text-preview">
<h4>文本样式</h4>
<div class="text-samples">
<p class="text-primary">主要文本颜色</p>
<p class="text-regular">常规文本颜色</p>
<p class="text-secondary">次要文本颜色</p>
<p class="text-placeholder">占位符文本颜色</p>
<p class="text-disabled">禁用文本颜色</p>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, inject } from 'vue'
import { ElMessage } from 'element-plus'
import type { ThemeManager, ThemeConfig } from '../theme/ThemeManager'
// Props
interface Props {
themeName: string
title?: string
showApplyButton?: boolean
showExportButton?: boolean
interactive?: boolean
size?: 'small' | 'default' | 'large'
}
const props = withDefaults(defineProps<Props>(), {
showApplyButton: true,
showExportButton: true,
interactive: true,
size: 'default'
})
// Emits
interface Emits {
(e: 'apply', themeName: string): void
(e: 'export', themeName: string, theme: ThemeConfig): void
}
const emit = defineEmits<Emits>()
// 注入主题管理器
const themeManager = inject<ThemeManager>('themeManager')
if (!themeManager) {
throw new Error('未提供ThemeManager')
}
// 响应式数据
const isApplying = ref(false)
const sampleText = ref('示例文本')
const sampleTableData = ref([
{ name: '张三', age: 25, city: '北京' },
{ name: '李四', age: 30, city: '上海' },
{ name: '王五', age: 28, city: '广州' }
])
// 计算属性
const previewClasses = computed(() => ({
[`theme-preview--${props.size}`]: true,
'theme-preview--interactive': props.interactive
}))
const previewStyles = computed(() => {
const theme = themeManager.getTheme(props.themeName)
if (!theme) return {}
return {
'--el-color-primary': theme.colors.primary,
'--el-color-success': theme.colors.success,
'--el-color-warning': theme.colors.warning,
'--el-color-danger': theme.colors.danger,
'--el-color-info': theme.colors.info,
'--el-text-color-primary': theme.colors.textPrimary,
'--el-text-color-regular': theme.colors.textRegular,
'--el-text-color-secondary': theme.colors.textSecondary,
'--el-text-color-placeholder': theme.colors.textPlaceholder,
'--el-text-color-disabled': theme.colors.textDisabled
}
})
const mainColors = computed(() => {
const theme = themeManager.getTheme(props.themeName)
if (!theme) return {}
return {
primary: theme.colors.primary,
success: theme.colors.success,
warning: theme.colors.warning,
danger: theme.colors.danger,
info: theme.colors.info
}
})
const colorLabels = {
primary: '主要',
success: '成功',
warning: '警告',
danger: '危险',
info: '信息'
}
// 方法
const applyTheme = async () => {
try {
isApplying.value = true
await themeManager.setTheme(props.themeName)
emit('apply', props.themeName)
ElMessage.success('主题应用成功')
} catch (error) {
ElMessage.error('应用主题失败')
console.error('应用主题错误:', error)
} finally {
isApplying.value = false
}
}
const exportTheme = () => {
const theme = themeManager.getTheme(props.themeName)
if (theme) {
emit('export', props.themeName, theme)
ElMessage.success('主题导出成功')
}
}
const copyColor = async (color: string) => {
try {
await navigator.clipboard.writeText(color)
ElMessage.success(`颜色值 ${color} 已复制到剪贴板`)
} catch (error) {
ElMessage.error('复制失败')
}
}
</script>
<style scoped>
.theme-preview {
border: 1px solid var(--el-border-color);
border-radius: var(--el-border-radius-base);
overflow: hidden;
}
.preview-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px;
background-color: var(--el-bg-color-page);
border-bottom: 1px solid var(--el-border-color);
}
.preview-header h3 {
margin: 0;
font-size: 16px;
font-weight: 500;
}
.preview-actions {
display: flex;
gap: 8px;
}
.preview-content {
padding: 20px;
}
.color-preview,
.component-preview,
.text-preview {
margin-bottom: 24px;
}
.color-preview h4,
.component-preview h4,
.text-preview h4 {
margin: 0 0 12px 0;
font-size: 16px;
font-weight: 600;
color: var(--el-text-color-primary);
}
.color-preview .color-item,
.component-preview .demo-item,
.text-preview .text-item {
padding: 12px;
border-radius: 6px;
border: 1px solid var(--el-border-color-light);
background: var(--el-bg-color);
transition: all 0.3s ease;
}
.color-preview .color-item:hover,
.component-preview .demo-item:hover,
.text-preview .text-item:hover {
border-color: var(--el-color-primary);
box-shadow: 0 2px 8px rgba(64, 158, 255, 0.1);
}
.color-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
gap: 16px;
}
.color-item {
display: flex;
flex-direction: column;
align-items: center;
cursor: pointer;
}
.color-swatch {
width: 50px;
height: 50px;
border-radius: 4px;
margin-bottom: 8px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.color-name {
font-weight: 500;
margin-bottom: 4px;
}
.color-value {
font-size: 12px;
color: var(--el-text-color-secondary);
}
.component-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
gap: 20px;
}
.component-section {
margin-bottom: 16px;
}
.component-section h5 {
margin: 0 0 8px 0;
font-size: 14px;
font-weight: 500;
}
.button-group {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.sample-card {
margin-bottom: 16px;
}
.text-samples p {
margin: 8px 0;
line-height: 1.5;
}
.text-primary {
color: var(--el-text-color-primary);
}
.text-regular {
color: var(--el-text-color-regular);
}
.text-secondary {
color: var(--el-text-color-secondary);
}
.text-placeholder {
color: var(--el-text-color-placeholder);
}
.text-disabled {
color: var(--el-text-color-disabled);
}
.theme-preview--small {
font-size: 13px;
}
.theme-preview--large {
font-size: 16px;
}
.theme-preview--interactive .color-swatch:hover {
transform: scale(1.05);
}
</style>