ThemeSwitch.vue是主题切换组件,提供用户切换不同主题模式的功能,支持亮色主题、暗色主题、自动主题等多种主题模式。
文件位置: src/components/common/ThemeSwitch.vue
- 支持多种主题模式切换
- 实时预览主题效果
- 主题状态持久化保存
- 系统主题自动跟随
- 自定义主题配置
<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><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>// 处理命令
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)
}
}))
}// 打开自定义主题对话框
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('自定义主题保存成功')
}// 监听主题变化并保存
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()
}).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);
}.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;
}
}
}
}
}.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;
}
}
}
}
}
}.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;
}<template>
<div class="header-tools">
<ThemeSwitch />
</div>
</template>
<script setup>
import ThemeSwitch from '@/components/common/ThemeSwitch.vue'
</script><template>
<ThemeSwitch
:show-text="false"
:show-arrow="false"
size="small"
/>
</template><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>- 性能: 主题切换时避免频繁的DOM操作
- 兼容性: 确保CSS变量在不同浏览器中的支持
- 用户体验: 提供平滑的主题切换动画
- 可访问性: 确保在不同主题下的对比度符合标准
- 状态管理: 正确保存和恢复主题状态
最后更新时间:2025-09-19