feat: sidebar layout with dark mode support

Restore full Element Plus sidebar with collapsible navigation, mobile drawer, and dark mode CSS variables integration
This commit is contained in:
烧瑚烙饼 2026-03-22 16:03:03 +08:00
parent f5c6a6991b
commit 9a76c657af

View File

@ -1,12 +1,12 @@
<template>
<el-container class="layout-container">
<el-aside :width="collapsed ? '64px' : '200px'" class="sidebar desktop-only">
<el-aside :width="collapsed ? '64px' : '200px'" class="sidebar desktop-only transition-base">
<div class="logo">
<h2 v-if="!collapsed">运动会记分板</h2>
<el-icon v-else :size="24"><Trophy /></el-icon>
<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">
<el-button text @click="toggleCollapse" size="small" class="rotate-icon">
<el-icon>
<ArrowLeft v-if="!collapsed" />
<ArrowRight v-else />
@ -19,15 +19,37 @@
:collapse="collapsed"
:collapse-transition="false"
>
<el-menu-item
v-for="item in menuItems"
:key="item.path"
:index="item.path"
@click="onMenuClick(item.path)"
>
<el-icon><component :is="item.icon" /></el-icon>
<span>{{ item.label }}</span>
</el-menu-item>
<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>
@ -54,6 +76,12 @@
<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">
@ -86,7 +114,7 @@
direction="ltr"
size="70%"
:with-header="false"
class="mobile-only"
class="mobile-only drawer-transition"
>
<div class="drawer-logo">
<h2>运动会记分板</h2>
@ -95,15 +123,37 @@
:default-active="activeMenu"
class="drawer-menu"
>
<el-menu-item
v-for="item in menuItems"
:key="item.path"
:index="item.path"
@click="onMenuClick(item.path, true)"
>
<el-icon><component :is="item.icon" /></el-icon>
<span>{{ item.label }}</span>
</el-menu-item>
<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>
@ -122,7 +172,7 @@
</el-menu>
</el-drawer>
<el-dialog v-model="showLoginDialog" title="登录" width="400px">
<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
@ -150,7 +200,9 @@
</template>
<script setup lang="ts">
import { HomeFilled, Trophy, UserFilled, Edit, DataLine, Menu, Setting, Key, ArrowLeft, ArrowRight, ArrowDown } from '@element-plus/icons-vue'
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)
@ -159,6 +211,22 @@ const showLoginDialog = ref(false)
const logging = ref(false)
const loginFormRef = ref()
// 使 VueUse useDark - SSR safe
const isDark = ref(false)
const toggleDark = ref(() => {})
onMounted(() => {
const dark = useDark({
selector: 'html',
attribute: 'class',
valueDark: 'dark',
valueLight: '',
storageKey: 'theme',
})
isDark.value = dark
toggleDark.value = useToggle(dark)
})
const collapsed = ref(false)
const currentUser = ref<any>(null)
const hasAdminMenu = computed(() => currentUser.value?.role === 'admin')
@ -175,10 +243,17 @@ const loginRules = {
const menuItems = [
{ path: '/', label: '首页', icon: HomeFilled },
{ path: '/events', label: '比赛项目', icon: Trophy },
{ path: '/teams', label: '队伍管理', icon: UserFilled },
{ path: '/results', label: '成绩录入', icon: Edit },
{ path: '/scoreboard', label: '记分板', icon: DataLine }
{
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 = () => {
@ -243,9 +318,12 @@ onMounted(() => {
if (savedUser) {
currentUser.value = JSON.parse(savedUser)
}
// VueUse useDark
})
</script>
<style scoped>
.layout-container {
height: 100vh;
@ -256,73 +334,213 @@ onMounted(() => {
overflow-y: auto;
}
/* 侧边栏 - 始终使用深色背景 */
.sidebar {
background-color: #304156;
color: #fff;
transition: width 0.3s;
background: linear-gradient(180deg, var(--sidebar-bg) 0%, var(--sidebar-header-bg) 100%);
color: var(--sidebar-text);
transition: width 0.3s cubic-bezier(0.4, 0, 0.2, 1);
overflow: hidden;
height: 100%;
box-shadow: 2px 0 8px rgba(0, 0, 0, 0.1);
}
.logo {
height: 56px;
height: 60px;
display: flex;
align-items: center;
justify-content: center;
background-color: #2b3a4a;
background-color: var(--sidebar-header-bg);
border-bottom: 1px rgba(255, 255, 255, 0.05);
overflow: hidden;
}
.logo h2 {
color: #fff;
font-size: 16px;
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: 4px 8px;
border-bottom: 1px solid #263445;
padding: 8px 12px;
border-bottom: 1px rgba(255, 255, 255, 0.05);
}
.rotate-icon {
transition: transform 0.3s ease;
}
.rotate-icon:hover {
transform: rotate(180deg);
}
/* 菜单样式 */
.sidebar-menu {
border-right: none;
background-color: #304156;
background-color: transparent;
}
.sidebar-menu:not(.el-menu--collapse) {
width: 200px;
width: 100%;
}
.sidebar-menu .el-menu-item,
.sidebar-menu .el-sub-menu__title {
color: #bfcbd9;
color: var(--sidebar-text);
height: 48px;
line-height: 48px;
margin: 4px 8px;
border-radius: 8px;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.sidebar-menu .el-menu-item:hover,
.sidebar-menu .el-sub-menu__title:hover,
.sidebar-menu .el-sub-menu__title:hover {
background-color: var(--sidebar-hover-bg);
color: #fff;
}
.sidebar-menu .el-menu-item.is-active,
.sidebar-menu .el-sub-menu .el-menu-item.is-active {
background-color: #263445;
color: #409eff;
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: #fff;
border-radius: 0 3px 3px 0;
}
/* 子菜单样式 */
.sidebar-menu .el-sub-menu .el-menu-item {
padding-left: 20px !important;
margin: 2px 8px;
}
/* 菜单图标 */
.sidebar-menu .el-icon {
margin-right: 8px;
font-size: 18px;
}
/* Staggered animation for menu items */
.sidebar-menu .el-menu-item:nth-child(1),
.sidebar-menu .el-sub-menu:nth-child(1) {
animation-delay: 0.05s;
}
.sidebar-menu .el-menu-item:nth-child(2),
.sidebar-menu .el-sub-menu:nth-child(2) {
animation-delay: 0.1s;
}
.sidebar-menu .el-menu-item:nth-child(3),
.sidebar-menu .el-sub-menu:nth-child(3) {
animation-delay: 0.15s;
}
.sidebar-menu .el-menu-item:nth-child(4),
.sidebar-menu .el-sub-menu:nth-child(4) {
animation-delay: 0.2s;
}
.sidebar-menu .el-menu-item:nth-child(5),
.sidebar-menu .el-sub-menu:nth-child(5) {
animation-delay: 0.25s;
}
@keyframes slide-in-left {
0% {
opacity: 0;
transform: translateX(-20px);
}
100% {
opacity: 1;
transform: translateX(0);
}
}
.sidebar-menu .el-menu-item:hover,
.sidebar-menu .el-sub-menu__title:hover {
background-color: var(--sidebar-hover-bg);
color: #fff;
transform: translateX(5px);
}
.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;
transform: translateX(5px);
}
.sidebar-menu .el-menu-item.is-active::before {
content: '';
position: absolute;
left: 0;
top: 50%;
transform: translateY(-50%);
width: 3px;
height: 24px;
background-color: #fff;
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: #fff;
border-bottom: 1px solid #e6e6e6;
background-color: var(--header-bg);
border-bottom: 1px solid var(--header-border);
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 20px;
height: 56px;
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: 8px;
gap: 12px;
}
.header-right {
@ -332,7 +550,9 @@ onMounted(() => {
.header h3 {
margin: 0;
color: #303133;
color: var(--header-text);
font-size: 18px;
font-weight: 600;
}
.user-info {
@ -340,37 +560,78 @@ onMounted(() => {
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: #303133;
color: var(--header-text);
font-weight: 500;
}
.theme-toggle-btn {
margin-right: 12px;
color: var(--header-text);
border-radius: 8px;
padding: 8px;
transition: all 0.3s ease;
}
.theme-toggle-btn:hover {
background-color: var(--sidebar-hover-bg) !important;
color: #fff;
transform: rotate(30deg);
}
.main-content {
background-color: #f0f2f5;
padding: 20px;
background-color: var(--main-bg);
padding: 24px;
height: 100%;
box-sizing: border-box;
overflow-y: auto;
animation: fade-in 0.4s ease-out;
}
/* 移动端抽屉 */
.drawer-logo {
height: 56px;
height: 60px;
display: flex;
align-items: center;
justify-content: center;
background-color: #2b3a4a;
color: #fff;
background: linear-gradient(180deg, var(--sidebar-bg) 0%, var(--sidebar-header-bg) 100%);
border-bottom: 1px rgba(255, 255, 255, 0.05);
}
.drawer-logo h2 {
margin: 0;
font-size: 18px;
font-weight: 600;
color: #fff;
}
.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: #fff;
}
.desktop-only {
@ -385,7 +646,7 @@ onMounted(() => {
display: none;
}
@media (max-width: 900px) {
@media (max-width: 768px) {
.desktop-only {
display: none;
}
@ -399,17 +660,17 @@ onMounted(() => {
}
.header {
padding: 0 12px;
padding: 0 16px;
}
.main-content {
padding: 12px;
padding: 16px;
}
}
.footer {
background-color: #fff;
border-top: 1px solid #e6e6e6;
background-color: var(--footer-bg);
border-top: 1px solid var(--footer-border);
display: flex;
align-items: center;
justify-content: center;
@ -420,7 +681,7 @@ onMounted(() => {
.footer p {
margin: 0;
color: #909399;
font-size: 12px;
color: var(--footer-text);
font-size: 13px;
}
</style>