返回笔记首页

5.3 主题切换系统 - 动态主题与暗黑模式

主题配置

简历描述模板

版本一:注重用户体验

plain
设计并实现企业级主题切换系统,支持亮色/暗色模式及10+套自定义主题无缝切换。采用
CSS变量+动态样式注入方案,主题切换响应时间<100ms,无闪烁。基于HSL色彩空间实现
主题色智能计算,自动生成hover、active等衍生色值,保证视觉一致性。开发可视化配
置界面,用户可自定义品牌色、圆角、间距等30+参数并实时预览,支持导出为JSON配置
文件。针对暗色模式优化了对比度和阴影算法,通过WCAG 2.1 AAA级无障碍标准。

版本二:强调技术实现

plain
主导主题系统架构设计,构建基于CSS Custom Properties的动态主题引擎。通过计算属
性和watch实现主题参数的响应式更新,配合Pinia状态管理持久化用户偏好。针对暗色模
式实现了色彩反转算法和智能对比度调整,处理了图片、Icon、阴影等20+场景的适配问
题。开发主题编辑器,支持实时预览、导入导出、版本管理,非技术人员可独立创建主题
方案。集成系统主题检测API,自动匹配用户操作系统的明暗偏好。

版本三:突出工程化能力

plain
负责前端主题系统的完整落地,支持多主题动态切换和品牌定制化。设计了模块化的主题
配置结构,将色彩、字体、间距、圆角等抽象为独立变量层,便于维护和扩展。实现了主
题预编译工具,将SCSS变量转换为CSS变量,兼容老项目的主题方案。针对低端设备优化
了切换性能,通过RAF批量更新样式,避免重排和重绘。搭建主题市场,用户可浏览、试
用、下载社区分享的主题包,并支持一键应用到自己的工作空间。

SOP 标准回答

Q1: 你们的主题切换方案是怎么实现的?

标准回答

plain
我们的主题方案主要用的是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: 暗色模式的适配有什么难点?

标准回答

plain
暗色模式确实不是简单地把背景改黑、文字改白就行了,主要有几个难点:

第一是对比度问题。暗色背景上纯白文字会太刺眼,我们参考了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: 主题色的衍生色是怎么计算的?

标准回答

plain
这个是我研究了一段时间才搞明白的。一开始我是手动配置每个状态的颜色,但每次改主
题色都要调一大堆值,特别麻烦。后来参考了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。

解决思路

  1. 使用RAF批量更新,避免连续触发重排
  2. 只更新变化的变量,跳过值相同的
  3. 用transform代替会触发重排的属性
  4. 关键帧动画配合will-change优化

技术实现

javascript
// 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
  }
}

全局样式优化

css
/* 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);
}

难点二:暗色模式的色彩反转算法

问题背景: 暗色模式不是简单的黑白反转,需要智能调整每个颜色的亮度、饱和度,保持视觉舒适度和对比度。

解决方案

javascript
// 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
}

使用示例

javascript
// 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
      }
    }
  }
})

亮点一:可视化主题编辑器

设计思路: 让用户能够通过可视化界面自定义主题,实时预览效果,无需编写代码。

核心实现

vue
<!-- 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类,这个类里定义了过渡样式,切换完成再移除,避免影响正常的交互动画。