第41天:Element Plus 主题系统深入定制
学习目标
- 深入理解 Element Plus 主题系统架构
- 掌握 CSS 变量和 SCSS 变量的使用
- 学习主题切换的实现方案
- 实践动态主题生成和定制
学习内容
1. Element Plus 主题系统架构
1.1 主题系统概述
scss
// Element Plus 主题架构
// theme-chalk/src/common/var.scss - 主题变量定义
// 基础颜色系统
$colors: () !default;
$colors: map.merge(
(
'white': #ffffff,
'black': #000000,
'primary': (
'base': #409eff,
),
'success': (
'base': #67c23a,
),
'warning': (
'base': #e6a23c,
),
'danger': (
'base': #f56c6c,
),
'error': (
'base': #f56c6c,
),
'info': (
'base': #909399,
),
),
$colors
);
// 生成颜色变体
@each $type in (primary, success, warning, danger, error, info) {
$colors: map.merge(
$colors,
(
$type: (
'light-3': mix(#fff, map.get($colors, $type, 'base'), 30%),
'light-5': mix(#fff, map.get($colors, $type, 'base'), 50%),
'light-7': mix(#fff, map.get($colors, $type, 'base'), 70%),
'light-8': mix(#fff, map.get($colors, $type, 'base'), 80%),
'light-9': mix(#fff, map.get($colors, $type, 'base'), 90%),
'dark-2': mix(#000, map.get($colors, $type, 'base'), 20%),
),
)
);
}
// 文本颜色
$text-color: () !default;
$text-color: map.merge(
(
'primary': #303133,
'regular': #606266,
'secondary': #909399,
'placeholder': #a8abb2,
'disabled': #c0c4cc,
),
$text-color
);
// 边框颜色
$border-color: () !default;
$border-color: map.merge(
(
'': #dcdfe6,
'light': #e4e7ed,
'lighter': #ebeef5,
'extra-light': #f2f6fc,
'dark': #d4d7de,
'darker': #cdd0d6,
),
$border-color
);
// 填充颜色
$fill-color: () !default;
$fill-color: map.merge(
(
'': #f0f2f5,
'light': #f5f7fa,
'lighter': #fafafa,
'extra-light': #fafcff,
'dark': #ebedf0,
'darker': #e6e8eb,
'blank': #ffffff,
),
$fill-color
);
// 背景颜色
$bg-color: () !default;
$bg-color: map.merge(
(
'': #ffffff,
'page': #f2f3f5,
'overlay': #ffffff,
),
$bg-color
);
1.2 CSS 变量系统
scss
// CSS 变量生成 mixin
@mixin set-css-var-value($name, $value) {
#{getCssVarName($name)}: #{$value};
}
@mixin set-css-color-type($colors, $type) {
@include set-css-var-value(('color', $type), map.get($colors, $type, 'base'));
@each $level in (3, 5, 7, 8, 9) {
@include set-css-var-value(
('color', $type, 'light', $level),
map.get($colors, $type, 'light-#{$level}')
);
}
@include set-css-var-value(
('color', $type, 'dark-2'),
map.get($colors, $type, 'dark-2')
);
}
// 生成所有 CSS 变量
:root {
// 颜色变量
@each $type in (primary, success, warning, danger, error, info) {
@include set-css-color-type($colors, $type);
}
// 文本颜色变量
@each $type, $value in $text-color {
@include set-css-var-value(('text-color', $type), $value);
}
// 边框颜色变量
@each $type, $value in $border-color {
@if $type == '' {
@include set-css-var-value('border-color', $value);
} @else {
@include set-css-var-value(('border-color', $type), $value);
}
}
// 填充颜色变量
@each $type, $value in $fill-color {
@if $type == '' {
@include set-css-var-value('fill-color', $value);
} @else {
@include set-css-var-value(('fill-color', $type), $value);
}
}
// 背景颜色变量
@each $type, $value in $bg-color {
@if $type == '' {
@include set-css-var-value('bg-color', $value);
} @else {
@include set-css-var-value(('bg-color', $type), $value);
}
}
// 组件特定变量
@include set-css-var-value('border-radius-base', 4px);
@include set-css-var-value('border-radius-small', 2px);
@include set-css-var-value('border-radius-round', 20px);
@include set-css-var-value('border-radius-circle', 100%);
// 字体大小
@include set-css-var-value('font-size-extra-large', 20px);
@include set-css-var-value('font-size-large', 18px);
@include set-css-var-value('font-size-medium', 16px);
@include set-css-var-value('font-size-base', 14px);
@include set-css-var-value('font-size-small', 13px);
@include set-css-var-value('font-size-extra-small', 12px);
// 字体粗细
@include set-css-var-value('font-weight-primary', 500);
// 行高
@include set-css-var-value('font-line-height-primary', 24px);
// 字体家族
@include set-css-var-value(
'font-family',
"'Helvetica Neue', Helvetica, 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', '微软雅黑', Arial, sans-serif"
);
// 阴影
@include set-css-var-value('box-shadow', 0px 12px 32px 4px rgba(0, 0, 0, 0.04), 0px 8px 20px rgba(0, 0, 0, 0.08));
@include set-css-var-value('box-shadow-light', 0px 0px 12px rgba(0, 0, 0, 0.12));
@include set-css-var-value('box-shadow-lighter', 0px 0px 6px rgba(0, 0, 0, 0.12));
@include set-css-var-value('box-shadow-dark', 0px 16px 48px 16px rgba(0, 0, 0, 0.08), 0px 12px 32px rgba(0, 0, 0, 0.12), 0px 8px 16px -8px rgba(0, 0, 0, 0.16));
// 禁用透明度
@include set-css-var-value('disabled-opacity', 0.5);
// 遮罩层
@include set-css-var-value('overlay-color', rgba(0, 0, 0, 0.8));
@include set-css-var-value('overlay-color-light', rgba(0, 0, 0, 0.7));
@include set-css-var-value('overlay-color-lighter', rgba(0, 0, 0, 0.5));
// 遮罩层
@include set-css-var-value('mask-color', rgba(255, 255, 255, 0.9));
@include set-css-var-value('mask-color-extra-light', rgba(255, 255, 255, 0.3));
}
2. 自定义主题实现
2.1 SCSS 变量定制
scss
// custom-theme.scss - 自定义主题变量
// 重新定义颜色系统
$custom-colors: (
'primary': (
'base': #1890ff, // 蓝色主题
),
'success': (
'base': #52c41a, // 绿色
),
'warning': (
'base': #faad14, // 橙色
),
'danger': (
'base': #ff4d4f, // 红色
),
'info': (
'base': #1890ff, // 信息色
),
);
// 合并自定义颜色
$colors: map.merge($colors, $custom-colors) !global;
// 自定义文本颜色
$custom-text-color: (
'primary': #262626,
'regular': #595959,
'secondary': #8c8c8c,
'placeholder': #bfbfbf,
'disabled': #d9d9d9,
);
$text-color: map.merge($text-color, $custom-text-color) !global;
// 自定义边框颜色
$custom-border-color: (
'': #d9d9d9,
'light': #e8e8e8,
'lighter': #f0f0f0,
'extra-light': #fafafa,
);
$border-color: map.merge($border-color, $custom-border-color) !global;
// 自定义圆角
$border-radius-base: 6px !global;
$border-radius-small: 4px !global;
$border-radius-round: 16px !global;
// 自定义字体
$font-family: "'SF Pro Display', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif" !global;
$font-size-base: 14px !global;
$font-size-small: 12px !global;
$font-size-large: 16px !global;
// 自定义阴影
$box-shadow-base: 0 2px 8px rgba(0, 0, 0, 0.15) !global;
$box-shadow-light: 0 1px 3px rgba(0, 0, 0, 0.12) !global;
// 组件特定定制
// Button 组件
$button-padding-vertical: 8px !global;
$button-padding-horizontal: 16px !global;
$button-font-weight: 400 !global;
$button-border-width: 1px !global;
$button-bg-color: transparent !global;
$button-text-color: getCssVar('text-color', 'regular') !global;
$button-disabled-text-color: getCssVar('text-color', 'disabled') !global;
$button-disabled-bg-color: getCssVar('fill-color', 'light') !global;
$button-disabled-border-color: getCssVar('border-color', 'light') !global;
// Input 组件
$input-height: 36px !global;
$input-text-color: getCssVar('text-color', 'regular') !global;
$input-border: getCssVar('border') !global;
$input-hover-border-color: getCssVar('color', 'primary') !global;
$input-focus-border-color: getCssVar('color', 'primary') !global;
$input-transparent-border: 0 0 0 1px transparent inset !global;
$input-border-color: getCssVar('border-color') !global;
$input-border-radius: getCssVar('border-radius', 'base') !global;
$input-bg-color: getCssVar('fill-color', 'blank') !global;
$input-icon-color: getCssVar('text-color', 'placeholder') !global;
$input-placeholder-color: getCssVar('text-color', 'placeholder') !global;
$input-max-width: 314px !global;
// Table 组件
$table-border-color: getCssVar('border-color', 'lighter') !global;
$table-border: 1px solid $table-border-color !global;
$table-text-color: getCssVar('text-color', 'regular') !global;
$table-header-text-color: getCssVar('text-color', 'secondary') !global;
$table-row-hover-bg-color: getCssVar('fill-color', 'light') !global;
$table-current-row-bg-color: getCssVar('color', 'primary', 'light-9') !global;
$table-header-bg-color: getCssVar('bg-color', 'page') !global;
$table-fixed-box-shadow: 0 0 10px rgba(0, 0, 0, 0.12) !global;
$table-bg-color: getCssVar('fill-color', 'blank') !global;
$table-tr-bg-color: getCssVar('fill-color', 'blank') !global;
$table-expanded-cell-bg-color: getCssVar('fill-color', 'light') !global;
// Card 组件
$card-border-color: getCssVar('border-color', 'light') !global;
$card-border-radius: getCssVar('border-radius', 'base') !global;
$card-padding: 20px !global;
$card-bg-color: getCssVar('fill-color', 'blank') !global;
2.2 动态主题切换
typescript
// themeManager.ts - 主题管理器
import { ref, computed, watch } from 'vue'
// 主题类型定义
interface ThemeConfig {
name: string
displayName: string
colors: {
primary: string
success: string
warning: string
danger: string
info: string
}
textColors: {
primary: string
regular: string
secondary: string
placeholder: string
disabled: string
}
borderColors: {
base: string
light: string
lighter: string
extraLight: string
}
fillColors: {
base: string
light: string
lighter: string
extraLight: string
}
bgColors: {
base: string
page: string
overlay: string
}
borderRadius: {
base: string
small: string
round: string
circle: string
}
fontSize: {
extraLarge: string
large: string
medium: string
base: string
small: string
extraSmall: string
}
fontFamily: string
boxShadow: {
base: string
light: string
lighter: string
dark: string
}
}
// 预定义主题
const themes: Record<string, ThemeConfig> = {
default: {
name: 'default',
displayName: '默认主题',
colors: {
primary: '#409eff',
success: '#67c23a',
warning: '#e6a23c',
danger: '#f56c6c',
info: '#909399'
},
textColors: {
primary: '#303133',
regular: '#606266',
secondary: '#909399',
placeholder: '#a8abb2',
disabled: '#c0c4cc'
},
borderColors: {
base: '#dcdfe6',
light: '#e4e7ed',
lighter: '#ebeef5',
extraLight: '#f2f6fc'
},
fillColors: {
base: '#f0f2f5',
light: '#f5f7fa',
lighter: '#fafafa',
extraLight: '#fafcff'
},
bgColors: {
base: '#ffffff',
page: '#f2f3f5',
overlay: '#ffffff'
},
borderRadius: {
base: '4px',
small: '2px',
round: '20px',
circle: '100%'
},
fontSize: {
extraLarge: '20px',
large: '18px',
medium: '16px',
base: '14px',
small: '13px',
extraSmall: '12px'
},
fontFamily: "'Helvetica Neue', Helvetica, 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', Arial, sans-serif",
boxShadow: {
base: '0px 12px 32px 4px rgba(0, 0, 0, 0.04), 0px 8px 20px rgba(0, 0, 0, 0.08)',
light: '0px 0px 12px rgba(0, 0, 0, 0.12)',
lighter: '0px 0px 6px rgba(0, 0, 0, 0.12)',
dark: '0px 16px 48px 16px rgba(0, 0, 0, 0.08), 0px 12px 32px rgba(0, 0, 0, 0.12)'
}
},
dark: {
name: 'dark',
displayName: '暗黑主题',
colors: {
primary: '#409eff',
success: '#67c23a',
warning: '#e6a23c',
danger: '#f56c6c',
info: '#909399'
},
textColors: {
primary: '#e5eaf3',
regular: '#cfd3dc',
secondary: '#a3a6ad',
placeholder: '#8d9095',
disabled: '#6c6e72'
},
borderColors: {
base: '#4c4d4f',
light: '#414243',
lighter: '#363637',
extraLight: '#2b2b2c'
},
fillColors: {
base: '#303133',
light: '#262727',
lighter: '#1d1d1d',
extraLight: '#191919'
},
bgColors: {
base: '#141414',
page: '#0a0a0a',
overlay: '#1d1e1f'
},
borderRadius: {
base: '4px',
small: '2px',
round: '20px',
circle: '100%'
},
fontSize: {
extraLarge: '20px',
large: '18px',
medium: '16px',
base: '14px',
small: '13px',
extraSmall: '12px'
},
fontFamily: "'Helvetica Neue', Helvetica, 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', Arial, sans-serif",
boxShadow: {
base: '0px 12px 32px 4px rgba(0, 0, 0, 0.36), 0px 8px 20px rgba(0, 0, 0, 0.72)',
light: '0px 0px 12px rgba(0, 0, 0, 0.72)',
lighter: '0px 0px 6px rgba(0, 0, 0, 0.72)',
dark: '0px 16px 48px 16px rgba(0, 0, 0, 0.72), 0px 12px 32px rgba(0, 0, 0, 0.84)'
}
},
blue: {
name: 'blue',
displayName: '蓝色主题',
colors: {
primary: '#1890ff',
success: '#52c41a',
warning: '#faad14',
danger: '#ff4d4f',
info: '#1890ff'
},
textColors: {
primary: '#262626',
regular: '#595959',
secondary: '#8c8c8c',
placeholder: '#bfbfbf',
disabled: '#d9d9d9'
},
borderColors: {
base: '#d9d9d9',
light: '#e8e8e8',
lighter: '#f0f0f0',
extraLight: '#fafafa'
},
fillColors: {
base: '#f5f5f5',
light: '#fafafa',
lighter: '#ffffff',
extraLight: '#ffffff'
},
bgColors: {
base: '#ffffff',
page: '#f0f2f5',
overlay: '#ffffff'
},
borderRadius: {
base: '6px',
small: '4px',
round: '16px',
circle: '100%'
},
fontSize: {
extraLarge: '20px',
large: '18px',
medium: '16px',
base: '14px',
small: '12px',
extraSmall: '12px'
},
fontFamily: "'SF Pro Display', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif",
boxShadow: {
base: '0 2px 8px rgba(0, 0, 0, 0.15)',
light: '0 1px 3px rgba(0, 0, 0, 0.12)',
lighter: '0 1px 2px rgba(0, 0, 0, 0.08)',
dark: '0 4px 12px rgba(0, 0, 0, 0.15)'
}
},
green: {
name: 'green',
displayName: '绿色主题',
colors: {
primary: '#52c41a',
success: '#52c41a',
warning: '#faad14',
danger: '#ff4d4f',
info: '#1890ff'
},
textColors: {
primary: '#262626',
regular: '#595959',
secondary: '#8c8c8c',
placeholder: '#bfbfbf',
disabled: '#d9d9d9'
},
borderColors: {
base: '#d9d9d9',
light: '#e8e8e8',
lighter: '#f0f0f0',
extraLight: '#f6ffed'
},
fillColors: {
base: '#f6ffed',
light: '#f6ffed',
lighter: '#ffffff',
extraLight: '#ffffff'
},
bgColors: {
base: '#ffffff',
page: '#f6ffed',
overlay: '#ffffff'
},
borderRadius: {
base: '4px',
small: '2px',
round: '20px',
circle: '100%'
},
fontSize: {
extraLarge: '20px',
large: '18px',
medium: '16px',
base: '14px',
small: '13px',
extraSmall: '12px'
},
fontFamily: "'Helvetica Neue', Helvetica, 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', Arial, sans-serif",
boxShadow: {
base: '0px 12px 32px 4px rgba(0, 0, 0, 0.04), 0px 8px 20px rgba(0, 0, 0, 0.08)',
light: '0px 0px 12px rgba(0, 0, 0, 0.12)',
lighter: '0px 0px 6px rgba(0, 0, 0, 0.12)',
dark: '0px 16px 48px 16px rgba(0, 0, 0, 0.08), 0px 12px 32px rgba(0, 0, 0, 0.12)'
}
}
}
// 主题管理器类
class ThemeManager {
private currentTheme = ref<string>('default')
private customThemes = ref<Record<string, ThemeConfig>>({})
constructor() {
this.loadThemeFromStorage()
this.watchThemeChange()
}
// 获取当前主题
get theme() {
return this.currentTheme
}
// 获取当前主题配置
get themeConfig() {
return computed(() => {
const themeName = this.currentTheme.value
return this.customThemes.value[themeName] || themes[themeName] || themes.default
})
}
// 获取所有可用主题
get availableThemes() {
return computed(() => {
return {
...themes,
...this.customThemes.value
}
})
}
// 切换主题
setTheme(themeName: string) {
if (this.availableThemes.value[themeName]) {
this.currentTheme.value = themeName
this.applyTheme(this.availableThemes.value[themeName])
this.saveThemeToStorage(themeName)
} else {
console.warn(`Theme '${themeName}' not found`)
}
}
// 应用主题到 DOM
private applyTheme(config: ThemeConfig) {
const root = document.documentElement
// 应用颜色变量
Object.entries(config.colors).forEach(([type, color]) => {
root.style.setProperty(`--el-color-${type}`, color)
// 生成颜色变体
this.generateColorVariants(root, type, color)
})
// 应用文本颜色
Object.entries(config.textColors).forEach(([type, color]) => {
root.style.setProperty(`--el-text-color-${type}`, color)
})
// 应用边框颜色
Object.entries(config.borderColors).forEach(([type, color]) => {
const varName = type === 'base' ? '--el-border-color' : `--el-border-color-${type}`
root.style.setProperty(varName, color)
})
// 应用填充颜色
Object.entries(config.fillColors).forEach(([type, color]) => {
const varName = type === 'base' ? '--el-fill-color' : `--el-fill-color-${type}`
root.style.setProperty(varName, color)
})
// 应用背景颜色
Object.entries(config.bgColors).forEach(([type, color]) => {
const varName = type === 'base' ? '--el-bg-color' : `--el-bg-color-${type}`
root.style.setProperty(varName, color)
})
// 应用圆角
Object.entries(config.borderRadius).forEach(([type, value]) => {
const varName = type === 'base' ? '--el-border-radius-base' : `--el-border-radius-${type}`
root.style.setProperty(varName, value)
})
// 应用字体大小
Object.entries(config.fontSize).forEach(([type, value]) => {
root.style.setProperty(`--el-font-size-${type.replace(/([A-Z])/g, '-$1').toLowerCase()}`, value)
})
// 应用字体家族
root.style.setProperty('--el-font-family', config.fontFamily)
// 应用阴影
Object.entries(config.boxShadow).forEach(([type, value]) => {
const varName = type === 'base' ? '--el-box-shadow' : `--el-box-shadow-${type}`
root.style.setProperty(varName, value)
})
// 添加主题类名
document.body.className = document.body.className.replace(/theme-\w+/g, '')
document.body.classList.add(`theme-${config.name}`)
}
// 生成颜色变体
private generateColorVariants(root: HTMLElement, type: string, baseColor: string) {
// 生成浅色变体
const lightLevels = [3, 5, 7, 8, 9]
lightLevels.forEach(level => {
const lightColor = this.mixColors('#ffffff', baseColor, level / 10)
root.style.setProperty(`--el-color-${type}-light-${level}`, lightColor)
})
// 生成深色变体
const darkColor = this.mixColors('#000000', baseColor, 0.2)
root.style.setProperty(`--el-color-${type}-dark-2`, darkColor)
}
// 颜色混合函数
private mixColors(color1: string, color2: string, ratio: number): string {
const hex1 = color1.replace('#', '')
const hex2 = color2.replace('#', '')
const r1 = parseInt(hex1.substr(0, 2), 16)
const g1 = parseInt(hex1.substr(2, 2), 16)
const b1 = parseInt(hex1.substr(4, 2), 16)
const r2 = parseInt(hex2.substr(0, 2), 16)
const g2 = parseInt(hex2.substr(2, 2), 16)
const b2 = parseInt(hex2.substr(4, 2), 16)
const r = Math.round(r1 * ratio + r2 * (1 - ratio))
const g = Math.round(g1 * ratio + g2 * (1 - ratio))
const b = Math.round(b1 * ratio + b2 * (1 - ratio))
return `#${r.toString(16).padStart(2, '0')}${g.toString(16).padStart(2, '0')}${b.toString(16).padStart(2, '0')}`
}
// 注册自定义主题
registerTheme(config: ThemeConfig) {
this.customThemes.value[config.name] = config
}
// 移除自定义主题
removeTheme(themeName: string) {
if (this.customThemes.value[themeName]) {
delete this.customThemes.value[themeName]
// 如果当前主题被删除,切换到默认主题
if (this.currentTheme.value === themeName) {
this.setTheme('default')
}
}
}
// 从本地存储加载主题
private loadThemeFromStorage() {
try {
const savedTheme = localStorage.getItem('element-plus-theme')
const savedCustomThemes = localStorage.getItem('element-plus-custom-themes')
if (savedTheme && this.availableThemes.value[savedTheme]) {
this.currentTheme.value = savedTheme
}
if (savedCustomThemes) {
this.customThemes.value = JSON.parse(savedCustomThemes)
}
} catch (error) {
console.warn('Failed to load theme from storage:', error)
}
}
// 保存主题到本地存储
private saveThemeToStorage(themeName: string) {
try {
localStorage.setItem('element-plus-theme', themeName)
localStorage.setItem('element-plus-custom-themes', JSON.stringify(this.customThemes.value))
} catch (error) {
console.warn('Failed to save theme to storage:', error)
}
}
// 监听主题变化
private watchThemeChange() {
watch(
this.currentTheme,
(newTheme) => {
const config = this.availableThemes.value[newTheme]
if (config) {
this.applyTheme(config)
}
},
{ immediate: true }
)
}
// 导出主题配置
exportTheme(themeName: string): string | null {
const config = this.availableThemes.value[themeName]
if (config) {
return JSON.stringify(config, null, 2)
}
return null
}
// 导入主题配置
importTheme(configJson: string): boolean {
try {
const config: ThemeConfig = JSON.parse(configJson)
// 验证配置格式
if (this.validateThemeConfig(config)) {
this.registerTheme(config)
return true
}
return false
} catch (error) {
console.error('Failed to import theme:', error)
return false
}
}
// 验证主题配置
private validateThemeConfig(config: any): config is ThemeConfig {
const requiredFields = ['name', 'displayName', 'colors', 'textColors', 'borderColors', 'fillColors', 'bgColors']
return requiredFields.every(field => {
return config && typeof config[field] !== 'undefined'
})
}
// 重置所有主题
resetThemes() {
this.customThemes.value = {}
this.setTheme('default')
localStorage.removeItem('element-plus-custom-themes')
}
}
// 创建主题管理器实例
const themeManager = new ThemeManager()
// 导出组合式函数
export function useTheme() {
return {
currentTheme: themeManager.theme,
themeConfig: themeManager.themeConfig,
availableThemes: themeManager.availableThemes,
setTheme: themeManager.setTheme.bind(themeManager),
registerTheme: themeManager.registerTheme.bind(themeManager),
removeTheme: themeManager.removeTheme.bind(themeManager),
exportTheme: themeManager.exportTheme.bind(themeManager),
importTheme: themeManager.importTheme.bind(themeManager),
resetThemes: themeManager.resetThemes.bind(themeManager)
}
}
export { themeManager, themes }
export type { ThemeConfig }
3. 主题切换组件实现
3.1 主题选择器组件
vue
<!-- ThemeSelector.vue - 主题选择器组件 -->
<template>
<div class="theme-selector">
<el-dropdown
trigger="click"
placement="bottom-end"
@command="handleThemeChange"
>
<el-button class="theme-trigger">
<el-icon><Palette /></el-icon>
<span>{{ currentThemeConfig.displayName }}</span>
<el-icon class="el-icon--right"><ArrowDown /></el-icon>
</el-button>
<template #dropdown>
<el-dropdown-menu class="theme-dropdown">
<div class="theme-section">
<div class="theme-section-title">预设主题</div>
<div class="theme-grid">
<div
v-for="(theme, key) in presetThemes"
:key="key"
class="theme-item"
:class="{ active: currentTheme === key }"
@click="handleThemeChange(key)"
>
<div class="theme-preview">
<div
class="color-dot primary"
:style="{ backgroundColor: theme.colors.primary }"
></div>
<div
class="color-dot success"
:style="{ backgroundColor: theme.colors.success }"
></div>
<div
class="color-dot warning"
:style="{ backgroundColor: theme.colors.warning }"
></div>
<div
class="color-dot danger"
:style="{ backgroundColor: theme.colors.danger }"
></div>
</div>
<div class="theme-name">{{ theme.displayName }}</div>
</div>
</div>
</div>
<el-divider v-if="Object.keys(customThemes).length > 0" />
<div v-if="Object.keys(customThemes).length > 0" class="theme-section">
<div class="theme-section-title">
自定义主题
<el-button
type="text"
size="small"
@click="showCustomThemeDialog = true"
>
管理
</el-button>
</div>
<div class="theme-grid">
<div
v-for="(theme, key) in customThemes"
:key="key"
class="theme-item"
:class="{ active: currentTheme === key }"
@click="handleThemeChange(key)"
>
<div class="theme-preview">
<div
class="color-dot primary"
:style="{ backgroundColor: theme.colors.primary }"
></div>
<div
class="color-dot success"
:style="{ backgroundColor: theme.colors.success }"
></div>
<div
class="color-dot warning"
:style="{ backgroundColor: theme.colors.warning }"
></div>
<div
class="color-dot danger"
:style="{ backgroundColor: theme.colors.danger }"
></div>
</div>
<div class="theme-name">{{ theme.displayName }}</div>
</div>
</div>
</div>
<el-divider />
<el-dropdown-item class="theme-action" @click="showThemeEditor = true">
<el-icon><Plus /></el-icon>
创建自定义主题
</el-dropdown-item>
<el-dropdown-item class="theme-action" @click="showImportDialog = true">
<el-icon><Upload /></el-icon>
导入主题
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
<!-- 主题编辑器对话框 -->
<ThemeEditor
v-model="showThemeEditor"
:initial-theme="editingTheme"
@save="handleThemeSave"
/>
<!-- 主题管理对话框 -->
<ThemeManager
v-model="showCustomThemeDialog"
@delete="handleThemeDelete"
@edit="handleThemeEdit"
/>
<!-- 导入主题对话框 -->
<ImportThemeDialog
v-model="showImportDialog"
@import="handleThemeImport"
/>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { ElMessage } from 'element-plus'
import { Palette, ArrowDown, Plus, Upload } from '@element-plus/icons-vue'
import { useTheme, type ThemeConfig } from './themeManager'
import ThemeEditor from './ThemeEditor.vue'
import ThemeManager from './ThemeManager.vue'
import ImportThemeDialog from './ImportThemeDialog.vue'
// 使用主题管理器
const {
currentTheme,
themeConfig: currentThemeConfig,
availableThemes,
setTheme,
registerTheme,
removeTheme
} = useTheme()
// 对话框状态
const showThemeEditor = ref(false)
const showCustomThemeDialog = ref(false)
const showImportDialog = ref(false)
const editingTheme = ref<ThemeConfig | null>(null)
// 计算属性
const presetThemes = computed(() => {
const preset = ['default', 'dark', 'blue', 'green']
const result: Record<string, ThemeConfig> = {}
preset.forEach(key => {
if (availableThemes.value[key]) {
result[key] = availableThemes.value[key]
}
})
return result
})
const customThemes = computed(() => {
const preset = ['default', 'dark', 'blue', 'green']
const result: Record<string, ThemeConfig> = {}
Object.entries(availableThemes.value).forEach(([key, theme]) => {
if (!preset.includes(key)) {
result[key] = theme
}
})
return result
})
// 事件处理
const handleThemeChange = (themeName: string) => {
setTheme(themeName)
ElMessage.success(`已切换到${availableThemes.value[themeName]?.displayName}主题`)
}
const handleThemeSave = (theme: ThemeConfig) => {
registerTheme(theme)
setTheme(theme.name)
showThemeEditor.value = false
editingTheme.value = null
ElMessage.success(`主题 "${theme.displayName}" 已保存`)
}
const handleThemeDelete = (themeName: string) => {
const theme = availableThemes.value[themeName]
removeTheme(themeName)
ElMessage.success(`主题 "${theme?.displayName}" 已删除`)
}
const handleThemeEdit = (themeName: string) => {
editingTheme.value = availableThemes.value[themeName]
showThemeEditor.value = true
showCustomThemeDialog.value = false
}
const handleThemeImport = (theme: ThemeConfig) => {
registerTheme(theme)
showImportDialog.value = false
ElMessage.success(`主题 "${theme.displayName}" 导入成功`)
}
</script>
<style scoped>
.theme-selector {
display: inline-block;
}
.theme-trigger {
display: flex;
align-items: center;
gap: 8px;
}
.theme-dropdown {
width: 320px;
max-height: 400px;
overflow-y: auto;
}
.theme-section {
padding: 12px;
}
.theme-section-title {
display: flex;
justify-content: space-between;
align-items: center;
font-size: 14px;
font-weight: 500;
color: var(--el-text-color-primary);
margin-bottom: 12px;
}
.theme-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 12px;
}
.theme-item {
display: flex;
flex-direction: column;
align-items: center;
padding: 12px;
border: 1px solid var(--el-border-color-light);
border-radius: var(--el-border-radius-base);
cursor: pointer;
transition: all 0.2s;
}
.theme-item:hover {
border-color: var(--el-color-primary);
background-color: var(--el-color-primary-light-9);
}
.theme-item.active {
border-color: var(--el-color-primary);
background-color: var(--el-color-primary-light-8);
}
.theme-preview {
display: flex;
gap: 4px;
margin-bottom: 8px;
}
.color-dot {
width: 12px;
height: 12px;
border-radius: 50%;
border: 1px solid var(--el-border-color-light);
}
.theme-name {
font-size: 12px;
color: var(--el-text-color-regular);
text-align: center;
}
.theme-action {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
}
.theme-action:hover {
background-color: var(--el-fill-color-light);
}
</style>
3.2 主题编辑器组件
vue
<!-- ThemeEditor.vue - 主题编辑器 -->
<template>
<el-dialog
v-model="visible"
title="主题编辑器"
width="800px"
:before-close="handleClose"
>
<div class="theme-editor">
<el-form
ref="formRef"
:model="form"
:rules="rules"
label-width="120px"
>
<!-- 基本信息 -->
<div class="editor-section">
<h3>基本信息</h3>
<el-form-item label="主题名称" prop="name">
<el-input v-model="form.name" placeholder="请输入主题名称" />
</el-form-item>
<el-form-item label="显示名称" prop="displayName">
<el-input v-model="form.displayName" placeholder="请输入显示名称" />
</el-form-item>
</div>
<!-- 主要颜色 -->
<div class="editor-section">
<h3>主要颜色</h3>
<div class="color-grid">
<el-form-item
v-for="(color, key) in form.colors"
:key="key"
:label="colorLabels[key]"
>
<div class="color-input">
<el-color-picker
v-model="form.colors[key]"
show-alpha
:predefine="predefineColors"
/>
<el-input
v-model="form.colors[key]"
class="color-text"
placeholder="#000000"
/>
</div>
</el-form-item>
</div>
</div>
<!-- 文本颜色 -->
<div class="editor-section">
<h3>文本颜色</h3>
<div class="color-grid">
<el-form-item
v-for="(color, key) in form.textColors"
:key="key"
:label="textColorLabels[key]"
>
<div class="color-input">
<el-color-picker
v-model="form.textColors[key]"
show-alpha
:predefine="predefineColors"
/>
<el-input
v-model="form.textColors[key]"
class="color-text"
placeholder="#000000"
/>
</div>
</el-form-item>
</div>
</div>
<!-- 边框和填充 -->
<div class="editor-section">
<h3>边框和填充</h3>
<el-row :gutter="20">
<el-col :span="12">
<h4>边框颜色</h4>
<el-form-item
v-for="(color, key) in form.borderColors"
:key="key"
:label="borderColorLabels[key]"
>
<div class="color-input">
<el-color-picker
v-model="form.borderColors[key]"
show-alpha
:predefine="predefineColors"
/>
<el-input
v-model="form.borderColors[key]"
class="color-text"
placeholder="#000000"
/>
</div>
</el-form-item>
</el-col>
<el-col :span="12">
<h4>填充颜色</h4>
<el-form-item
v-for="(color, key) in form.fillColors"
:key="key"
:label="fillColorLabels[key]"
>
<div class="color-input">
<el-color-picker
v-model="form.fillColors[key]"
show-alpha
:predefine="predefineColors"
/>
<el-input
v-model="form.fillColors[key]"
class="color-text"
placeholder="#000000"
/>
</div>
</el-form-item>
</el-col>
</el-row>
</div>
<!-- 字体设置 -->
<div class="editor-section">
<h3>字体设置</h3>
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="字体家族">
<el-input
v-model="form.fontFamily"
type="textarea"
:rows="2"
placeholder="请输入字体家族"
/>
</el-form-item>
</el-col>
<el-col :span="12">
<h4>字体大小</h4>
<el-form-item
v-for="(size, key) in form.fontSize"
:key="key"
:label="fontSizeLabels[key]"
>
<el-input
v-model="form.fontSize[key]"
placeholder="14px"
>
<template #append>px</template>
</el-input>
</el-form-item>
</el-col>
</el-row>
</div>
<!-- 圆角设置 -->
<div class="editor-section">
<h3>圆角设置</h3>
<el-row :gutter="20">
<el-col :span="6" v-for="(radius, key) in form.borderRadius" :key="key">
<el-form-item :label="borderRadiusLabels[key]">
<el-input
v-model="form.borderRadius[key]"
placeholder="4px"
>
<template #append>px</template>
</el-input>
</el-form-item>
</el-col>
</el-row>
</div>
</el-form>
<!-- 预览区域 -->
<div class="preview-section">
<h3>预览效果</h3>
<div class="preview-container" :style="previewStyles">
<div class="preview-card">
<div class="preview-header">
<h4>预览标题</h4>
<el-button type="primary" size="small">主要按钮</el-button>
</div>
<div class="preview-content">
<p>这是一段预览文本,用于展示主题效果。</p>
<el-button>默认按钮</el-button>
<el-button type="success">成功按钮</el-button>
<el-button type="warning">警告按钮</el-button>
<el-button type="danger">危险按钮</el-button>
</div>
</div>
</div>
</div>
</div>
<template #footer>
<div class="dialog-footer">
<el-button @click="handleClose">取消</el-button>
<el-button @click="handleReset">重置</el-button>
<el-button type="primary" @click="handleSave">保存主题</el-button>
</div>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import { ref, computed, watch, nextTick } from 'vue'
import { ElMessage, type FormInstance, type FormRules } from 'element-plus'
import type { ThemeConfig } from './themeManager'
// Props
interface Props {
modelValue: boolean
initialTheme?: ThemeConfig | null
}
const props = withDefaults(defineProps<Props>(), {
initialTheme: null
})
// Emits
interface Emits {
(e: 'update:modelValue', value: boolean): void
(e: 'save', theme: ThemeConfig): void
}
const emit = defineEmits<Emits>()
// 响应式数据
const visible = computed({
get: () => props.modelValue,
set: (value) => emit('update:modelValue', value)
})
const formRef = ref<FormInstance>()
// 表单数据
const defaultForm = (): ThemeConfig => ({
name: '',
displayName: '',
colors: {
primary: '#409eff',
success: '#67c23a',
warning: '#e6a23c',
danger: '#f56c6c',
info: '#909399'
},
textColors: {
primary: '#303133',
regular: '#606266',
secondary: '#909399',
placeholder: '#a8abb2',
disabled: '#c0c4cc'
},
borderColors: {
base: '#dcdfe6',
light: '#e4e7ed',
lighter: '#ebeef5',
extraLight: '#f2f6fc'
},
fillColors: {
base: '#f0f2f5',
light: '#f5f7fa',
lighter: '#fafafa',
extraLight: '#fafcff'
},
bgColors: {
base: '#ffffff',
page: '#f2f3f5',
overlay: '#ffffff'
},
borderRadius: {
base: '4px',
small: '2px',
round: '20px',
circle: '100%'
},
fontSize: {
extraLarge: '20px',
large: '18px',
medium: '16px',
base: '14px',
small: '13px',
extraSmall: '12px'
},
fontFamily: "'Helvetica Neue', Helvetica, 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', Arial, sans-serif",
boxShadow: {
base: '0px 12px 32px 4px rgba(0, 0, 0, 0.04), 0px 8px 20px rgba(0, 0, 0, 0.08)',
light: '0px 0px 12px rgba(0, 0, 0, 0.12)',
lighter: '0px 0px 6px rgba(0, 0, 0, 0.12)',
dark: '0px 16px 48px 16px rgba(0, 0, 0, 0.08), 0px 12px 32px rgba(0, 0, 0, 0.12)'
}
})
const form = ref<ThemeConfig>(defaultForm())
// 表单验证规则
const rules: FormRules = {
name: [
{ required: true, message: '请输入主题名称', trigger: 'blur' },
{ pattern: /^[a-zA-Z][a-zA-Z0-9_-]*$/, message: '主题名称只能包含字母、数字、下划线和连字符,且必须以字母开头', trigger: 'blur' }
],
displayName: [
{ required: true, message: '请输入显示名称', trigger: 'blur' }
]
}
// 标签映射
const colorLabels = {
primary: '主要色',
success: '成功色',
warning: '警告色',
danger: '危险色',
info: '信息色'
}
const textColorLabels = {
primary: '主要文本',
regular: '常规文本',
secondary: '次要文本',
placeholder: '占位文本',
disabled: '禁用文本'
}
const borderColorLabels = {
base: '基础边框',
light: '浅色边框',
lighter: '更浅边框',
extraLight: '极浅边框'
}
const fillColorLabels = {
base: '基础填充',
light: '浅色填充',
lighter: '更浅填充',
extraLight: '极浅填充'
}
const fontSizeLabels = {
extraLarge: '超大字体',
large: '大字体',
medium: '中等字体',
base: '基础字体',
small: '小字体',
extraSmall: '超小字体'
}
const borderRadiusLabels = {
base: '基础圆角',
small: '小圆角',
round: '圆形',
circle: '圆形'
}
// 预定义颜色
const predefineColors = [
'#ff4500',
'#ff8c00',
'#ffd700',
'#90ee90',
'#00ced1',
'#1e90ff',
'#c71585',
'rgba(255, 69, 0, 0.68)',
'rgb(255, 120, 0)',
'hsv(51, 100, 98)',
'hsva(120, 40, 94, 0.5)',
'hsl(181, 100%, 37%)',
'hsla(209, 100%, 56%, 0.73)',
'#c7158577'
]
// 预览样式
const previewStyles = computed(() => {
return {
'--preview-primary': form.value.colors.primary,
'--preview-success': form.value.colors.success,
'--preview-warning': form.value.colors.warning,
'--preview-danger': form.value.colors.danger,
'--preview-text-primary': form.value.textColors.primary,
'--preview-text-regular': form.value.textColors.regular,
'--preview-border': form.value.borderColors.base,
'--preview-bg': form.value.bgColors.base,
'--preview-fill': form.value.fillColors.base,
'--preview-border-radius': form.value.borderRadius.base,
'--preview-font-family': form.value.fontFamily,
'--preview-font-size': form.value.fontSize.base
}
})
// 监听初始主题变化
watch(
() => props.initialTheme,
(newTheme) => {
if (newTheme) {
form.value = { ...newTheme }
} else {
form.value = defaultForm()
}
},
{ immediate: true, deep: true }
)
// 事件处理
const handleClose = () => {
visible.value = false
}
const handleReset = () => {
if (props.initialTheme) {
form.value = { ...props.initialTheme }
} else {
form.value = defaultForm()
}
nextTick(() => {
formRef.value?.clearValidate()
})
}
const handleSave = async () => {
if (!formRef.value) return
try {
await formRef.value.validate()
emit('save', { ...form.value })
} catch (error) {
ElMessage.error('请检查表单输入')
}
}
</script>
<style scoped>
.theme-editor {
max-height: 600px;
overflow-y: auto;
}
.editor-section {
margin-bottom: 24px;
padding-bottom: 16px;
border-bottom: 1px solid var(--el-border-color-lighter);
}
.editor-section:last-child {
border-bottom: none;
}
.editor-section h3 {
margin: 0 0 16px 0;
font-size: 16px;
font-weight: 500;
color: var(--el-text-color-primary);
}
.editor-section h4 {
margin: 0 0 12px 0;
font-size: 14px;
font-weight: 500;
color: var(--el-text-color-regular);
}
.color-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 16px;
}
.color-input {
display: flex;
align-items: center;
gap: 8px;
}
.color-text {
flex: 1;
}
.preview-section {
margin-top: 24px;
padding-top: 16px;
border-top: 1px solid var(--el-border-color-lighter);
}
.preview-container {
padding: 16px;
border: 1px solid var(--preview-border);
border-radius: var(--preview-border-radius);
background-color: var(--preview-bg);
font-family: var(--preview-font-family);
font-size: var(--preview-font-size);
}
.preview-card {
background: var(--preview-fill);
border-radius: var(--preview-border-radius);
padding: 16px;
}
.preview-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
}
.preview-header h4 {
margin: 0;
color: var(--preview-text-primary);
}
.preview-content p {
color: var(--preview-text-regular);
margin-bottom: 16px;
}
.preview-content .el-button {
margin-right: 8px;
}
.dialog-footer {
display: flex;
justify-content: flex-end;
gap: 8px;
}
</style>
实践练习
练习1:自定义主题创建
scss
// 创建企业级主题
// 1. 定义品牌色彩系统
// 2. 设置文本和背景色
// 3. 配置组件样式
// 4. 实现主题切换
练习2:暗黑模式实现
typescript
// 实现暗黑模式切换
// 1. 定义暗黑主题变量
// 2. 实现自动切换逻辑
// 3. 处理图片和图标适配
// 4. 优化用户体验
练习3:动态主题生成
typescript
// 实现动态主题生成器
// 1. 基于主色生成配色方案
// 2. 自动计算对比度
// 3. 生成完整主题配置
// 4. 实时预览效果
练习4:主题管理系统
typescript
// 构建完整的主题管理系统
// 1. 主题导入导出功能
// 2. 主题版本管理
// 3. 主题分享机制
// 4. 主题市场功能
学习资源
官方文档
设计系统
工具和资源
作业
- 主题系统:实现完整的主题切换系统
- 自定义主题:创建符合企业品牌的自定义主题
- 暗黑模式:实现自动暗黑模式切换功能
- 主题编辑器:开发可视化主题编辑工具
- 性能优化:优化主题切换的性能和用户体验
下一步学习
明天我们将学习「Element Plus 国际化深入应用」,包括:
- 多语言配置系统
- 动态语言切换
- 自定义语言包
- 国际化最佳实践