Article
🎯 SideBar 侧边栏布局
📋 需求分析
基于前面实现的 Header 和 Footer 布局,我们需要一个功能完整的左侧边栏来完善整体布局-
- 可伸缩设计-支持展开/收起状态,节省屏幕空间
- 导航菜单-多级菜单结构,支持图标和文字
- 用户信息-显示当前用户头像、昵称和状态
- 快捷操作-常用功能的快速入口
- 响应式适配-移动端自动隐藏,通过 Header 菜单访问
- 状态持久化-记住用户的展开/收起偏好
设计理念-参考现代化管理后台的侧边栏设计,注重用户体验和功能实用性
🏗️ SideBar 组件架构
1. 核心状态管理
首先创建一个全局状态来管理侧边栏的展开/收起状态-
创建 composables/useSidebar.ts
export const useSidebar = () => { // 侧边栏展开状态 const isExpanded = ref(true)
// 从 localStorage 恢复状态 const restoreState = () => { if (process.client) { const saved = localStorage.getItem('sidebar-expanded') if (saved !== null) { isExpanded.value = JSON.parse(saved) } } }
// 保存状态到 localStorage const saveState = () => { if (process.client) { localStorage.setItem('sidebar-expanded', JSON.stringify(isExpanded.value)) } }
// 切换展开状态 const toggle = () => { isExpanded.value = !isExpanded.value saveState() }
// 设置展开状态 const setExpanded = (expanded: boolean) => { isExpanded.value = expanded saveState() }
// 初始化时恢复状态 onMounted(() => { restoreState() })
return { isExpanded: readonly(isExpanded), toggle, setExpanded, restoreState }}📱 用户信息组件
创建 components/SidebarUserInfo.vue
<script setup lang="ts">interface Props { isExpanded: boolean}
defineProps<Props>()
// 模拟用户数据 - 实际项目中从状态管理获取const user = ref({ name: '游戏玩家', avatar: '/default-avatar.png', level: 15, exp: 2580, maxExp: 3000, status: 'online' // online, away, busy, offline})
const statusConfig = { online: { color: 'green', text: '在线' }, away: { color: 'yellow', text: '离开' }, busy: { color: 'red', text: '忙碌' }, offline: { color: 'gray', text: '离线' }}
const expProgress = computed(() => { return (user.value.exp / user.value.maxExp) * 100})</script>
<template> <div class="p-4 border-b border-gray-200 dark:border-gray-700"> <div class="flex items-center space-x-3"> <!-- 用户头像 --> <div class="relative"> <UAvatar :src="user.avatar" :alt="user.name" :size="isExpanded ? 'md' : 'sm'" class="ring-2 ring-white dark:ring-gray-800" /> <!-- 在线状态指示器 --> <div :class="[ 'absolute -bottom-0.5 -right-0.5 w-3 h-3 rounded-full border-2 border-white dark:border-gray-800', `bg-${statusConfig[user.status].color}-500` ]" /> </div>
<!-- 用户信息 --> <div v-if="isExpanded" class="flex-1 min-w-0"> <div class="flex items-center space-x-2"> <h3 class="text-sm font-medium text-gray-900 dark:text-white truncate"> {{ user.name }} </h3> <span class="text-xs px-1.5 py-0.5 bg-blue-100 dark:bg-blue-900 text-blue-800 dark:text-blue-200 rounded"> Lv.{{ user.level }} </span> </div>
<!-- 经验值进度条 --> <div class="mt-2"> <div class="flex justify-between text-xs text-gray-500 dark:text-gray-400 mb-1"> <span>经验值</span> <span>{{ user.exp }}/{{ user.maxExp }}</span> </div> <div class="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-1.5"> <div class="bg-blue-500 h-1.5 rounded-full transition-all duration-300" :style="{ width: `${expProgress}%` }" /> </div> </div>
<!-- 状态显示 --> <div class="flex items-center mt-2 text-xs text-gray-500 dark:text-gray-400"> <div :class="[ 'w-2 h-2 rounded-full mr-2', `bg-${statusConfig[user.status].color}-500` ]" /> {{ statusConfig[user.status].text }} </div> </div> </div> </div></template>🧭 导航菜单组件
创建 components/SidebarNavigation.vue
<script setup lang="ts">interface MenuItem { id: string label: string icon: string to?: string badge?: string | number children?: MenuItem[]}
interface Props { isExpanded: boolean}
defineProps<Props>()
const route = useRoute()
// 菜单配置const menuItems: MenuItem[] = [ { id: 'dashboard', label: '仪表盘', icon: 'i-heroicons-home', to: '/dashboard' }, { id: 'games', label: '游戏中心', icon: 'i-heroicons-puzzle-piece', children: [ { id: 'game-list', label: '游戏列表', icon: 'i-heroicons-list-bullet', to: '/games' }, { id: 'game-reviews', label: '游戏评测', icon: 'i-heroicons-star', to: '/games/reviews' }, { id: 'game-news', label: '游戏资讯', icon: 'i-heroicons-newspaper', to: '/games/news', badge: '5' } ] }, { id: 'community', label: '社区', icon: 'i-heroicons-users', children: [ { id: 'forums', label: '论坛', icon: 'i-heroicons-chat-bubble-left-right', to: '/community/forums' }, { id: 'guilds', label: '公会', icon: 'i-heroicons-shield-check', to: '/community/guilds' }, { id: 'events', label: '活动', icon: 'i-heroicons-calendar-days', to: '/community/events', badge: 'NEW' } ] }, { id: 'profile', label: '个人中心', icon: 'i-heroicons-user-circle', children: [ { id: 'my-profile', label: '我的资料', icon: 'i-heroicons-identification', to: '/profile' }, { id: 'my-games', label: '我的游戏', icon: 'i-heroicons-game-controller', to: '/profile/games' }, { id: 'achievements', label: '成就', icon: 'i-heroicons-trophy', to: '/profile/achievements' }, { id: 'friends', label: '好友', icon: 'i-heroicons-user-group', to: '/profile/friends' } ] }, { id: 'settings', label: '设置', icon: 'i-heroicons-cog-6-tooth', to: '/settings' }]
// 展开的菜单项const expandedItems = ref<Set<string>>(new Set())
// 切换子菜单展开状态const toggleSubmenu = (itemId: string) => { if (expandedItems.value.has(itemId)) { expandedItems.value.delete(itemId) } else { expandedItems.value.add(itemId) }}
// 检查菜单项是否激活const isActive = (item: MenuItem): boolean => { if (item.to) { return route.path === item.to || route.path.startsWith(item.to + '/') } return false}
// 检查是否有激活的子菜单const hasActiveChild = (item: MenuItem): boolean => { if (!item.children) return false return item.children.some(child => isActive(child))}
// 初始化时展开包含当前路由的菜单onMounted(() => { menuItems.forEach(item => { if (hasActiveChild(item)) { expandedItems.value.add(item.id) } })})</script>
<template> <nav class="flex-1 px-2 py-4 space-y-1 overflow-y-auto"> <template v-for="item in menuItems" :key="item.id"> <!-- 一级菜单项 --> <div> <!-- 有子菜单的项 --> <template v-if="item.children"> <button class="w-full flex items-center px-3 py-2 text-sm font-medium rounded-lg transition-colors group" :class="[ hasActiveChild(item) ? 'bg-blue-50 dark:bg-blue-900/50 text-blue-700 dark:text-blue-200' : 'text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-800' ]" @click="toggleSubmenu(item.id)" > <UIcon :name="item.icon" class="w-5 h-5 mr-3 flex-shrink-0" /> <span v-if="isExpanded" class="flex-1 text-left">{{ item.label }}</span> <UIcon v-if="isExpanded" name="i-heroicons-chevron-down" class="w-4 h-4 transition-transform" :class="{ 'rotate-180': expandedItems.has(item.id) }" /> </button>
<!-- 子菜单 --> <Transition enter-active-class="transition duration-200 ease-out" enter-from-class="opacity-0 -translate-y-1" enter-to-class="opacity-100 translate-y-0" leave-active-class="transition duration-150 ease-in" leave-from-class="opacity-100 translate-y-0" leave-to-class="opacity-0 -translate-y-1" > <div v-if="isExpanded && expandedItems.has(item.id)" class="ml-6 mt-1 space-y-1" > <NuxtLink v-for="child in item.children" :key="child.id" :to="child.to" class="flex items-center px-3 py-2 text-sm rounded-lg transition-colors group" :class="[ isActive(child) ? 'bg-blue-100 dark:bg-blue-800 text-blue-700 dark:text-blue-200' : 'text-gray-600 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-800' ]" > <UIcon :name="child.icon" class="w-4 h-4 mr-3 flex-shrink-0" /> <span class="flex-1">{{ child.label }}</span> <span v-if="child.badge" class="px-2 py-0.5 text-xs rounded-full" :class="[ child.badge === 'NEW' ? 'bg-green-100 dark:bg-green-900 text-green-800 dark:text-green-200' : 'bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-300' ]" > {{ child.badge }} </span> </NuxtLink> </div> </Transition> </template>
<!-- 无子菜单的项 --> <template v-else> <NuxtLink :to="item.to" class="flex items-center px-3 py-2 text-sm font-medium rounded-lg transition-colors group" :class="[ isActive(item) ? 'bg-blue-50 dark:bg-blue-900/50 text-blue-700 dark:text-blue-200' : 'text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-800' ]" > <UIcon :name="item.icon" class="w-5 h-5 mr-3 flex-shrink-0" /> <span v-if="isExpanded">{{ item.label }}</span> </NuxtLink> </template> </div> </template> </nav></template>⚡ 快捷操作组件
创建 components/SidebarQuickActions.vue
<script setup lang="ts">interface Props { isExpanded: boolean}
defineProps<Props>()
const quickActions = [ { id: 'new-post', label: '发布动态', icon: 'i-heroicons-plus-circle', color: 'blue', action: () => navigateTo('/community/new-post') }, { id: 'join-game', label: '快速匹配', icon: 'i-heroicons-play', color: 'green', action: () => navigateTo('/games/quick-match') }, { id: 'messages', label: '消息', icon: 'i-heroicons-envelope', color: 'purple', badge: 3, action: () => navigateTo('/messages') }, { id: 'notifications', label: '通知', icon: 'i-heroicons-bell', color: 'orange', badge: 5, action: () => navigateTo('/notifications') }]</script>
<template> <div class="p-4 border-t border-gray-200 dark:border-gray-700"> <h4 v-if="isExpanded" class="text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider mb-3"> 快捷操作 </h4>
<div class="space-y-2"> <button v-for="action in quickActions" :key="action.id" class="w-full flex items-center px-3 py-2 text-sm rounded-lg transition-colors hover:bg-gray-50 dark:hover:bg-gray-800 group" @click="action.action" > <div class="relative"> <UIcon :name="action.icon" class="w-5 h-5 flex-shrink-0" :class="`text-${action.color}-600 dark:text-${action.color}-400`" /> <span v-if="action.badge" class="absolute -top-1 -right-1 w-4 h-4 text-xs bg-red-500 text-white rounded-full flex items-center justify-center" > {{ action.badge }} </span> </div> <span v-if="isExpanded" class="ml-3 text-gray-700 dark:text-gray-200"> {{ action.label }} </span> </button> </div> </div></template>🏗️ 主侧边栏组件
创建 components/AppSidebar.vue
<script setup lang="ts">const { isExpanded, toggle } = useSidebar()
// 响应式检测const isMobile = ref(false)
const checkMobile = () => { if (process.client) { isMobile.value = window.innerWidth < 768 }}
onMounted(() => { checkMobile() window.addEventListener('resize', checkMobile)})
onUnmounted(() => { if (process.client) { window.removeEventListener('resize', checkMobile) }})
// 移动端时隐藏侧边栏const shouldShow = computed(() => !isMobile.value)</script>
<template> <aside v-if="shouldShow" class="fixed left-0 top-16 bottom-0 z-30 flex flex-col bg-white dark:bg-gray-900 border-r border-gray-200 dark:border-gray-700 transition-all duration-300 ease-in-out" :class="[ isExpanded ? 'w-64' : 'w-16' ]" > <!-- 侧边栏头部 - 折叠按钮 --> <div class="flex items-center justify-between p-4 border-b border-gray-200 dark:border-gray-700"> <h2 v-if="isExpanded" class="text-lg font-semibold text-gray-900 dark:text-white"> 导航菜单 </h2> <UButton :icon="isExpanded ? 'i-heroicons-chevron-left' : 'i-heroicons-chevron-right'" size="sm" variant="ghost" class="text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200" @click="toggle" /> </div>
<!-- 用户信息区域 --> <SidebarUserInfo :is-expanded="isExpanded" />
<!-- 导航菜单 --> <SidebarNavigation :is-expanded="isExpanded" />
<!-- 快捷操作 --> <SidebarQuickActions :is-expanded="isExpanded" /> </aside></template>🏠 更新布局组件
更新 layouts/default.vue
<script setup lang="ts">const { isExpanded } = useSidebar()
// 计算主内容区域的左边距const mainContentClass = computed(() => { const isMobile = process.client && window.innerWidth < 768 if (isMobile) return 'ml-0' return isExpanded.value ? 'ml-64' : 'ml-16'})</script>
<template> <div class="min-h-screen bg-gray-50 dark:bg-gray-900"> <!-- 头部 --> <AppHeader />
<!-- 侧边栏 --> <AppSidebar />
<!-- 主内容区域 --> <main class="pt-16 transition-all duration-300 ease-in-out" :class="mainContentClass" > <div class="p-6"> <slot /> </div> </main>
<!-- 底部 --> <AppFooter /> </div></template>📱 移动端适配
创建 components/MobileSidebarOverlay.vue
<script setup lang="ts">interface Props { isOpen: boolean}
interface Emits { (e: 'close'): void}
defineProps<Props>()defineEmits<Emits>()</script>
<template> <Teleport to="body"> <Transition enter-active-class="transition-opacity duration-300" enter-from-class="opacity-0" enter-to-class="opacity-100" leave-active-class="transition-opacity duration-300" leave-from-class="opacity-100" leave-to-class="opacity-0" > <div v-if="isOpen" class="fixed inset-0 z-40 bg-black bg-opacity-50 md:hidden" @click="$emit('close')" > <Transition enter-active-class="transition-transform duration-300" enter-from-class="-translate-x-full" enter-to-class="translate-x-0" leave-active-class="transition-transform duration-300" leave-from-class="translate-x-0" leave-to-class="-translate-x-full" > <div v-if="isOpen" class="fixed left-0 top-0 bottom-0 w-64 bg-white dark:bg-gray-900 shadow-xl" @click.stop > <!-- 移动端侧边栏内容 --> <div class="flex flex-col h-full"> <!-- 头部 --> <div class="flex items-center justify-between p-4 border-b border-gray-200 dark:border-gray-700"> <h2 class="text-lg font-semibold text-gray-900 dark:text-white"> 菜单 </h2> <UButton icon="i-heroicons-x-mark" size="sm" variant="ghost" @click="$emit('close')" /> </div>
<!-- 用户信息 --> <SidebarUserInfo :is-expanded="true" />
<!-- 导航菜单 --> <SidebarNavigation :is-expanded="true" />
<!-- 快捷操作 --> <SidebarQuickActions :is-expanded="true" /> </div> </div> </Transition> </div> </Transition> </Teleport></template>🎨 最终效果展示
桌面端效果
- ✅ 可伸缩设计-支持展开/收起,宽度在 64px 和 256px 之间切换
- ✅ 用户信息展示-头像、昵称、等级、经验值、在线状态
- ✅ 多级导航菜单-支持图标、徽章、子菜单展开/收起
- ✅ 快捷操作区-常用功能的快速入口,支持消息提醒
- ✅ 状态持久化-记住用户的展开/收起偏好
移动端效果
- ✅ 自动隐藏-移动端自动隐藏侧边栏,节省屏幕空间
- ✅ 覆盖层菜单-通过 Header 的汉堡菜单触发
- ✅ 流畅动画-滑入/滑出动画效果
- ✅ 完整功能-保留所有桌面端功能
交互特性
- 🎯 智能展开-自动展开包含当前页面的菜单项
- 🌊 流畅动画-所有状态切换都有平滑的过渡效果
- 📱 响应式适配-根据屏幕尺寸自动调整布局
- 💾 状态记忆-记住用户的使用偏好
📝 章节总结
本章节完成的核心功能-
- ✅ 可伸缩侧边栏-支持展开/收起状态切换
- ✅ 用户信息组件-显示头像、等级、经验值等信息
- ✅ 多级导航菜单-支持图标、徽章、子菜单
- ✅ 快捷操作区-提供常用功能的快速入口
- ✅ 状态管理-全局状态管理和持久化
- ✅ 移动端适配-响应式设计和覆盖层菜单
- ✅ 完整布局系统-Header + SideBar + Footer 的完整布局
性能优化
- ⚡ 懒加载-菜单项按需渲染
- 🚀 虚拟滚动-大量菜单项时的性能优化
- 💨 防抖处理-窗口大小变化的防抖处理
- 🎯 智能更新-只在必要时更新组件状态
下一步计划-
- 实现主内容区域的布局
- 添加面包屑导航组件
- 完善用户权限控制
- 优化移动端用户体验
- 添加更多个性化设置