简历描述模板
版本一:注重用户体验
设计并实现企业级主题切换系统,支持亮色/暗色模式及10+套自定义主题无缝切换。采用
CSS变量+动态样式注入方案,主题切换响应时间<100ms,无闪烁。基于HSL色彩空间实现
主题色智能计算,自动生成hover、active等衍生色值,保证视觉一致性。开发可视化配
置界面,用户可自定义品牌色、圆角、间距等30+参数并实时预览,支持导出为JSON配置
文件。针对暗色模式优化了对比度和阴影算法,通过WCAG 2.1 AAA级无障碍标准。
版本二:强调技术实现
主导主题系统架构设计,构建基于CSS Custom Properties的动态主题引擎。通过计算属
性和watch实现主题参数的响应式更新,配合Pinia状态管理持久化用户偏好。针对暗色模
式实现了色彩反转算法和智能对比度调整,处理了图片、Icon、阴影等20+场景的适配问
题。开发主题编辑器,支持实时预览、导入导出、版本管理,非技术人员可独立创建主题
方案。集成系统主题检测API,自动匹配用户操作系统的明暗偏好。
版本三:突出工程化能力
负责前端主题系统的完整落地,支持多主题动态切换和品牌定制化。设计了模块化的主题
配置结构,将色彩、字体、间距、圆角等抽象为独立变量层,便于维护和扩展。实现了主
题预编译工具,将SCSS变量转换为CSS变量,兼容老项目的主题方案。针对低端设备优化
了切换性能,通过RAF批量更新样式,避免重排和重绘。搭建主题市场,用户可浏览、试
用、下载社区分享的主题包,并支持一键应用到自己的工作空间。
SOP 标准回答
Q1: 你们的主题切换方案是怎么实现的?
标准回答:
我们的主题方案主要用的是CSS变量,整体分三层:
底层是变量定义层。我把所有的颜色、字体、间距这些设计规范抽成CSS变量,定义在:root
上。比如主色就是--color-primary,文字色是--color-text,背景色是--color-bg这样。
一个主题大概有50多个变量,覆盖颜色、尺寸、阴影、圆角等方面。
中间层是主题管理。我用Pinia做状态管理,存储当前主题ID和主题配置对象。切换主题时,
就是把新主题的变量值通过document.documentElement.style.setProperty批量写到:root
上。为了避免一次性更新太多变量造成卡顿,我用requestAnimationFrame做了分批处理,
一帧更新10个变量,实测切换时间从500ms降到了80ms左右。
上层是应用层。组件里直接用var(--color-primary)这种方式引用变量,不用关心具体值
是多少。这样切换主题时,所有引用了变量的地方会自动更新,不需要重新渲染组件。暗
色模式也是一样的逻辑,只是变量值从亮色改成暗色。
还有个细节是主题预加载,用户在设置页面浏览主题列表时,鼠标hover到某个主题就开
始预加载它的配置,真正点击切换时配置已经在内存里了,切换会更快。
追问应对:
- "为什么用CSS变量不用预处理器?" → CSS变量可运行时修改,Sass变量编译时确定无法动态切换
- "如何保证主题切换不闪烁?" → 用transition做过渡动画,加载中用骨架屏遮盖
- "如果浏览器不支持CSS变量怎么办?" → 提供fallback值,检测支持度降级到class切换
Q2: 暗色模式的适配有什么难点?
标准回答:
暗色模式确实不是简单地把背景改黑、文字改白就行了,主要有几个难点:
第一是对比度问题。暗色背景上纯白文字会太刺眼,我们参考了Material Design的方案,
文字色用的是rgba(255,255,255,0.87),次要文字用0.6,禁用状态用0.38。这个透明度
不是随便定的,是经过对比度测试工具验证过的,保证符合WCAG 2.1的AA级标准,就是对
比度至少4.5:1。
第二是颜色调整。亮色模式下的鲜艳色彩,直接用到暗色模式会很突兀。我用HSL色彩空间
来做转换,保持色相H不变,降低饱和度S和亮度L。比如主题色从hsl(210, 80%, 50%)调
整到hsl(210, 60%, 60%),看起来不会太刺眼。次要颜色也是同样的逻辑,整体视觉会比
较和谐。
第三是图片和Icon。彩色图片在暗色背景上会很突兀,我给Image组件加了个darkFilter属
性,暗色模式下自动加filter: brightness(0.8) contrast(0.9)降低亮度。对于纯色Icon,
用fill属性引用CSS变量,跟随主题自动变色。但Logo这些带品牌色的元素不能变,要单独
处理。
第四是阴影。亮色模式用黑色半透明阴影,暗色模式下几乎看不见。我改成用白色半透明
阴影,box-shadow: 0 2px 8px rgba(255,255,255,0.1),这样在暗色背景上能体现出层次
感。还有个trick是把elevation-1、elevation-2这些层级定义成CSS变量,不同模式下给
不同的阴影值。
最后是过渡效果。切换明暗模式时,如果所有颜色瞬间变化会很生硬,我给关键元素加了
transition: background-color 0.3s, color 0.3s,这样切换会有个渐变过程,体验好很
多。但要注意不能给所有元素加过渡,会影响性能。
Q3: 主题色的衍生色是怎么计算的?
标准回答:
这个是我研究了一段时间才搞明白的。一开始我是手动配置每个状态的颜色,但每次改主
题色都要调一大堆值,特别麻烦。后来参考了Ant Design的做法,用算法自动生成。
核心原理是基于HSL色彩空间。HSL就是色相Hue、饱和度Saturation、亮度Lightness,比
RGB更符合人类对颜色的认知。我先把主题色从HEX格式转成HSL,然后基于不同状态调整
饱和度和亮度:
- hover状态:亮度增加10%,比如L从50%变成60%
- active状态:亮度减少10%,L从50%变成40%
- disabled状态:饱和度降低到30%,亮度提升到70%,看起来灰蒙蒙的
- light版本:饱和度降低20%,亮度提升30%,用在浅色背景
- dark版本:饱和度提升10%,亮度降低20%,用在暗色背景
实际代码大概是这样的思路:先把HEX转HSL,调整HSL的值,再转回HEX。我封装了一个
generateColorPalette函数,输入一个主题色,输出10个衍生色。这样只要用户选了一个
主色,整套色系就自动生成了。
还有个细节是对比度保护,有些颜色在调整后可能对比度不够。我会用color-contrast函
数检查,如果对比度低于4.5:1,就强制调整亮度直到达标。这样能保证不管用户选什么主
题色,文字都能看清楚。
另外,语义化颜色也是类似逻辑。比如success是绿色、warning是橙色、error是红色,这
些颜色也要有hover、active状态,都是基于基础色用算法生成的。这样整个色系就很统一,
不会显得乱七八糟。
难点与亮点分析
难点一:主题切换性能优化
问题背景: 初版实现时,切换主题需要更新50+个CSS变量,document.documentElement.style.setProperty 会触发大量重排重绘,在低端手机上卡顿明显,切换耗时达到500ms。
解决思路:
- 使用RAF批量更新,避免连续触发重排
- 只更新变化的变量,跳过值相同的
- 用transform代替会触发重排的属性
- 关键帧动画配合will-change优化
技术实现:
// composables/useTheme.js
import { ref, computed, watch } from 'vue'
import { useThemeStore } from '@/stores/theme'
export function useTheme() {
const themeStore = useThemeStore()
const switching = ref(false)
const progress = ref(0)
// 当前主题配置
const currentTheme = computed(() => themeStore.currentTheme)
// 批量更新CSS变量
function updateCSSVariables(variables, onProgress) {
return new Promise((resolve) => {
const entries = Object.entries(variables)
const batchSize = 10 // 每批次更新10个变量
let currentIndex = 0
function updateBatch() {
const batch = entries.slice(currentIndex, currentIndex + batchSize)
const root = document.documentElement.style
batch.forEach(([key, value]) => {
// 只更新值不同的变量
const currentValue = root.getPropertyValue(key)
if (currentValue !== value) {
root.setProperty(key, value)
}
})
currentIndex += batchSize
const completed = Math.min(currentIndex / entries.length, 1)
if (onProgress) {
onProgress(completed)
}
if (currentIndex < entries.length) {
requestAnimationFrame(updateBatch)
} else {
resolve()
}
}
requestAnimationFrame(updateBatch)
})
}
// 切换主题
async function changeTheme(themeId) {
if (switching.value) return
switching.value = true
progress.value = 0
try {
// 添加切换类,启用过渡动画
document.documentElement.classList.add('theme-switching')
// 加载主题配置
const theme = await themeStore.loadTheme(themeId)
// 批量更新CSS变量
await updateCSSVariables(theme.variables, (p) => {
progress.value = p * 100
})
// 保存到本地存储
themeStore.setCurrentTheme(themeId)
localStorage.setItem('theme-id', themeId)
// 触发主题变化事件
document.dispatchEvent(new CustomEvent('theme-changed', {
detail: { themeId, theme }
}))
} catch (error) {
console.error('切换主题失败:', error)
throw error
} finally {
// 移除切换类
setTimeout(() => {
document.documentElement.classList.remove('theme-switching')
switching.value = false
progress.value = 0
}, 300)
}
}
// 切换暗色模式
function toggleDarkMode() {
const currentMode = themeStore.darkMode
const newMode = !currentMode
// 立即更新class,避免闪烁
if (newMode) {
document.documentElement.classList.add('dark')
} else {
document.documentElement.classList.remove('dark')
}
// 更新store
themeStore.setDarkMode(newMode)
// 自动切换对应的亮色/暗色主题
const targetTheme = newMode
? themeStore.currentTheme.darkVersion
: themeStore.currentTheme.lightVersion
if (targetTheme) {
changeTheme(targetTheme)
}
}
// 监听系统主题变化
function watchSystemTheme() {
if (!window.matchMedia) return
const darkModeQuery = window.matchMedia('(prefers-color-scheme: dark)')
const handleChange = (e) => {
if (themeStore.autoSync) {
toggleDarkMode()
}
}
// 监听变化
darkModeQuery.addEventListener('change', handleChange)
// 初始化
if (themeStore.autoSync) {
const prefersDark = darkModeQuery.matches
if (prefersDark !== themeStore.darkMode) {
toggleDarkMode()
}
}
// 返回清理函数
return () => {
darkModeQuery.removeEventListener('change', handleChange)
}
}
return {
currentTheme,
switching,
progress,
changeTheme,
toggleDarkMode,
watchSystemTheme
}
}
全局样式优化:
/* styles/theme-transition.css */
/* 主题切换时的过渡动画 */
.theme-switching {
/* 只对关键属性添加过渡 */
* {
transition:
background-color 0.3s cubic-bezier(0.4, 0, 0.2, 1),
color 0.3s cubic-bezier(0.4, 0, 0.2, 1),
border-color 0.3s cubic-bezier(0.4, 0, 0.2, 1),
box-shadow 0.3s cubic-bezier(0.4, 0, 0.2, 1) !important;
}
}
/* 优化性能:使用transform和opacity */
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.3s;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
/* 骨架屏遮罩 */
.theme-loading-mask {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: var(--color-bg-overlay);
z-index: 9999;
display: flex;
align-items: center;
justify-content: center;
opacity: 0;
pointer-events: none;
transition: opacity 0.3s;
}
.theme-loading-mask.active {
opacity: 1;
pointer-events: all;
}
/* 使用will-change优化动画性能 */
.theme-preview-card {
will-change: transform;
transition: transform 0.3s;
}
.theme-preview-card:hover {
transform: translateY(-4px);
}
难点二:暗色模式的色彩反转算法
问题背景: 暗色模式不是简单的黑白反转,需要智能调整每个颜色的亮度、饱和度,保持视觉舒适度和对比度。
解决方案:
// utils/colorConverter.js
// HEX转RGB
function hexToRgb(hex) {
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex)
return result ? {
r: parseInt(result[1], 16),
g: parseInt(result[2], 16),
b: parseInt(result[3], 16)
} : null
}
// RGB转HSL
function rgbToHsl(r, g, b) {
r /= 255
g /= 255
b /= 255
const max = Math.max(r, g, b)
const min = Math.min(r, g, b)
let h, s, l = (max + min) / 2
if (max === min) {
h = s = 0 // 灰色
} else {
const d = max - min
s = l > 0.5 ? d / (2 - max - min) : d / (max + min)
switch (max) {
case r:
h = ((g - b) / d + (g < b ? 6 : 0)) / 6
break
case g:
h = ((b - r) / d + 2) / 6
break
case b:
h = ((r - g) / d + 4) / 6
break
}
}
return {
h: Math.round(h * 360),
s: Math.round(s * 100),
l: Math.round(l * 100)
}
}
// HSL转RGB
function hslToRgb(h, s, l) {
h /= 360
s /= 100
l /= 100
let r, g, b
if (s === 0) {
r = g = b = l
} else {
const hue2rgb = (p, q, t) => {
if (t < 0) t += 1
if (t > 1) t -= 1
if (t < 1/6) return p + (q - p) * 6 * t
if (t < 1/2) return q
if (t < 2/3) return p + (q - p) * (2/3 - t) * 6
return p
}
const q = l < 0.5 ? l * (1 + s) : l + s - l * s
const p = 2 * l - q
r = hue2rgb(p, q, h + 1/3)
g = hue2rgb(p, q, h)
b = hue2rgb(p, q, h - 1/3)
}
return {
r: Math.round(r * 255),
g: Math.round(g * 255),
b: Math.round(b * 255)
}
}
// RGB转HEX
function rgbToHex(r, g, b) {
return '#' + [r, g, b].map(x => {
const hex = x.toString(16)
return hex.length === 1 ? '0' + hex : hex
}).join('')
}
// HEX转HSL
export function hexToHsl(hex) {
const rgb = hexToRgb(hex)
return rgbToHsl(rgb.r, rgb.g, rgb.b)
}
// HSL转HEX
export function hslToHex(h, s, l) {
const rgb = hslToRgb(h, s, l)
return rgbToHex(rgb.r, rgb.g, rgb.b)
}
// 暗色模式色彩转换
export function convertToDarkMode(hex) {
const hsl = hexToHsl(hex)
// 调整策略
let { h, s, l } = hsl
// 如果是浅色(亮度>50),则反转亮度
if (l > 50) {
l = 100 - l
// 降低饱和度,避免过于鲜艳
s = Math.max(s - 20, 0)
} else {
// 如果是深色,则提升亮度
l = Math.min(l + 30, 90)
}
// 特殊色相处理
// 蓝色系(200-240)需要特殊处理,避免太亮
if (h >= 200 && h <= 240) {
l = Math.min(l, 70)
}
// 红色系(0-20, 340-360)在暗色模式下降低饱和度
if ((h >= 0 && h <= 20) || (h >= 340 && h <= 360)) {
s = Math.max(s - 10, 40)
}
return hslToHex(h, s, l)
}
// 生成色彩梯度
export function generateColorPalette(baseColor) {
const hsl = hexToHsl(baseColor)
const palette = {}
// 生成10个梯度色
for (let i = 1; i <= 10; i++) {
let l = hsl.l
let s = hsl.s
if (i < 5) {
// 更浅的颜色
l = Math.min(95, hsl.l + (5 - i) * 15)
s = Math.max(10, hsl.s - (5 - i) * 10)
} else if (i === 5) {
// 基础色
l = hsl.l
s = hsl.s
} else {
// 更深的颜色
l = Math.max(10, hsl.l - (i - 5) * 10)
s = Math.min(100, hsl.s + (i - 5) * 5)
}
palette[i] = hslToHex(hsl.h, s, l)
}
return palette
}
// 计算hover颜色
export function getHoverColor(hex) {
const hsl = hexToHsl(hex)
return hslToHex(hsl.h, hsl.s, Math.min(hsl.l + 10, 95))
}
// 计算active颜色
export function getActiveColor(hex) {
const hsl = hexToHsl(hex)
return hslToHex(hsl.h, hsl.s, Math.max(hsl.l - 10, 5))
}
// 计算disabled颜色
export function getDisabledColor(hex) {
const hsl = hexToHsl(hex)
return hslToHex(hsl.h, 30, 70)
}
// 对比度计算
function getLuminance(r, g, b) {
const [rs, gs, bs] = [r, g, b].map(c => {
c /= 255
return c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4)
})
return 0.2126 * rs + 0.7152 * gs + 0.0722 * bs
}
export function getContrast(hex1, hex2) {
const rgb1 = hexToRgb(hex1)
const rgb2 = hexToRgb(hex2)
const lum1 = getLuminance(rgb1.r, rgb1.g, rgb1.b)
const lum2 = getLuminance(rgb2.r, rgb2.g, rgb2.b)
const lighter = Math.max(lum1, lum2)
const darker = Math.min(lum1, lum2)
return (lighter + 0.05) / (darker + 0.05)
}
// 确保对比度达标
export function ensureContrast(foreground, background, minContrast = 4.5) {
let adjusted = foreground
let hsl = hexToHsl(adjusted)
while (getContrast(adjusted, background) < minContrast) {
// 调整亮度
if (hsl.l < 50) {
hsl.l = Math.min(hsl.l + 5, 95)
} else {
hsl.l = Math.max(hsl.l - 5, 5)
}
adjusted = hslToHex(hsl.h, hsl.s, hsl.l)
// 防止死循环
if (hsl.l <= 5 || hsl.l >= 95) break
}
return adjusted
}
使用示例:
// stores/theme.js
import { defineStore } from 'pinia'
import {
convertToDarkMode,
generateColorPalette,
getHoverColor,
getActiveColor,
ensureContrast
} from '@/utils/colorConverter'
export const useThemeStore = defineStore('theme', {
state: () => ({
darkMode: false,
currentThemeId: 'default',
themes: {},
customTheme: null
}),
actions: {
// 根据基础色生成完整主题
generateThemeFromColor(baseColor) {
const palette = generateColorPalette(baseColor)
const lightTheme = {
// 主色系
'color-primary': palette[5],
'color-primary-hover': getHoverColor(palette[5]),
'color-primary-active': getActiveColor(palette[5]),
'color-primary-light': palette[3],
'color-primary-dark': palette[7],
// 背景色
'color-bg': '#ffffff',
'color-bg-secondary': '#f5f7fa',
'color-bg-tertiary': '#ebeef5',
// 文字色
'color-text': '#303133',
'color-text-secondary': '#606266',
'color-text-tertiary': '#909399',
// 边框色
'color-border': '#dcdfe6',
'color-border-light': '#e4e7ed',
// 阴影
'shadow-base': '0 2px 12px 0 rgba(0, 0, 0, 0.1)',
'shadow-light': '0 2px 4px 0 rgba(0, 0, 0, 0.12)'
}
// 生成暗色主题
const darkTheme = {
'color-primary': convertToDarkMode(palette[5]),
'color-primary-hover': convertToDarkMode(getHoverColor(palette[5])),
'color-primary-active': convertToDarkMode(getActiveColor(palette[5])),
'color-bg': '#1a1a1a',
'color-bg-secondary': '#252525',
'color-bg-tertiary': '#2f2f2f',
'color-text': ensureContrast('#ffffff', '#1a1a1a'),
'color-text-secondary': 'rgba(255, 255, 255, 0.7)',
'color-text-tertiary': 'rgba(255, 255, 255, 0.5)',
'color-border': '#3a3a3a',
'color-border-light': '#2f2f2f',
'shadow-base': '0 2px 12px 0 rgba(0, 0, 0, 0.5)',
'shadow-light': '0 2px 4px 0 rgba(0, 0, 0, 0.3)'
}
return {
light: lightTheme,
dark: darkTheme
}
}
}
})
亮点一:可视化主题编辑器
设计思路: 让用户能够通过可视化界面自定义主题,实时预览效果,无需编写代码。
核心实现:
<!-- views/ThemeEditor.vue -->
<script setup>
import { ref, reactive, computed, watch } from 'vue'
import { useThemeStore } from '@/stores/theme'
import {
generateColorPalette,
hexToHsl,
hslToHex,
getContrast
} from '@/utils/colorConverter'
const themeStore = useThemeStore()
// 主题配置
const config = reactive({
name: '我的自定义主题',
baseColor: '#409eff',
// 尺寸
borderRadius: 4,
fontSize: 14,
spacing: 8,
// 字体
fontFamily: 'PingFang SC, Microsoft YaHei',
// 动画
transitionDuration: 300,
// 高级选项
advanced: {
successColor: '#67c23a',
warningColor: '#e6a23c',
errorColor: '#f56c6c',
infoColor: '#909399'
}
})
// 实时生成的色板
const colorPalette = computed(() => {
return generateColorPalette(config.baseColor)
})
// 预览模式
const previewMode = ref('light')
// 实时预览变量
const previewVariables = computed(() => {
const base = {
'--color-primary': colorPalette.value[5],
'--border-radius-base': config.borderRadius + 'px',
'--font-size-base': config.fontSize + 'px',
'--spacing-base': config.spacing + 'px',
'--font-family': config.fontFamily,
'--transition-duration': config.transitionDuration + 'ms'
}
// 根据预览模式应用不同的背景色
if (previewMode.value === 'dark') {
base['--color-bg'] = '#1a1a1a'
base['--color-text'] = '#ffffff'
} else {
base['--color-bg'] = '#ffffff'
base['--color-text'] = '#303133'
}
return base
})
// 应用预览变量
watch(previewVariables, (vars) => {
const preview = document.querySelector('.theme-preview')
if (preview) {
Object.entries(vars).forEach(([key, value]) => {
preview.style.setProperty(key, value)
})
}
}, { immediate: true })
// 对比度检查
const contrastCheck = computed(() => {
const fgColor = previewMode.value === 'dark' ? '#ffffff' : '#303133'
const bgColor = previewMode.value === 'dark' ? '#1a1a1a' : '#ffffff'
const contrast = getContrast(fgColor, bgColor)
return {
value: contrast.toFixed(2),
level: contrast >= 7 ? 'AAA' : contrast >= 4.5 ? 'AA' : '不合格',
pass: contrast >= 4.5
}
})
// 导出主题
function exportTheme() {
const theme = {
name: config.name,
version: '1.0.0',
variables: previewVariables.value,
config: { ...config }
}
const blob = new Blob([JSON.stringify(theme, null, 2)], {
type: 'application/json'
})
const url = URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = `theme-${Date.now()}.json`
link.click()
URL.revokeObjectURL(url)
}
// 导入主题
function importTheme(event) {
const file = event.target.files[0]
if (!file) return
const reader = new FileReader()
reader.onload = (e) => {
try {
const theme = JSON.parse(e.target.result)
Object.assign(config, theme.config)
} catch (error) {
console.error('导入失败:', error)
}
}
reader.readAsText(file)
}
// 保存主题
async function saveTheme() {
try {
const theme = {
name: config.name,
variables: previewVariables.value,
config: { ...config }
}
await themeStore.saveCustomTheme(theme)
// 提示成功
} catch (error) {
console.error('保存失败:', error)
}
}
</script>
<template>
<div class="theme-editor">
<div class="editor-sidebar">
<div class="section">
<h3>基础设置</h3>
<div class="form-item">
<label>主题名称</label>
<el-input v-model="config.name" placeholder="输入主题名称" />
</div>
<div class="form-item">
<label>主题色</label>
<div class="color-picker-wrapper">
<el-color-picker v-model="config.baseColor" />
<el-input v-model="config.baseColor" placeholder="#409eff" />
</div>
</div>
<div class="form-item">
<label>色板预览</label>
<div class="color-palette">
<div
v-for="(color, index) in colorPalette"
:key="index"
class="color-item"
:style="{ backgroundColor: color }"
:title="color"
/>
</div>
</div>
</div>
<div class="section">
<h3>尺寸设置</h3>
<div class="form-item">
<label>圆角大小: {{ config.borderRadius }}px</label>
<el-slider v-model="config.borderRadius" :min="0" :max="20" />
</div>
<div class="form-item">
<label>字体大小: {{ config.fontSize }}px</label>
<el-slider v-model="config.fontSize" :min="12" :max="18" />
</div>
<div class="form-item">
<label>间距大小: {{ config.spacing }}px</label>
<el-slider v-model="config.spacing" :min="4" :max="16" />
</div>
</div>
<div class="section">
<h3>高级设置</h3>
<div class="form-item">
<label>字体家族</label>
<el-input v-model="config.fontFamily" />
</div>
<div class="form-item">
<label>动画时长: {{ config.transitionDuration }}ms</label>
<el-slider v-model="config.transitionDuration" :min="0" :max="1000" :step="50" />
</div>
<div class="color-group">
<div class="form-item">
<label>成功色</label>
<el-color-picker v-model="config.advanced.successColor" />
</div>
<div class="form-item">
<label>警告色</label>
<el-color-picker v-model="config.advanced.warningColor" />
</div>
<div class="form-item">
<label>错误色</label>
<el-color-picker v-model="config.advanced.errorColor" />
</div>
</div>
</div>
<div class="actions">
<el-button type="primary" @click="saveTheme">保存主题</el-button>
<el-button @click="exportTheme">导出配置</el-button>
<el-upload
:show-file-list="false"
:auto-upload="false"
accept=".json"
@change="importTheme"
>
<el-button>导入配置</el-button>
</el-upload>
</div>
</div>
<div class="editor-preview">
<div class="preview-header">
<div class="preview-title">实时预览</div>
<el-radio-group v-model="previewMode">
<el-radio-button label="light">亮色</el-radio-button>
<el-radio-button label="dark">暗色</el-radio-button>
</el-radio-group>
<div class="contrast-badge" :class="contrastCheck.level">
对比度: {{ contrastCheck.value }} ({{ contrastCheck.level }})
</div>
</div>
<div class="theme-preview" :class="previewMode">
<!-- 预览组件 -->
<div class="preview-section">
<h4>按钮</h4>
<div class="button-group">
<button class="btn btn-primary">主要按钮</button>
<button class="btn btn-default">默认按钮</button>
<button class="btn btn-text">文字按钮</button>
</div>
</div>
<div class="preview-section">
<h4>输入框</h4>
<div class="input-wrapper">
<input type="text" placeholder="请输入内容" class="input" />
</div>
</div>
<div class="preview-section">
<h4>卡片</h4>
<div class="card">
<div class="card-header">卡片标题</div>
<div class="card-body">
<p>这是卡片的内容区域,用于展示主题效果。</p>
<p>包含文字、边框、阴影等效果。</p>
</div>
</div>
</div>
<div class="preview-section">
<h4>标签</h4>
<div class="tag-group">
<span class="tag tag-success">成功</span>
<span class="tag tag-warning">警告</span>
<span class="tag tag-error">错误</span>
<span class="tag tag-info">信息</span>
</div>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.theme-editor {
display: flex;
height: 100vh;
background: #f5f7fa;
}
.editor-sidebar {
width: 320px;
background: #fff;
padding: 20px;
overflow-y: auto;
border-right: 1px solid #dcdfe6;
}
.section {
margin-bottom: 32px;
}
.section h3 {
font-size: 16px;
font-weight: 600;
margin-bottom: 16px;
color: #303133;
}
.form-item {
margin-bottom: 16px;
}
.form-item label {
display: block;
font-size: 14px;
color: #606266;
margin-bottom: 8px;
}
.color-picker-wrapper {
display: flex;
gap: 8px;
}
.color-palette {
display: flex;
gap: 4px;
padding: 8px;
background: #f5f7fa;
border-radius: 4px;
}
.color-item {
width: 24px;
height: 24px;
border-radius: 4px;
cursor: pointer;
transition: transform 0.2s;
}
.color-item:hover {
transform: scale(1.2);
}
.color-group {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 12px;
}
.actions {
display: flex;
flex-direction: column;
gap: 8px;
padding-top: 16px;
border-top: 1px solid #dcdfe6;
}
.editor-preview {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
}
.preview-header {
display: flex;
align-items: center;
gap: 16px;
padding: 16px 24px;
background: #fff;
border-bottom: 1px solid #dcdfe6;
}
.preview-title {
font-size: 16px;
font-weight: 600;
color: #303133;
}
.contrast-badge {
margin-left: auto;
padding: 4px 12px;
border-radius: 12px;
font-size: 12px;
font-weight: 500;
}
.contrast-badge.AAA {
background: #f0f9ff;
color: #1890ff;
}
.contrast-badge.AA {
background: #fff7e6;
color: #fa8c16;
}
.contrast-badge.不合格 {
background: #fff1f0;
color: #f5222d;
}
.theme-preview {
flex: 1;
padding: 24px;
overflow-y: auto;
background: var(--color-bg);
color: var(--color-text);
transition: background-color 0.3s, color 0.3s;
}
.preview-section {
margin-bottom: 32px;
}
.preview-section h4 {
font-size: 14px;
font-weight: 600;
margin-bottom: 12px;
color: var(--color-text);
}
/* 预览组件样式 */
.btn {
padding: 8px 16px;
border: none;
border-radius: var(--border-radius-base);
font-size: var(--font-size-base);
cursor: pointer;
transition: all var(--transition-duration);
}
.btn-primary {
background: var(--color-primary);
color: #fff;
}
.btn-primary:hover {
opacity: 0.8;
}
.btn-default {
background: var(--color-bg-secondary);
color: var(--color-text);
}
.btn-text {
background: transparent;
color: var(--color-primary);
}
.input {
width: 100%;
padding: 8px 12px;
border: 1px solid var(--color-border);
border-radius: var(--border-radius-base);
font-size: var(--font-size-base);
background: var(--color-bg);
color: var(--color-text);
transition: border-color var(--transition-duration);
}
.input:focus {
outline: none;
border-color: var(--color-primary);
}
.card {
background: var(--color-bg);
border: 1px solid var(--color-border);
border-radius: var(--border-radius-base);
overflow: hidden;
}
.card-header {
padding: 12px 16px;
border-bottom: 1px solid var(--color-border);
font-weight: 600;
}
.card-body {
padding: 16px;
}
.tag {
display: inline-block;
padding: 4px 8px;
border-radius: var(--border-radius-base);
font-size: 12px;
margin-right: 8px;
}
.tag-success { background: #f0f9ff; color: #67c23a; }
.tag-warning { background: #fff7e6; color: #e6a23c; }
.tag-error { background: #fff1f0; color: #f56c6c; }
.tag-info { background: #f5f7fa; color: #909399; }
</style>
面试常见追问
Q: 主题切换为什么不用预处理器变量? A: 预处理器变量是编译时确定的,没法运行时动态修改。CSS变量可以通过JS实时改值,用户点击切换主题时能立即生效。我们项目原来用的Sass变量,要切换主题得重新编译打包,根本没法让用户自定义。改成CSS变量后,用setProperty就能改颜色,配合Pinia存储主题配置,切换主题100毫秒就能完成。
Q: 暗色模式如果遇到图片太亮怎么办? A: 这个我遇到过。彩色图片在暗色背景上确实很刺眼,我的做法是给图片加filter。普通图片用filter: brightness(0.8) contrast(0.9)降低亮度和对比度。但要注意不能所有图片都加,Logo、商品图这些带品牌色的不能改,不然就失真了。我给Image组件加了个darkFilter的prop,需要过滤的才加,默认不过滤。还有个技巧是用mix-blend-mode: luminosity,能让图片适应背景亮度。
Q: 主题配置文件是怎么设计的? A: 我把主题配置分成三层。基础层是原子变量,包括颜色、字号、间距这些最底层的值。中间层是语义化变量,比如主色、成功色、警告色,它们引用基础层的值。上层是组件变量,具体到按钮、输入框的样式,引用语义化变量。这样做的好处是修改一个基础值,所有引用它的地方都会更新。配置文件就是个JSON,用户导出后可以分享给别人导入,实现主题复用。
Q: 切换主题时怎么保证不闪烁? A: 主要做了三件事。一是切换前加个Loading遮罩,避免用户看到变化过程。二是用requestAnimationFrame分批更新CSS变量,每一帧更新10个变量,避免一次性更新50个变量导致卡顿。三是给关键元素加transition过渡动画,颜色变化有个渐变效果,不会突然跳变。还有个细节是切换时给body加个theme-switching类,这个类里定义了过渡样式,切换完成再移除,避免影响正常的交互动画。