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> <template>
<el-container class="layout-container"> <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"> <div class="logo">
<h2 v-if="!collapsed">运动会记分板</h2> <h2 v-if="!collapsed" class="slide-down-enter">运动会记分板</h2>
<el-icon v-else :size="24"><Trophy /></el-icon> <el-icon v-else :size="24" class="slide-down-enter"><Trophy /></el-icon>
</div> </div>
<div class="sidebar-toggle"> <div class="sidebar-toggle">
<el-button text @click="toggleCollapse" size="small"> <el-button text @click="toggleCollapse" size="small" class="rotate-icon">
<el-icon> <el-icon>
<ArrowLeft v-if="!collapsed" /> <ArrowLeft v-if="!collapsed" />
<ArrowRight v-else /> <ArrowRight v-else />
@ -19,9 +19,10 @@
:collapse="collapsed" :collapse="collapsed"
:collapse-transition="false" :collapse-transition="false"
> >
<template v-for="item in menuItems" :key="item.path || item.label">
<!-- Regular menu item -->
<el-menu-item <el-menu-item
v-for="item in menuItems" v-if="!item.children"
:key="item.path"
:index="item.path" :index="item.path"
@click="onMenuClick(item.path)" @click="onMenuClick(item.path)"
> >
@ -29,6 +30,27 @@
<span>{{ item.label }}</span> <span>{{ item.label }}</span>
</el-menu-item> </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"> <el-sub-menu v-if="hasAdminMenu" index="admin">
<template #title> <template #title>
<el-icon><Setting /></el-icon> <el-icon><Setting /></el-icon>
@ -54,6 +76,12 @@
<h3>运动会管理系统</h3> <h3>运动会管理系统</h3>
</div> </div>
<div class="header-right"> <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"> <el-dropdown v-if="currentUser" @command="handleUserCommand">
<span class="user-info"> <span class="user-info">
<el-avatar :size="32"> <el-avatar :size="32">
@ -86,7 +114,7 @@
direction="ltr" direction="ltr"
size="70%" size="70%"
:with-header="false" :with-header="false"
class="mobile-only" class="mobile-only drawer-transition"
> >
<div class="drawer-logo"> <div class="drawer-logo">
<h2>运动会记分板</h2> <h2>运动会记分板</h2>
@ -95,9 +123,10 @@
:default-active="activeMenu" :default-active="activeMenu"
class="drawer-menu" class="drawer-menu"
> >
<template v-for="item in menuItems" :key="item.path || item.label">
<!-- Regular menu item -->
<el-menu-item <el-menu-item
v-for="item in menuItems" v-if="!item.children"
:key="item.path"
:index="item.path" :index="item.path"
@click="onMenuClick(item.path, true)" @click="onMenuClick(item.path, true)"
> >
@ -105,6 +134,27 @@
<span>{{ item.label }}</span> <span>{{ item.label }}</span>
</el-menu-item> </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"> <el-sub-menu v-if="hasAdminMenu" index="admin">
<template #title> <template #title>
<el-icon><Setting /></el-icon> <el-icon><Setting /></el-icon>
@ -122,7 +172,7 @@
</el-menu> </el-menu>
</el-drawer> </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 :model="loginForm" :rules="loginRules" ref="loginFormRef">
<el-form-item prop="username"> <el-form-item prop="username">
<el-input <el-input
@ -150,7 +200,9 @@
</template> </template>
<script setup lang="ts"> <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 route = useRoute()
const activeMenu = computed(() => route.path) const activeMenu = computed(() => route.path)
@ -159,6 +211,22 @@ const showLoginDialog = ref(false)
const logging = ref(false) const logging = ref(false)
const loginFormRef = ref() 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 collapsed = ref(false)
const currentUser = ref<any>(null) const currentUser = ref<any>(null)
const hasAdminMenu = computed(() => currentUser.value?.role === 'admin') const hasAdminMenu = computed(() => currentUser.value?.role === 'admin')
@ -175,10 +243,17 @@ const loginRules = {
const menuItems = [ const menuItems = [
{ path: '/', label: '首页', icon: HomeFilled }, { path: '/', label: '首页', icon: HomeFilled },
{
label: '比赛管理',
icon: Trophy,
children: [
{ path: '/events', label: '比赛项目', icon: Trophy }, { path: '/events', label: '比赛项目', icon: Trophy },
{ path: '/teams', label: '队伍管理', icon: UserFilled }, { path: '/teams', label: '队伍管理', icon: UserFilled },
{ path: '/results', label: '成绩录入', icon: Edit }, { path: '/results', label: '成绩录入', icon: Edit },
{ path: '/scoreboard', label: '记分板', icon: DataLine } { path: '/scoreboard', label: '记分板', icon: DataLine }
]
},
{ path: '/checkins', label: '机位打卡', icon: LocationFilled }
] ]
const toggleCollapse = () => { const toggleCollapse = () => {
@ -243,9 +318,12 @@ onMounted(() => {
if (savedUser) { if (savedUser) {
currentUser.value = JSON.parse(savedUser) currentUser.value = JSON.parse(savedUser)
} }
// VueUse useDark
}) })
</script> </script>
<style scoped> <style scoped>
.layout-container { .layout-container {
height: 100vh; height: 100vh;
@ -256,73 +334,213 @@ onMounted(() => {
overflow-y: auto; overflow-y: auto;
} }
/* 侧边栏 - 始终使用深色背景 */
.sidebar { .sidebar {
background-color: #304156; background: linear-gradient(180deg, var(--sidebar-bg) 0%, var(--sidebar-header-bg) 100%);
color: #fff; color: var(--sidebar-text);
transition: width 0.3s; transition: width 0.3s cubic-bezier(0.4, 0, 0.2, 1);
overflow: hidden; overflow: hidden;
height: 100%; height: 100%;
box-shadow: 2px 0 8px rgba(0, 0, 0, 0.1);
} }
.logo { .logo {
height: 56px; height: 60px;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: 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 { .logo h2 {
color: #fff; color: #fff;
font-size: 16px; font-size: 18px;
font-weight: 600;
margin: 0; margin: 0;
white-space: nowrap; white-space: nowrap;
letter-spacing: 0.5px;
transition: opacity 0.2s ease;
} }
.sidebar-toggle { .sidebar-toggle {
display: flex; display: flex;
justify-content: flex-end; justify-content: flex-end;
padding: 4px 8px; padding: 8px 12px;
border-bottom: 1px solid #263445; border-bottom: 1px rgba(255, 255, 255, 0.05);
} }
.rotate-icon {
transition: transform 0.3s ease;
}
.rotate-icon:hover {
transform: rotate(180deg);
}
/* 菜单样式 */
.sidebar-menu { .sidebar-menu {
border-right: none; border-right: none;
background-color: #304156; background-color: transparent;
} }
.sidebar-menu:not(.el-menu--collapse) { .sidebar-menu:not(.el-menu--collapse) {
width: 200px; width: 100%;
} }
.sidebar-menu .el-menu-item, .sidebar-menu .el-menu-item,
.sidebar-menu .el-sub-menu__title { .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-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-menu-item.is-active,
.sidebar-menu .el-sub-menu .el-menu-item.is-active { .sidebar-menu .el-sub-menu .el-menu-item.is-active {
background-color: #263445; background-color: var(--sidebar-active-bg);
color: #409eff; 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 { .header {
background-color: #fff; background-color: var(--header-bg);
border-bottom: 1px solid #e6e6e6; border-bottom: 1px solid var(--header-border);
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
padding: 0 20px; padding: 0 24px;
height: 56px; height: 60px;
flex-shrink: 0; flex-shrink: 0;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
} }
.header-left { .header-left {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 8px; gap: 12px;
} }
.header-right { .header-right {
@ -332,7 +550,9 @@ onMounted(() => {
.header h3 { .header h3 {
margin: 0; margin: 0;
color: #303133; color: var(--header-text);
font-size: 18px;
font-weight: 600;
} }
.user-info { .user-info {
@ -340,37 +560,78 @@ onMounted(() => {
align-items: center; align-items: center;
gap: 8px; gap: 8px;
cursor: pointer; 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 { .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 { .main-content {
background-color: #f0f2f5; background-color: var(--main-bg);
padding: 20px; padding: 24px;
height: 100%; height: 100%;
box-sizing: border-box; box-sizing: border-box;
overflow-y: auto; overflow-y: auto;
animation: fade-in 0.4s ease-out;
} }
/* 移动端抽屉 */
.drawer-logo { .drawer-logo {
height: 56px; height: 60px;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
background-color: #2b3a4a; background: linear-gradient(180deg, var(--sidebar-bg) 0%, var(--sidebar-header-bg) 100%);
color: #fff; border-bottom: 1px rgba(255, 255, 255, 0.05);
} }
.drawer-logo h2 { .drawer-logo h2 {
margin: 0; margin: 0;
font-size: 18px; font-size: 18px;
font-weight: 600;
color: #fff; color: #fff;
} }
.drawer-menu { .drawer-menu {
border-right: none; 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 { .desktop-only {
@ -385,7 +646,7 @@ onMounted(() => {
display: none; display: none;
} }
@media (max-width: 900px) { @media (max-width: 768px) {
.desktop-only { .desktop-only {
display: none; display: none;
} }
@ -399,17 +660,17 @@ onMounted(() => {
} }
.header { .header {
padding: 0 12px; padding: 0 16px;
} }
.main-content { .main-content {
padding: 12px; padding: 16px;
} }
} }
.footer { .footer {
background-color: #fff; background-color: var(--footer-bg);
border-top: 1px solid #e6e6e6; border-top: 1px solid var(--footer-border);
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
@ -420,7 +681,7 @@ onMounted(() => {
.footer p { .footer p {
margin: 0; margin: 0;
color: #909399; color: var(--footer-text);
font-size: 12px; font-size: 13px;
} }
</style> </style>