Nuxt实战(3)-Layout布局实现-SideBar

2025年8月27日
约 5 分钟阅读时间
By 麦兜九天 & Tianyang Wang

目录

🎯 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 的完整布局

性能优化

  • 懒加载-菜单项按需渲染
  • 🚀 虚拟滚动-大量菜单项时的性能优化
  • 💨 防抖处理-窗口大小变化的防抖处理
  • 🎯 智能更新-只在必要时更新组件状态

下一步计划-

  1. 实现主内容区域的布局
  2. 添加面包屑导航组件
  3. 完善用户权限控制
  4. 优化移动端用户体验
  5. 添加更多个性化设置