Skip to content

Latest commit

 

History

History
1021 lines (869 loc) · 23.2 KB

File metadata and controls

1021 lines (869 loc) · 23.2 KB

ThemeSwitch.vue 主题切换组件文档

概述

ThemeSwitch.vue是主题切换组件,提供用户切换不同主题模式的功能,支持亮色主题、暗色主题、自动主题等多种主题模式。

文件位置: src/components/common/ThemeSwitch.vue

组件功能

1. 主题切换功能

  • 支持多种主题模式切换
  • 实时预览主题效果
  • 主题状态持久化保存
  • 系统主题自动跟随
  • 自定义主题配置

2. 组件结构

<template>
  <div class="theme-switch">
    <el-dropdown 
      @command="handleCommand" 
      trigger="click"
      placement="bottom-end"
    >
      <div class="theme-trigger">
        <el-icon class="theme-icon">
          <component :is="currentThemeIcon" />
        </el-icon>
        <span class="theme-text" v-if="showText">{{ currentThemeText }}</span>
        <el-icon class="dropdown-icon" v-if="showArrow">
          <ArrowDown />
        </el-icon>
      </div>
      
      <template #dropdown>
        <el-dropdown-menu>
          <el-dropdown-item 
            v-for="theme in themeOptions" 
            :key="theme.value"
            :command="theme.value"
            :class="{ 'is-active': currentTheme === theme.value }"
          >
            <div class="theme-option">
              <div class="theme-preview">
                <div :class="['preview-container', `preview-${theme.value}`]">
                  <div class="preview-header"></div>
                  <div class="preview-body">
                    <div class="preview-sidebar"></div>
                    <div class="preview-main">
                      <div class="preview-card"></div>
                      <div class="preview-card"></div>
                    </div>
                  </div>
                </div>
              </div>
              <div class="theme-info">
                <div class="theme-name">
                  <el-icon class="theme-option-icon">
                    <component :is="theme.icon" />
                  </el-icon>
                  <span>{{ theme.label }}</span>
                </div>
                <small class="theme-desc">{{ theme.description }}</small>
              </div>
              <el-icon v-if="currentTheme === theme.value" class="check-icon">
                <Check />
              </el-icon>
            </div>
          </el-dropdown-item>
          
          <el-divider style="margin: 8px 0;" />
          
          <el-dropdown-item command="custom">
            <div class="theme-option">
              <el-icon class="theme-option-icon">
                <Setting />
              </el-icon>
              <span>自定义主题</span>
            </div>
          </el-dropdown-item>
        </el-dropdown-menu>
      </template>
    </el-dropdown>
    
    <!-- 自定义主题对话框 -->
    <el-dialog
      v-model="customDialogVisible"
      title="自定义主题"
      width="600px"
      :close-on-click-modal="false"
    >
      <div class="custom-theme-panel">
        <div class="color-section">
          <h4>主色调</h4>
          <div class="color-picker-group">
            <div class="color-item">
              <label>主要颜色</label>
              <el-color-picker 
                v-model="customTheme.primary" 
                @change="handleColorChange"
              />
            </div>
            <div class="color-item">
              <label>成功颜色</label>
              <el-color-picker 
                v-model="customTheme.success" 
                @change="handleColorChange"
              />
            </div>
            <div class="color-item">
              <label>警告颜色</label>
              <el-color-picker 
                v-model="customTheme.warning" 
                @change="handleColorChange"
              />
            </div>
            <div class="color-item">
              <label>危险颜色</label>
              <el-color-picker 
                v-model="customTheme.danger" 
                @change="handleColorChange"
              />
            </div>
          </div>
        </div>
        
        <div class="preset-section">
          <h4>预设主题</h4>
          <div class="preset-colors">
            <div 
              v-for="preset in presetThemes" 
              :key="preset.name"
              class="preset-item"
              @click="applyPreset(preset)"
            >
              <div class="preset-preview" :style="{ backgroundColor: preset.primary }"></div>
              <span>{{ preset.name }}</span>
            </div>
          </div>
        </div>
        
        <div class="preview-section">
          <h4>预览效果</h4>
          <div class="theme-preview-large">
            <div class="preview-header-large" :style="{ backgroundColor: customTheme.primary }">
              <div class="preview-logo"></div>
              <div class="preview-nav">
                <div class="nav-item active"></div>
                <div class="nav-item"></div>
                <div class="nav-item"></div>
              </div>
            </div>
            <div class="preview-content-large">
              <div class="preview-sidebar-large">
                <div class="sidebar-item" :style="{ backgroundColor: customTheme.primary + '20' }"></div>
                <div class="sidebar-item"></div>
                <div class="sidebar-item"></div>
              </div>
              <div class="preview-main-large">
                <div class="preview-card-large">
                  <div class="card-header" :style="{ backgroundColor: customTheme.primary + '10' }"></div>
                  <div class="card-body">
                    <div class="card-button" :style="{ backgroundColor: customTheme.primary }"></div>
                    <div class="card-button" :style="{ backgroundColor: customTheme.success }"></div>
                  </div>
                </div>
              </div>
            </div>
          </div>
        </div>
      </div>
      
      <template #footer>
        <el-button @click="customDialogVisible = false">取消</el-button>
        <el-button @click="resetCustomTheme">重置</el-button>
        <el-button type="primary" @click="saveCustomTheme">保存并应用</el-button>
      </template>
    </el-dialog>
  </div>
</template>

3. 脚本逻辑

<script setup>
import { ref, computed, onMounted, watch } from 'vue'
import { useThemeStore } from '@/stores/theme'
import { ElMessage } from 'element-plus'
import {
  ArrowDown,
  Check,
  Sunny,
  Moon,
  Monitor,
  Setting
} from '@element-plus/icons-vue'

// Props
const props = defineProps({
  showText: {
    type: Boolean,
    default: true
  },
  showArrow: {
    type: Boolean,
    default: true
  },
  size: {
    type: String,
    default: 'default',
    validator: (value) => ['small', 'default', 'large'].includes(value)
  }
})

// 状态管理
const themeStore = useThemeStore()

// 响应式数据
const customDialogVisible = ref(false)

// 主题选项配置
const themeOptions = ref([
  {
    value: 'light',
    label: '亮色主题',
    description: '经典的亮色界面',
    icon: 'Sunny'
  },
  {
    value: 'dark',
    label: '暗色主题',
    description: '护眼的暗色界面',
    icon: 'Moon'
  },
  {
    value: 'auto',
    label: '跟随系统',
    description: '自动跟随系统主题',
    icon: 'Monitor'
  }
])

// 自定义主题数据
const customTheme = ref({
  primary: '#409EFF',
  success: '#67C23A',
  warning: '#E6A23C',
  danger: '#F56C6C'
})

// 预设主题
const presetThemes = ref([
  { name: '默认蓝', primary: '#409EFF' },
  { name: '科技紫', primary: '#722ED1' },
  { name: '商务绿', primary: '#52C41A' },
  { name: '活力橙', primary: '#FA8C16' },
  { name: '浪漫粉', primary: '#EB2F96' },
  { name: '深海蓝', primary: '#1890FF' },
  { name: '森林绿', primary: '#389E0D' },
  { name: '夕阳红', primary: '#CF1322' }
])

// 当前主题
const currentTheme = computed(() => themeStore.theme)

// 当前主题信息
const currentThemeInfo = computed(() => {
  return themeOptions.value.find(theme => theme.value === currentTheme.value) || themeOptions.value[0]
})

// 当前主题图标
const currentThemeIcon = computed(() => currentThemeInfo.value.icon)

// 当前主题文本
const currentThemeText = computed(() => currentThemeInfo.value.label)
</script>

主题切换功能

1. 主题模式切换

// 处理命令
const handleCommand = (themeMode) => {
  if (themeMode === 'custom') {
    openCustomThemeDialog()
    return
  }
  
  if (themeMode === currentTheme.value) {
    return
  }
  
  setThemeMode(themeMode)
}

// 设置主题模式
const setThemeMode = (mode) => {
  // 更新主题状态
  themeStore.setTheme(mode)
  
  // 应用主题变化
  applyThemeChanges(mode)
  
  // 显示切换提示
  showThemeChangeNotification(mode)
  
  // 触发主题变化事件
  emitThemeChange(mode)
}

// 应用主题变化
const applyThemeChanges = (mode) => {
  const root = document.documentElement
  
  // 移除旧的主题类
  root.classList.remove('theme-light', 'theme-dark', 'theme-auto')
  
  // 添加新的主题类
  root.classList.add(`theme-${mode}`)
  
  // 设置主题属性
  root.setAttribute('data-theme', mode)
  
  // 处理自动主题
  if (mode === 'auto') {
    handleAutoTheme()
  } else {
    applyThemeColors(mode)
  }
}

// 处理自动主题
const handleAutoTheme = () => {
  const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)')
  const isDark = mediaQuery.matches
  
  applyThemeColors(isDark ? 'dark' : 'light')
  
  // 监听系统主题变化
  mediaQuery.addEventListener('change', (e) => {
    if (currentTheme.value === 'auto') {
      applyThemeColors(e.matches ? 'dark' : 'light')
    }
  })
}

// 应用主题颜色
const applyThemeColors = (actualTheme) => {
  const root = document.documentElement
  
  if (actualTheme === 'dark') {
    // 暗色主题颜色
    root.style.setProperty('--el-bg-color', '#1a1a1a')
    root.style.setProperty('--el-bg-color-page', '#0a0a0a')
    root.style.setProperty('--el-text-color-primary', '#E5EAF3')
    root.style.setProperty('--el-text-color-regular', '#CFD3DC')
    root.style.setProperty('--el-border-color', '#414243')
    root.style.setProperty('--el-fill-color', '#262727')
  } else {
    // 亮色主题颜色
    root.style.setProperty('--el-bg-color', '#ffffff')
    root.style.setProperty('--el-bg-color-page', '#f2f3f5')
    root.style.setProperty('--el-text-color-primary', '#303133')
    root.style.setProperty('--el-text-color-regular', '#606266')
    root.style.setProperty('--el-border-color', '#dcdfe6')
    root.style.setProperty('--el-fill-color', '#f0f2f5')
  }
}

// 显示主题切换通知
const showThemeChangeNotification = (mode) => {
  const themeInfo = themeOptions.value.find(t => t.value === mode)
  if (!themeInfo) return
  
  ElMessage({
    message: `已切换到${themeInfo.label}`,
    type: 'success',
    duration: 2000,
    showClose: false
  })
}

// 触发主题变化事件
const emitThemeChange = (mode) => {
  window.dispatchEvent(new CustomEvent('theme-change', {
    detail: { 
      mode, 
      themeInfo: themeOptions.value.find(t => t.value === mode) 
    }
  }))
}

2. 自定义主题功能

// 打开自定义主题对话框
const openCustomThemeDialog = () => {
  // 加载当前自定义主题配置
  loadCustomThemeConfig()
  customDialogVisible.value = true
}

// 加载自定义主题配置
const loadCustomThemeConfig = () => {
  const savedTheme = themeStore.customTheme
  if (savedTheme) {
    Object.assign(customTheme.value, savedTheme)
  }
}

// 处理颜色变化
const handleColorChange = () => {
  // 实时预览颜色变化
  applyCustomThemePreview()
}

// 应用自定义主题预览
const applyCustomThemePreview = () => {
  const root = document.documentElement
  
  // 应用自定义颜色
  root.style.setProperty('--el-color-primary', customTheme.value.primary)
  root.style.setProperty('--el-color-success', customTheme.value.success)
  root.style.setProperty('--el-color-warning', customTheme.value.warning)
  root.style.setProperty('--el-color-danger', customTheme.value.danger)
  
  // 生成相关颜色变量
  generateColorVariables(customTheme.value.primary, 'primary')
  generateColorVariables(customTheme.value.success, 'success')
  generateColorVariables(customTheme.value.warning, 'warning')
  generateColorVariables(customTheme.value.danger, 'danger')
}

// 生成颜色变量
const generateColorVariables = (color, type) => {
  const root = document.documentElement
  
  // 生成不同透明度的颜色
  for (let i = 1; i <= 9; i++) {
    const alpha = i / 10
    const rgbaColor = hexToRgba(color, alpha)
    root.style.setProperty(`--el-color-${type}-light-${i}`, rgbaColor)
  }
  
  // 生成深色变体
  const darkColor = darkenColor(color, 0.2)
  root.style.setProperty(`--el-color-${type}-dark-2`, darkColor)
}

// 十六进制转RGBA
const hexToRgba = (hex, alpha) => {
  const r = parseInt(hex.slice(1, 3), 16)
  const g = parseInt(hex.slice(3, 5), 16)
  const b = parseInt(hex.slice(5, 7), 16)
  return `rgba(${r}, ${g}, ${b}, ${alpha})`
}

// 颜色加深
const darkenColor = (color, amount) => {
  const num = parseInt(color.replace('#', ''), 16)
  const amt = Math.round(2.55 * amount * 100)
  const R = (num >> 16) - amt
  const G = (num >> 8 & 0x00FF) - amt
  const B = (num & 0x0000FF) - amt
  
  return '#' + (0x1000000 + (R < 255 ? R < 1 ? 0 : R : 255) * 0x10000 +
    (G < 255 ? G < 1 ? 0 : G : 255) * 0x100 +
    (B < 255 ? B < 1 ? 0 : B : 255)).toString(16).slice(1)
}

// 应用预设主题
const applyPreset = (preset) => {
  customTheme.value.primary = preset.primary
  handleColorChange()
}

// 重置自定义主题
const resetCustomTheme = () => {
  Object.assign(customTheme.value, {
    primary: '#409EFF',
    success: '#67C23A',
    warning: '#E6A23C',
    danger: '#F56C6C'
  })
  handleColorChange()
}

// 保存自定义主题
const saveCustomTheme = () => {
  // 保存到store
  themeStore.setCustomTheme(customTheme.value)
  
  // 应用主题
  applyCustomThemePreview()
  
  // 关闭对话框
  customDialogVisible.value = false
  
  ElMessage.success('自定义主题保存成功')
}

3. 主题持久化

// 监听主题变化并保存
watch(
  () => themeStore.theme,
  (newTheme) => {
    // 保存到localStorage
    localStorage.setItem('theme', newTheme)
    
    // 应用主题
    applyThemeChanges(newTheme)
  },
  { immediate: true }
)

// 初始化主题
const initTheme = () => {
  // 从localStorage读取主题
  const savedTheme = localStorage.getItem('theme')
  if (savedTheme && themeOptions.value.some(t => t.value === savedTheme)) {
    themeStore.setTheme(savedTheme)
  } else {
    // 默认跟随系统
    themeStore.setTheme('auto')
  }
  
  // 加载自定义主题
  const savedCustomTheme = localStorage.getItem('customTheme')
  if (savedCustomTheme) {
    try {
      const customThemeData = JSON.parse(savedCustomTheme)
      themeStore.setCustomTheme(customThemeData)
    } catch (error) {
      console.error('加载自定义主题失败:', error)
    }
  }
}

// 组件挂载时初始化
onMounted(() => {
  initTheme()
})

样式定义

1. 组件基础样式

.theme-switch {
  display: inline-block;
}

.theme-trigger {
  display: flex;
  align-items: center;
  padding: 8px 12px;
  border-radius: 6px;
  cursor: pointer;
  transition: all 0.2s ease;
  user-select: none;
  
  &:hover {
    background: var(--el-fill-color-light);
  }
}

.theme-icon {
  font-size: 16px;
  color: var(--el-text-color-regular);
  margin-right: 6px;
}

.theme-text {
  font-size: 13px;
  color: var(--el-text-color-regular);
  margin-right: 4px;
}

.dropdown-icon {
  font-size: 12px;
  color: var(--el-text-color-placeholder);
  transition: transform 0.2s ease;
}

.theme-trigger:hover .dropdown-icon {
  transform: rotate(180deg);
}

2. 主题选项样式

.theme-option {
  display: flex;
  align-items: center;
  width: 260px;
  padding: 12px;
  
  &:hover {
    background: var(--el-fill-color-light);
  }
  
  &.is-active {
    background: var(--el-color-primary-light-9);
    
    .theme-name {
      color: var(--el-color-primary);
      font-weight: 500;
    }
  }
}

.theme-preview {
  margin-right: 12px;
  flex-shrink: 0;
}

.preview-container {
  width: 50px;
  height: 32px;
  border: 1px solid var(--el-border-color);
  border-radius: 4px;
  overflow: hidden;
  position: relative;
}

/* 亮色主题预览 */
.preview-light {
  background: #ffffff;
  
  .preview-header {
    height: 8px;
    background: #f0f2f5;
  }
  
  .preview-body {
    display: flex;
    height: 24px;
    
    .preview-sidebar {
      width: 12px;
      background: #fafafa;
    }
    
    .preview-main {
      flex: 1;
      padding: 2px;
      
      .preview-card {
        height: 4px;
        background: #f5f5f5;
        margin-bottom: 2px;
        border-radius: 1px;
        
        &:last-child {
          margin-bottom: 0;
        }
      }
    }
  }
}

/* 暗色主题预览 */
.preview-dark {
  background: #1a1a1a;
  
  .preview-header {
    height: 8px;
    background: #262727;
  }
  
  .preview-body {
    display: flex;
    height: 24px;
    
    .preview-sidebar {
      width: 12px;
      background: #2d2d2d;
    }
    
    .preview-main {
      flex: 1;
      padding: 2px;
      
      .preview-card {
        height: 4px;
        background: #3a3a3a;
        margin-bottom: 2px;
        border-radius: 1px;
        
        &:last-child {
          margin-bottom: 0;
        }
      }
    }
  }
}

/* 自动主题预览 */
.preview-auto {
  background: linear-gradient(90deg, #ffffff 50%, #1a1a1a 50%);
  
  .preview-header {
    height: 8px;
    background: linear-gradient(90deg, #f0f2f5 50%, #262727 50%);
  }
  
  .preview-body {
    display: flex;
    height: 24px;
    
    .preview-sidebar {
      width: 12px;
      background: linear-gradient(90deg, #fafafa 50%, #2d2d2d 50%);
    }
    
    .preview-main {
      flex: 1;
      padding: 2px;
      
      .preview-card {
        height: 4px;
        background: linear-gradient(90deg, #f5f5f5 50%, #3a3a3a 50%);
        margin-bottom: 2px;
        border-radius: 1px;
        
        &:last-child {
          margin-bottom: 0;
        }
      }
    }
  }
}

3. 自定义主题对话框样式

.custom-theme-panel {
  .color-section {
    margin-bottom: 24px;
    
    h4 {
      margin: 0 0 16px 0;
      color: var(--el-text-color-primary);
      font-size: 16px;
    }
  }
  
  .color-picker-group {
    display: grid;
    grid-template-columns: repeat(2, 1fr);
    gap: 16px;
  }
  
  .color-item {
    display: flex;
    align-items: center;
    justify-content: space-between;
    
    label {
      font-size: 14px;
      color: var(--el-text-color-regular);
    }
  }
  
  .preset-section {
    margin-bottom: 24px;
    
    h4 {
      margin: 0 0 16px 0;
      color: var(--el-text-color-primary);
      font-size: 16px;
    }
  }
  
  .preset-colors {
    display: grid;
    grid-template-columns: repeat(4, 1fr);
    gap: 12px;
  }
  
  .preset-item {
    display: flex;
    flex-direction: column;
    align-items: center;
    padding: 8px;
    border-radius: 6px;
    cursor: pointer;
    transition: all 0.2s ease;
    
    &:hover {
      background: var(--el-fill-color-light);
    }
    
    .preset-preview {
      width: 32px;
      height: 32px;
      border-radius: 50%;
      margin-bottom: 8px;
      border: 2px solid var(--el-border-color);
    }
    
    span {
      font-size: 12px;
      color: var(--el-text-color-regular);
    }
  }
  
  .preview-section {
    h4 {
      margin: 0 0 16px 0;
      color: var(--el-text-color-primary);
      font-size: 16px;
    }
  }
  
  .theme-preview-large {
    border: 1px solid var(--el-border-color);
    border-radius: 8px;
    overflow: hidden;
    background: var(--el-bg-color);
  }
  
  .preview-header-large {
    height: 40px;
    display: flex;
    align-items: center;
    padding: 0 16px;
    
    .preview-logo {
      width: 24px;
      height: 24px;
      background: rgba(255, 255, 255, 0.2);
      border-radius: 4px;
      margin-right: 16px;
    }
    
    .preview-nav {
      display: flex;
      gap: 8px;
      
      .nav-item {
        width: 60px;
        height: 20px;
        background: rgba(255, 255, 255, 0.1);
        border-radius: 4px;
        
        &.active {
          background: rgba(255, 255, 255, 0.3);
        }
      }
    }
  }
  
  .preview-content-large {
    display: flex;
    height: 120px;
    
    .preview-sidebar-large {
      width: 60px;
      background: var(--el-fill-color);
      padding: 8px;
      
      .sidebar-item {
        height: 16px;
        background: var(--el-fill-color-light);
        border-radius: 4px;
        margin-bottom: 8px;
        
        &:last-child {
          margin-bottom: 0;
        }
      }
    }
    
    .preview-main-large {
      flex: 1;
      padding: 16px;
      
      .preview-card-large {
        background: var(--el-bg-color);
        border: 1px solid var(--el-border-color);
        border-radius: 6px;
        overflow: hidden;
        
        .card-header {
          height: 32px;
          padding: 0 12px;
          display: flex;
          align-items: center;
        }
        
        .card-body {
          padding: 12px;
          display: flex;
          gap: 8px;
          
          .card-button {
            width: 60px;
            height: 24px;
            border-radius: 4px;
          }
        }
      }
    }
  }
}

4. 主题信息样式

.theme-info {
  flex: 1;
  min-width: 0;
}

.theme-name {
  display: flex;
  align-items: center;
  font-size: 14px;
  color: var(--el-text-color-primary);
  margin-bottom: 4px;
  
  .theme-option-icon {
    margin-right: 6px;
    font-size: 16px;
  }
}

.theme-desc {
  display: block;
  font-size: 12px;
  color: var(--el-text-color-secondary);
  line-height: 1.4;
}

.check-icon {
  margin-left: 8px;
  font-size: 16px;
  color: var(--el-color-primary);
  flex-shrink: 0;
}

使用示例

1. 基本使用

<template>
  <div class="header-tools">
    <ThemeSwitch />
  </div>
</template>

<script setup>
import ThemeSwitch from '@/components/common/ThemeSwitch.vue'
</script>

2. 自定义配置

<template>
  <ThemeSwitch 
    :show-text="false" 
    :show-arrow="false" 
    size="small" 
  />
</template>

3. 监听主题变化

<script setup>
import { onMounted, onUnmounted } from 'vue'

const handleThemeChange = (event) => {
  const { mode, themeInfo } = event.detail
  console.log(`主题已切换到: ${themeInfo.label}`)
  
  // 执行相关操作
  handleThemeChangeEffect(mode)
}

const handleThemeChangeEffect = (mode) => {
  // 重新渲染图表
  if (window.echarts) {
    window.echarts.getInstanceByDom(chartRef.value)?.resize()
  }
}

onMounted(() => {
  window.addEventListener('theme-change', handleThemeChange)
})

onUnmounted(() => {
  window.removeEventListener('theme-change', handleThemeChange)
})
</script>

注意事项

  1. 性能: 主题切换时避免频繁的DOM操作
  2. 兼容性: 确保CSS变量在不同浏览器中的支持
  3. 用户体验: 提供平滑的主题切换动画
  4. 可访问性: 确保在不同主题下的对比度符合标准
  5. 状态管理: 正确保存和恢复主题状态

相关文档


最后更新时间:2025-09-19