This commit is contained in:
gpu 2026-01-05 00:15:15 +08:00
parent b14c0beb36
commit 9b948a9bd2
8 changed files with 643 additions and 7 deletions

View File

@ -13,6 +13,13 @@
</div>
<div class="header-right">
<!-- 主题切换按钮 -->
<el-tooltip content="主题设置" placement="bottom">
<div class="header-action" @click="themeStore.toggleThemeDrawer">
<el-icon><Brush /></el-icon>
</div>
</el-tooltip>
<el-dropdown trigger="click" @command="handleCommand">
<div class="user-info">
<el-avatar :size="32" :src="userInfo?.avatar || undefined">
@ -36,7 +43,8 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { Fold, Expand, ArrowDown } from '@element-plus/icons-vue'
import { Fold, Expand, ArrowDown, Brush } from '@element-plus/icons-vue'
import { useThemeStore } from '@/store/modules/theme'
import { ElMessageBox } from 'element-plus'
import { useUserStore } from '@/store/modules/user'
@ -49,6 +57,7 @@ defineEmits(['toggle-collapse'])
const route = useRoute()
const router = useRouter()
const userStore = useUserStore()
const themeStore = useThemeStore()
const userInfo = computed(() => userStore.userInfo)
@ -112,6 +121,24 @@ const handleCommand = async (command: string) => {
color: var(--primary-color);
}
.header-action {
display: flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
border-radius: var(--border-radius-base);
cursor: pointer;
color: var(--text-regular);
margin-right: 12px;
transition: all 0.2s;
}
.header-action:hover {
background-color: var(--bg-hover);
color: var(--primary-color);
}
.header-right {
display: flex;
align-items: center;

View File

@ -0,0 +1,318 @@
<template>
<el-drawer
v-model="themeStore.showThemeDrawer"
title="主题设置"
direction="rtl"
size="300px"
:show-close="true"
>
<div class="theme-drawer">
<!-- 预设主题 -->
<div class="theme-section">
<h4 class="section-title">系统主题</h4>
<div class="theme-grid">
<div
v-for="theme in presetThemes"
:key="theme.name"
class="theme-item"
:class="{ active: themeStore.currentTheme === theme.name }"
@click="themeStore.setTheme(theme.name)"
>
<div
class="theme-preview"
:style="{ backgroundColor: theme.primaryColor }"
>
<el-icon v-if="themeStore.currentTheme === theme.name" class="check-icon">
<Check />
</el-icon>
</div>
<span class="theme-label">{{ theme.label }}</span>
</div>
</div>
</div>
<!-- 自定义主题 -->
<div class="theme-section">
<h4 class="section-title">自定义主题</h4>
<div class="custom-theme">
<div class="color-item">
<span class="color-label">主色调</span>
<el-color-picker v-model="customColors.primaryColor" @change="onCustomColorChange" />
</div>
<div class="color-item">
<span class="color-label">侧边栏背景</span>
<el-color-picker v-model="customColors.sidebarBg" @change="onCustomColorChange" />
</div>
<div class="color-item">
<span class="color-label">页面背景</span>
<el-color-picker v-model="customColors.bgPage" @change="onCustomColorChange" />
</div>
<el-button
type="primary"
class="apply-btn"
@click="applyCustomTheme"
>
应用自定义主题
</el-button>
</div>
</div>
<!-- 快速预览 -->
<div class="theme-section">
<h4 class="section-title">预览效果</h4>
<div class="preview-box">
<div class="preview-sidebar" :style="{ backgroundColor: previewColors.sidebarBg }">
<div class="preview-logo" :style="{ backgroundColor: previewColors.primaryColor }"></div>
<div class="preview-menu">
<div class="preview-menu-item"></div>
<div class="preview-menu-item active" :style="{ backgroundColor: previewColors.primaryBg }"></div>
<div class="preview-menu-item"></div>
</div>
</div>
<div class="preview-main" :style="{ backgroundColor: previewColors.bgPage }">
<div class="preview-header"></div>
<div class="preview-content">
<div class="preview-card"></div>
</div>
</div>
</div>
</div>
</div>
</el-drawer>
</template>
<script setup lang="ts">
import { ref, reactive, computed, watch } from 'vue'
import { Check } from '@element-plus/icons-vue'
import { useThemeStore, presetThemes } from '@/store/modules/theme'
const themeStore = useThemeStore()
//
const customColors = reactive({
primaryColor: '#4A90D9',
sidebarBg: '#F0F7FF',
bgPage: '#F5F9FC'
})
//
const previewColors = computed(() => {
if (themeStore.currentTheme === 'custom' && themeStore.customTheme) {
return {
primaryColor: themeStore.customTheme.primaryColor || customColors.primaryColor,
sidebarBg: themeStore.customTheme.sidebarBg || customColors.sidebarBg,
bgPage: themeStore.customTheme.bgPage || customColors.bgPage,
primaryBg: themeStore.customTheme.primaryBg || lightenColor(customColors.primaryColor, 0.9)
}
}
const theme = presetThemes.find(t => t.name === themeStore.currentTheme)
if (theme) {
return {
primaryColor: theme.primaryColor,
sidebarBg: theme.sidebarBg,
bgPage: theme.bgPage,
primaryBg: theme.primaryBg
}
}
return {
primaryColor: customColors.primaryColor,
sidebarBg: customColors.sidebarBg,
bgPage: customColors.bgPage,
primaryBg: lightenColor(customColors.primaryColor, 0.9)
}
})
//
const lightenColor = (hex: string, ratio: number): string => {
const color = hex.replace('#', '')
const r = parseInt(color.substring(0, 2), 16)
const g = parseInt(color.substring(2, 4), 16)
const b = parseInt(color.substring(4, 6), 16)
const lr = Math.round(r + (255 - r) * ratio)
const lg = Math.round(g + (255 - g) * ratio)
const lb = Math.round(b + (255 - b) * ratio)
return `rgb(${lr}, ${lg}, ${lb})`
}
//
const darkenColor = (hex: string, ratio: number): string => {
const color = hex.replace('#', '')
const r = parseInt(color.substring(0, 2), 16)
const g = parseInt(color.substring(2, 4), 16)
const b = parseInt(color.substring(4, 6), 16)
const dr = Math.round(r * (1 - ratio))
const dg = Math.round(g * (1 - ratio))
const db = Math.round(b * (1 - ratio))
return `rgb(${dr}, ${dg}, ${db})`
}
const onCustomColorChange = () => {
//
}
const applyCustomTheme = () => {
themeStore.saveCustomTheme({
primaryColor: customColors.primaryColor,
primaryLight: lightenColor(customColors.primaryColor, 0.3),
primaryDark: darkenColor(customColors.primaryColor, 0.2),
primaryBg: lightenColor(customColors.primaryColor, 0.9),
sidebarBg: customColors.sidebarBg,
sidebarLogoBg: customColors.primaryColor,
sidebarTextActive: customColors.primaryColor,
bgPage: customColors.bgPage,
bgLight: lightenColor(customColors.bgPage, 0.5),
bgHover: lightenColor(customColors.primaryColor, 0.85),
loginBgStart: lightenColor(customColors.primaryColor, 0.2),
loginBgEnd: customColors.primaryColor
})
}
//
watch(() => themeStore.customTheme, (val) => {
if (val) {
customColors.primaryColor = val.primaryColor || '#4A90D9'
customColors.sidebarBg = val.sidebarBg || '#F0F7FF'
customColors.bgPage = val.bgPage || '#F5F9FC'
}
}, { immediate: true })
</script>
<style scoped>
.theme-drawer {
padding: 0 10px;
}
.theme-section {
margin-bottom: 24px;
}
.section-title {
font-size: 14px;
font-weight: 500;
color: var(--text-primary);
margin-bottom: 12px;
padding-bottom: 8px;
border-bottom: 1px solid var(--border-lighter);
}
.theme-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 12px;
}
.theme-item {
display: flex;
flex-direction: column;
align-items: center;
cursor: pointer;
padding: 8px;
border-radius: var(--border-radius-base);
transition: all 0.2s;
}
.theme-item:hover {
background-color: var(--bg-hover);
}
.theme-item.active {
background-color: var(--primary-bg);
}
.theme-preview {
width: 48px;
height: 48px;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
margin-bottom: 6px;
}
.check-icon {
color: #fff;
font-size: 20px;
}
.theme-label {
font-size: 12px;
color: var(--text-regular);
}
.custom-theme {
background-color: var(--bg-light);
padding: 16px;
border-radius: var(--border-radius-base);
}
.color-item {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
}
.color-label {
font-size: 13px;
color: var(--text-regular);
}
.apply-btn {
width: 100%;
margin-top: 8px;
}
.preview-box {
display: flex;
height: 120px;
border-radius: var(--border-radius-base);
overflow: hidden;
box-shadow: var(--box-shadow-light);
}
.preview-sidebar {
width: 60px;
padding: 8px 6px;
}
.preview-logo {
height: 20px;
border-radius: 4px;
margin-bottom: 8px;
}
.preview-menu-item {
height: 12px;
background-color: rgba(0, 0, 0, 0.05);
border-radius: 3px;
margin-bottom: 6px;
}
.preview-menu-item.active {
background-color: rgba(0, 0, 0, 0.08);
}
.preview-main {
flex: 1;
display: flex;
flex-direction: column;
}
.preview-header {
height: 24px;
background-color: #fff;
border-bottom: 1px solid var(--border-lighter);
}
.preview-content {
flex: 1;
padding: 8px;
}
.preview-card {
height: 100%;
background-color: #fff;
border-radius: 4px;
}
</style>

View File

@ -24,19 +24,30 @@
</el-main>
</el-container>
</el-container>
<!-- 主题设置抽屉 -->
<ThemeDrawer />
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { ref, onMounted } from 'vue'
import Sidebar from './components/Sidebar.vue'
import Header from './components/Header.vue'
import ThemeDrawer from './components/ThemeDrawer.vue'
import { useThemeStore } from '@/store/modules/theme'
const isCollapse = ref(false)
const themeStore = useThemeStore()
const toggleCollapse = () => {
isCollapse.value = !isCollapse.value
}
//
onMounted(() => {
themeStore.initTheme()
})
</script>
<style scoped>

View File

@ -53,6 +53,14 @@ const router = createRouter({
// 白名单路由
const whiteList = ['/login', '/404']
// 标记动态路由是否已加载
let dynamicRoutesLoaded = false
// 重置动态路由状态(供外部调用)
export function resetRouter() {
dynamicRoutesLoaded = false
}
// 路由守卫
router.beforeEach(async (to, _from, next) => {
const token = getToken()
@ -64,12 +72,15 @@ router.beforeEach(async (to, _from, next) => {
const userStore = useUserStore()
const permissionStore = usePermissionStore()
if (userStore.userInfo) {
// 检查动态路由是否已加载
if (dynamicRoutesLoaded) {
next()
} else {
try {
// 获取用户信息
await userStore.getUserInfo()
// 获取用户信息(如果还没有)
if (!userStore.userInfo) {
await userStore.getUserInfo()
}
// 生成动态路由
const accessRoutes = await permissionStore.generateRoutes()
// 添加动态路由
@ -78,15 +89,20 @@ router.beforeEach(async (to, _from, next) => {
})
// 添加 404 兜底路由
router.addRoute({ path: '/:pathMatch(.*)*', redirect: '/404' })
// 标记动态路由已加载
dynamicRoutesLoaded = true
next({ ...to, replace: true })
} catch (error) {
// 获取用户信息失败,清除 token 并跳转登录页
dynamicRoutesLoaded = false
userStore.logout()
next(`/login?redirect=${to.path}`)
}
}
}
} else {
// 重置动态路由标记
dynamicRoutesLoaded = false
if (whiteList.includes(to.path)) {
next()
} else {

View File

@ -6,3 +6,4 @@ export default pinia
export * from './modules/user'
export * from './modules/permission'
export * from './modules/theme'

View File

@ -0,0 +1,262 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
// 预设主题配置
export interface ThemeConfig {
name: string
label: string
primaryColor: string
primaryLight: string
primaryDark: string
primaryBg: string
sidebarBg: string
sidebarLogoBg: string
sidebarTextActive: string
bgPage: string
bgLight: string
bgHover: string
loginBgStart: string
loginBgEnd: string
}
// 系统预设主题
export const presetThemes: ThemeConfig[] = [
{
name: 'blue',
label: '天空蓝',
primaryColor: '#4A90D9',
primaryLight: '#74B9FF',
primaryDark: '#2B7DE9',
primaryBg: '#E8F4FD',
sidebarBg: '#F0F7FF',
sidebarLogoBg: '#4A90D9',
sidebarTextActive: '#4A90D9',
bgPage: '#F5F9FC',
bgLight: '#F0F7FF',
bgHover: '#E8F4FD',
loginBgStart: '#74B9FF',
loginBgEnd: '#4A90D9'
},
{
name: 'green',
label: '翠绿',
primaryColor: '#52C41A',
primaryLight: '#73D13D',
primaryDark: '#389E0D',
primaryBg: '#F6FFED',
sidebarBg: '#F6FFED',
sidebarLogoBg: '#52C41A',
sidebarTextActive: '#52C41A',
bgPage: '#F9FFF6',
bgLight: '#F6FFED',
bgHover: '#D9F7BE',
loginBgStart: '#73D13D',
loginBgEnd: '#52C41A'
},
{
name: 'purple',
label: '典雅紫',
primaryColor: '#722ED1',
primaryLight: '#9254DE',
primaryDark: '#531DAB',
primaryBg: '#F9F0FF',
sidebarBg: '#F9F0FF',
sidebarLogoBg: '#722ED1',
sidebarTextActive: '#722ED1',
bgPage: '#FBF5FF',
bgLight: '#F9F0FF',
bgHover: '#EFDBFF',
loginBgStart: '#9254DE',
loginBgEnd: '#722ED1'
},
{
name: 'orange',
label: '活力橙',
primaryColor: '#FA8C16',
primaryLight: '#FFA940',
primaryDark: '#D46B08',
primaryBg: '#FFF7E6',
sidebarBg: '#FFF7E6',
sidebarLogoBg: '#FA8C16',
sidebarTextActive: '#FA8C16',
bgPage: '#FFFBF5',
bgLight: '#FFF7E6',
bgHover: '#FFE7BA',
loginBgStart: '#FFA940',
loginBgEnd: '#FA8C16'
},
{
name: 'red',
label: '中国红',
primaryColor: '#F5222D',
primaryLight: '#FF4D4F',
primaryDark: '#CF1322',
primaryBg: '#FFF1F0',
sidebarBg: '#FFF1F0',
sidebarLogoBg: '#F5222D',
sidebarTextActive: '#F5222D',
bgPage: '#FFFAFA',
bgLight: '#FFF1F0',
bgHover: '#FFCCC7',
loginBgStart: '#FF4D4F',
loginBgEnd: '#F5222D'
},
{
name: 'dark',
label: '暗夜黑',
primaryColor: '#1890FF',
primaryLight: '#40A9FF',
primaryDark: '#096DD9',
primaryBg: '#111B26',
sidebarBg: '#001529',
sidebarLogoBg: '#002140',
sidebarTextActive: '#1890FF',
bgPage: '#0D1117',
bgLight: '#161B22',
bgHover: '#21262D',
loginBgStart: '#001529',
loginBgEnd: '#000C17'
}
]
const THEME_STORAGE_KEY = 'honeybox-admin-theme'
const CUSTOM_THEME_KEY = 'honeybox-admin-custom-theme'
export const useThemeStore = defineStore('theme', () => {
const currentTheme = ref<string>('blue')
const customTheme = ref<Partial<ThemeConfig> | null>(null)
const showThemeDrawer = ref(false)
// 初始化主题
const initTheme = () => {
const savedTheme = localStorage.getItem(THEME_STORAGE_KEY)
const savedCustom = localStorage.getItem(CUSTOM_THEME_KEY)
if (savedCustom) {
customTheme.value = JSON.parse(savedCustom)
}
if (savedTheme) {
currentTheme.value = savedTheme
applyTheme(savedTheme)
}
}
// 应用主题
const applyTheme = (themeName: string) => {
let theme: ThemeConfig | Partial<ThemeConfig> | undefined
if (themeName === 'custom' && customTheme.value) {
theme = customTheme.value
} else {
theme = presetThemes.find(t => t.name === themeName)
}
if (!theme) return
const root = document.documentElement
// 应用CSS变量
if (theme.primaryColor) {
root.style.setProperty('--primary-color', theme.primaryColor)
root.style.setProperty('--el-color-primary', theme.primaryColor)
}
if (theme.primaryLight) {
root.style.setProperty('--primary-light', theme.primaryLight)
}
if (theme.primaryDark) {
root.style.setProperty('--primary-dark', theme.primaryDark)
}
if (theme.primaryBg) {
root.style.setProperty('--primary-bg', theme.primaryBg)
}
if (theme.sidebarBg) {
root.style.setProperty('--sidebar-bg', theme.sidebarBg)
}
if (theme.sidebarLogoBg) {
root.style.setProperty('--sidebar-logo-bg', theme.sidebarLogoBg)
}
if (theme.sidebarTextActive) {
root.style.setProperty('--sidebar-text-active', theme.sidebarTextActive)
root.style.setProperty('--sidebar-item-active', theme.primaryBg || theme.sidebarBg || '')
root.style.setProperty('--sidebar-item-hover', theme.bgHover || '')
}
if (theme.bgPage) {
root.style.setProperty('--bg-page', theme.bgPage)
root.style.setProperty('--el-bg-color-page', theme.bgPage)
}
if (theme.bgLight) {
root.style.setProperty('--bg-light', theme.bgLight)
}
if (theme.bgHover) {
root.style.setProperty('--bg-hover', theme.bgHover)
}
if (theme.loginBgStart) {
root.style.setProperty('--login-bg-start', theme.loginBgStart)
}
if (theme.loginBgEnd) {
root.style.setProperty('--login-bg-end', theme.loginBgEnd)
}
// 生成 Element Plus 主色阶
if (theme.primaryColor) {
generateElPrimaryColors(theme.primaryColor)
}
}
// 生成 Element Plus 主色阶
const generateElPrimaryColors = (primary: string) => {
const root = document.documentElement
const hex = primary.replace('#', '')
const r = parseInt(hex.substring(0, 2), 16)
const g = parseInt(hex.substring(2, 4), 16)
const b = parseInt(hex.substring(4, 6), 16)
// 生成浅色
const lightLevels = [3, 5, 7, 8, 9]
lightLevels.forEach(level => {
const ratio = level / 10
const lr = Math.round(r + (255 - r) * ratio)
const lg = Math.round(g + (255 - g) * ratio)
const lb = Math.round(b + (255 - b) * ratio)
root.style.setProperty(`--el-color-primary-light-${level}`, `rgb(${lr}, ${lg}, ${lb})`)
})
// 生成深色
const darkRatio = 0.2
const dr = Math.round(r * (1 - darkRatio))
const dg = Math.round(g * (1 - darkRatio))
const db = Math.round(b * (1 - darkRatio))
root.style.setProperty('--el-color-primary-dark-2', `rgb(${dr}, ${dg}, ${db})`)
}
// 切换主题
const setTheme = (themeName: string) => {
currentTheme.value = themeName
localStorage.setItem(THEME_STORAGE_KEY, themeName)
applyTheme(themeName)
}
// 保存自定义主题
const saveCustomTheme = (theme: Partial<ThemeConfig>) => {
customTheme.value = { ...theme, name: 'custom', label: '自定义' }
localStorage.setItem(CUSTOM_THEME_KEY, JSON.stringify(customTheme.value))
setTheme('custom')
}
// 切换主题抽屉
const toggleThemeDrawer = () => {
showThemeDrawer.value = !showThemeDrawer.value
}
return {
currentTheme,
customTheme,
showThemeDrawer,
initTheme,
setTheme,
saveCustomTheme,
toggleThemeDrawer,
applyTheme
}
})

View File

@ -2,7 +2,7 @@ import { defineStore } from 'pinia'
import { ref } from 'vue'
import { login as loginApi, getUserInfo as getUserInfoApi, logout as logoutApi, type LoginRequest, type UserInfo } from '@/api/auth'
import { setToken, removeToken } from '@/utils/auth'
import router from '@/router'
import router, { resetRouter } from '@/router'
export const useUserStore = defineStore('user', () => {
const token = ref<string | null>(null)
@ -34,6 +34,7 @@ export const useUserStore = defineStore('user', () => {
token.value = null
userInfo.value = null
removeToken()
resetRouter()
router.push('/login')
}

View File

@ -23,7 +23,7 @@ export default defineConfig({
server: {
proxy: {
'/api': {
target: 'http://localhost:5000',
target: 'http://localhost:61551',
changeOrigin: true
}
}