laobinghu 4df5c13976 refactor: simplify dark mode with VueUse + Element Plus
Remove custom CSS variable overrides, use Element Plus built-in dark mode with VueUse useDark
2026-03-22 16:22:35 +08:00

720 lines
18 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<el-container class="layout-container">
<el-aside :width="collapsed ? '64px' : '200px'" class="sidebar desktop-only transition-base">
<div class="logo">
<h2 v-if="!collapsed" class="slide-down-enter">运动会记分板</h2>
<el-icon v-else :size="24" class="slide-down-enter"><Trophy /></el-icon>
</div>
<div class="sidebar-toggle">
<el-button text @click="toggleCollapse" size="small" class="rotate-icon">
<el-icon>
<ArrowLeft v-if="!collapsed" />
<ArrowRight v-else />
</el-icon>
</el-button>
</div>
<el-menu
:default-active="activeMenu"
class="sidebar-menu"
:collapse="collapsed"
:collapse-transition="false"
>
<template v-for="item in menuItems" :key="item.path || item.label">
<!-- Regular menu item -->
<el-menu-item
v-if="!item.children"
:index="item.path"
@click="onMenuClick(item.path)"
>
<el-icon><component :is="item.icon" /></el-icon>
<span>{{ item.label }}</span>
</el-menu-item>
<!-- Submenu with children -->
<el-sub-menu
v-else
:index="item.label"
>
<template #title>
<el-icon><component :is="item.icon" /></el-icon>
<span>{{ item.label }}</span>
</template>
<el-menu-item
v-for="child in item.children"
:key="child.path"
:index="child.path"
@click="onMenuClick(child.path)"
>
<el-icon><component :is="child.icon" /></el-icon>
<span>{{ child.label }}</span>
</el-menu-item>
</el-sub-menu>
</template>
<el-sub-menu v-if="hasAdminMenu" index="admin">
<template #title>
<el-icon><Setting /></el-icon>
<span>系统管理</span>
</template>
<el-menu-item index="/admin/users" @click="onMenuClick('/admin/users')">
<el-icon><UserFilled /></el-icon>
<span>用户管理</span>
</el-menu-item>
<el-menu-item index="/admin/oauth" @click="onMenuClick('/admin/oauth')">
<el-icon><Key /></el-icon>
<span>OAuth 客户端</span>
</el-menu-item>
</el-sub-menu>
</el-menu>
</el-aside>
<el-container>
<el-header class="header">
<div class="header-left">
<el-button class="mobile-only-btn" text @click="drawerOpen = true">
<el-icon><Menu /></el-icon>
</el-button>
<h3>运动会管理系统</h3>
</div>
<div class="header-right">
<el-button text @click="toggleDark()" class="theme-toggle-btn">
<el-icon>
<Sunny v-if="isDark" />
<Moon v-else />
</el-icon>
</el-button>
<el-dropdown v-if="currentUser" @command="handleUserCommand">
<span class="user-info">
<el-avatar :size="32">
<el-icon><UserFilled /></el-icon>
</el-avatar>
<span class="username">{{ currentUser.username }}</span>
<el-icon><ArrowDown /></el-icon>
</span>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="profile">个人资料</el-dropdown-item>
<el-dropdown-item command="logout" divided>退出登录</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
<el-button v-else type="primary" @click="showLoginDialog = true">登录</el-button>
</div>
</el-header>
<el-main class="main-content">
<slot />
</el-main>
<el-footer class="footer">
<p>© 2026 运动会记分板系统 - 版权所有</p>
</el-footer>
</el-container>
</el-container>
<el-drawer
v-model="drawerOpen"
direction="ltr"
size="70%"
:with-header="false"
class="mobile-only drawer-transition"
>
<div class="drawer-logo">
<h2>运动会记分板</h2>
</div>
<el-menu
:default-active="activeMenu"
class="drawer-menu"
>
<template v-for="item in menuItems" :key="item.path || item.label">
<!-- Regular menu item -->
<el-menu-item
v-if="!item.children"
:index="item.path"
@click="onMenuClick(item.path, true)"
>
<el-icon><component :is="item.icon" /></el-icon>
<span>{{ item.label }}</span>
</el-menu-item>
<!-- Submenu with children -->
<el-sub-menu
v-else
:index="item.label"
>
<template #title>
<el-icon><component :is="item.icon" /></el-icon>
<span>{{ item.label }}</span>
</template>
<el-menu-item
v-for="child in item.children"
:key="child.path"
:index="child.path"
@click="onMenuClick(child.path, true)"
>
<el-icon><component :is="child.icon" /></el-icon>
<span>{{ child.label }}</span>
</el-menu-item>
</el-sub-menu>
</template>
<el-sub-menu v-if="hasAdminMenu" index="admin">
<template #title>
<el-icon><Setting /></el-icon>
<span>系统管理</span>
</template>
<el-menu-item index="/admin/users" @click="onMenuClick('/admin/users', true)">
<el-icon><UserFilled /></el-icon>
<span>用户管理</span>
</el-menu-item>
<el-menu-item index="/admin/oauth" @click="onMenuClick('/admin/oauth', true)">
<el-icon><Key /></el-icon>
<span>OAuth 客户端</span>
</el-menu-item>
</el-sub-menu>
</el-menu>
</el-drawer>
<el-dialog v-model="showLoginDialog" title="登录" width="400px" class="dialog-zoom">
<el-form :model="loginForm" :rules="loginRules" ref="loginFormRef">
<el-form-item prop="username">
<el-input
v-model="loginForm.username"
placeholder="用户名"
prefix-icon="User"
/>
</el-form-item>
<el-form-item prop="password">
<el-input
v-model="loginForm.password"
type="password"
placeholder="密码"
prefix-icon="Lock"
show-password
@keyup.enter="handleLogin"
/>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="showLoginDialog = false">取消</el-button>
<el-button type="primary" @click="handleLogin" :loading="logging">登录</el-button>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import { HomeFilled, Trophy, UserFilled, Edit, DataLine, Menu, Setting, Key, ArrowLeft, ArrowRight, ArrowDown, LocationFilled, Sunny, Moon } from '@element-plus/icons-vue'
import { useDark, useToggle } from '@vueuse/core'
import { ref, onMounted, computed } from 'vue'
const route = useRoute()
const activeMenu = computed(() => route.path)
const drawerOpen = ref(false)
const showLoginDialog = ref(false)
const logging = ref(false)
const loginFormRef = ref()
// VueUse useDark - 自动添加/移除 html.dark 类Element Plus 自动响应
const isDark = useDark()
const toggleDark = useToggle(isDark)
const collapsed = ref(false)
const currentUser = ref<any>(null)
const hasAdminMenu = computed(() => currentUser.value?.role === 'admin')
const loginForm = ref({
username: '',
password: ''
})
const loginRules = {
username: [{ required: true, message: '请输入用户名', trigger: 'blur' }],
password: [{ required: true, message: '请输入密码', trigger: 'blur' }]
}
const menuItems = [
{ path: '/', label: '首页', icon: HomeFilled },
{
label: '比赛管理',
icon: Trophy,
children: [
{ path: '/events', label: '比赛项目', icon: Trophy },
{ path: '/teams', label: '队伍管理', icon: UserFilled },
{ path: '/results', label: '成绩录入', icon: Edit },
{ path: '/scoreboard', label: '记分板', icon: DataLine }
]
},
{ path: '/checkins', label: '机位打卡', icon: LocationFilled }
]
const toggleCollapse = () => {
collapsed.value = !collapsed.value
localStorage.setItem('sidebar-collapsed', String(collapsed.value))
}
const onMenuClick = (path: string, closeDrawer = false) => {
navigateTo(path)
if (closeDrawer) {
drawerOpen.value = false
}
}
const handleUserCommand = async (command: string) => {
if (command === 'logout') {
localStorage.removeItem('token')
localStorage.removeItem('refreshToken')
localStorage.removeItem('user')
currentUser.value = null
ElMessage.success('已退出登录')
}
}
const handleLogin = async () => {
if (!loginFormRef.value) return
await loginFormRef.value.validate(async (valid: boolean) => {
if (!valid) return
logging.value = true
try {
const res = await $fetch('/api/auth/login', {
method: 'POST',
body: loginForm.value
})
if (res.success) {
localStorage.setItem('token', res.data.accessToken)
localStorage.setItem('refreshToken', res.data.refreshToken)
localStorage.setItem('user', JSON.stringify(res.data.user))
currentUser.value = res.data.user
showLoginDialog.value = false
loginForm.value = { username: '', password: '' }
ElMessage.success('登录成功')
}
} catch (error: any) {
ElMessage.error(error.data?.message || '登录失败')
} finally {
logging.value = false
}
})
}
onMounted(() => {
const savedCollapsed = localStorage.getItem('sidebar-collapsed')
if (savedCollapsed !== null) {
collapsed.value = savedCollapsed === 'true'
}
const savedUser = localStorage.getItem('user')
if (savedUser) {
currentUser.value = JSON.parse(savedUser)
}
})
</script>
<style scoped>
/* ========== 布局容器 ========== */
.layout-container {
height: 100vh;
overflow: hidden;
}
.el-main {
overflow-y: auto;
}
/* ========== 侧边栏 ========== */
.sidebar {
background: var(--sidebar-bg);
color: var(--sidebar-text);
transition: width 0.3s ease;
overflow: hidden;
height: 100%;
box-shadow: 2px 0 8px rgba(0, 0, 0, 0.1);
}
.logo {
height: 60px;
display: flex;
align-items: center;
justify-content: center;
background-color: var(--sidebar-header-bg);
border-bottom: 1px solid color-mix(in srgb, var(--sidebar-text) 10%, transparent);
overflow: hidden;
}
.logo h2 {
color: var(--sidebar-active-text);
font-size: 18px;
font-weight: 600;
margin: 0;
white-space: nowrap;
letter-spacing: 0.5px;
transition: opacity 0.2s ease;
}
/* ========== 侧边栏控制按钮 ========== */
.sidebar-toggle {
display: flex;
justify-content: flex-end;
padding: 8px 12px;
border-bottom: 1px solid color-mix(in srgb, var(--sidebar-text) 10%, transparent);
}
.rotate-icon {
transition: transform 0.3s ease;
}
.rotate-icon:hover {
transform: rotate(180deg);
}
/* ========== 菜单基础样式 ========== */
.sidebar-menu {
border-right: none;
background-color: transparent;
width: 100%;
}
.sidebar-menu .el-menu-item,
.sidebar-menu .el-sub-menu__title {
color: var(--sidebar-text);
height: 48px;
line-height: 48px;
margin: 4px 8px;
border-radius: 8px;
transition: all 0.2s ease;
}
/* ========== 菜单悬停状态 ========== */
.sidebar-menu .el-menu-item:hover,
.sidebar-menu .el-sub-menu__title:hover {
background-color: var(--sidebar-hover-bg);
color: var(--sidebar-active-text);
}
/* ========== 菜单激活状态 ========== */
.sidebar-menu .el-menu-item.is-active,
.sidebar-menu .el-sub-menu .el-menu-item.is-active {
background-color: var(--sidebar-active-bg);
color: var(--sidebar-active-text);
font-weight: 500;
}
.sidebar-menu .el-menu-item.is-active::before {
content: '';
position: absolute;
left: 0;
top: 50%;
transform: translateY(-50%);
width: 3px;
height: 24px;
background-color: var(--sidebar-active-text);
border-radius: 0 3px 3px 0;
}
/* ========== 子菜单样式 ========== */
.sidebar-menu .el-sub-menu .el-menu-item {
padding-left: 20px !important;
margin: 2px 8px;
display: flex;
align-items: center;
}
/* ========== 菜单图标 ========== */
.sidebar-menu .el-icon {
display: flex;
align-items: center;
justify-content: center;
margin-right: 8px;
font-size: 18px;
flex-shrink: 0;
}
/* ========== 子菜单箭头 ========== */
.sidebar-menu .el-sub-menu__title .el-sub-menu__icon-arrow {
margin-left: auto;
display: flex;
align-items: center;
}
.el-main {
overflow-y: auto;
}
/* ========== 侧边栏 ========== */
.sidebar {
background: var(--sidebar-bg);
color: var(--sidebar-text);
transition: width 0.3s ease;
overflow: hidden;
height: 100%;
box-shadow: 2px 0 8px rgba(0, 0, 0, 0.1);
}
.logo {
height: 60px;
display: flex;
align-items: center;
justify-content: center;
background-color: var(--sidebar-header-bg);
border-bottom: 1px solid var(--sidebar-text);
border-color: color-mix(in srgb, var(--sidebar-text) 10%, transparent);
overflow: hidden;
}
.logo h2 {
color: var(--sidebar-active-text);
font-size: 18px;
font-weight: 600;
margin: 0;
white-space: nowrap;
letter-spacing: 0.5px;
transition: opacity 0.2s ease;
}
/* ========== 侧边栏控制按钮 ========== */
.sidebar-toggle {
display: flex;
justify-content: flex-end;
padding: 8px 12px;
border-bottom: 1px solid color-mix(in srgb, var(--sidebar-text) 10%, transparent);
}
.rotate-icon {
transition: transform 0.3s ease;
}
.rotate-icon:hover {
transform: rotate(180deg);
}
/* ========== 菜单基础样式 ========== */
.sidebar-menu {
border-right: none;
background-color: transparent;
width: 100%;
}
.sidebar-menu .el-menu-item,
.sidebar-menu .el-sub-menu__title {
color: var(--sidebar-text);
height: 48px;
line-height: 48px;
margin: 4px 8px;
border-radius: 8px;
transition: all 0.2s ease;
}
/* ========== 菜单悬停状态 ========== */
.sidebar-menu .el-menu-item:hover,
.sidebar-menu .el-sub-menu__title:hover {
background-color: var(--sidebar-hover-bg);
color: var(--sidebar-active-text);
}
/* ========== 菜单激活状态 ========== */
.sidebar-menu .el-menu-item.is-active,
.sidebar-menu .el-sub-menu .el-menu-item.is-active {
background-color: var(--sidebar-active-bg);
color: var(--sidebar-active-text);
font-weight: 500;
}
.sidebar-menu .el-menu-item.is-active::before {
content: '';
position: absolute;
left: 0;
top: 50%;
transform: translateY(-50%);
width: 3px;
height: 24px;
background-color: var(--sidebar-active-text);
border-radius: 0 3px 3px 0;
}
/* ========== 子菜单样式 ========== */
.sidebar-menu .el-sub-menu .el-menu-item {
padding-left: 20px !important;
margin: 2px 8px;
display: flex;
align-items: center;
}
/* ========== 菜单图标 ========== */
.sidebar-menu .el-icon {
display: flex;
align-items: center;
justify-content: center;
margin-right: 8px;
font-size: 18px;
flex-shrink: 0;
}
/* ========== 子菜单箭头 ========== */
.sidebar-menu .el-sub-menu__title .el-sub-menu__icon-arrow {
margin-left: auto;
display: flex;
align-items: center;
}
.header {
background-color: var(--el-bg-color);
border-bottom: 1px solid var(--el-border-color);
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 24px;
height: 60px;
flex-shrink: 0;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
}
.header-left {
display: flex;
align-items: center;
gap: 12px;
}
.header-right {
display: flex;
align-items: center;
}
.header h3 {
margin: 0;
color: var(--el-text-color-primary);
font-size: 18px;
font-weight: 600;
}
.user-info {
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
padding: 6px 12px;
border-radius: 8px;
transition: all 0.3s ease;
}
.user-info:hover {
background-color: var(--sidebar-hover-bg);
transform: translateY(-2px);
}
.username {
color: var(--el-text-color-primary);
font-weight: 500;
}
.theme-toggle-btn {
margin-right: 12px;
color: var(--el-text-color-primary);
border-radius: 8px;
padding: 8px;
transition: all 0.3s ease;
}
.theme-toggle-btn:hover {
background-color: var(--sidebar-hover-bg) !important;
color: var(--sidebar-active-text);
transform: rotate(30deg);
}
.main-content {
background-color: var(--el-bg-color-page);
padding: 24px;
height: 100%;
box-sizing: border-box;
overflow-y: auto;
animation: fade-in 0.4s ease-out;
}
/* 移动端抽屉 */
.drawer-logo {
height: 60px;
display: flex;
align-items: center;
justify-content: center;
background: var(--sidebar-bg);
border-bottom: 1px solid color-mix(in srgb, var(--sidebar-text) 10%, transparent);
}
.drawer-logo h2 {
margin: 0;
font-size: 18px;
font-weight: 600;
color: var(--sidebar-active-text);
}
.drawer-menu {
border-right: none;
background-color: var(--sidebar-bg);
}
.drawer-menu .el-menu-item,
.drawer-menu .el-sub-menu__title,
.drawer-menu .el-sub-menu .el-menu-item {
color: var(--sidebar-text);
}
.drawer-menu .el-menu-item:hover,
.drawer-menu .el-sub-menu__title:hover,
.drawer-menu .el-menu-item.is-active,
.drawer-menu .el-sub-menu .el-menu-item.is-active {
background-color: var(--sidebar-hover-bg);
color: var(--sidebar-active-text);
}
.desktop-only {
display: block;
}
.mobile-only {
display: none;
}
.mobile-only-btn {
display: none;
}
@media (max-width: 768px) {
.desktop-only {
display: none;
}
.mobile-only {
display: block;
}
.mobile-only-btn {
display: inline-flex;
}
.header {
padding: 0 16px;
}
.main-content {
padding: 16px;
}
}
.footer {
background-color: var(--el-bg-color);
border-top: 1px solid var(--el-border-color);
display: flex;
align-items: center;
justify-content: center;
height: 40px;
padding: 0;
flex-shrink: 0;
}
.footer p {
margin: 0;
color: var(--el-text-color-secondary);
font-size: 13px;
}
</style>