后台布局实战:侧边栏 + 顶栏 + 内容区

1. 为什么这个布局值得单独讲

后端开发者写过无数个 @RestController,但第一次被要求"搭个后台管理系统"时通常卡在页面骨架

这些问题不是 CSS 难,是没有一个趁手的"骨架模板"。这篇就从 0 拆出来一个能直接用的三段式后台布局。

Java 对照:类似 Spring Boot 的"三层架构模板"——Controller → Service → Dao 的骨架固定,后面所有业务都在填空。后台布局也是一样,骨架固定后每个页面只管内容区。


2. 目标效果

┌─────────────────────────────────────────────────┐
│  顶栏(固定高度,横贯到顶)                      │
├──────────┬──────────────────────────────────────┤
│          │                                      │
│          │                                      │
│  侧边栏   │       内容区(可滚动)                │
│ (折叠/  │                                      │
│   展开) │                                      │
│          │                                      │
│          │                                      │
└──────────┴──────────────────────────────────────┘

交付标准

  1. 顶栏高度固定(如 56px),始终可见
  2. 侧边栏可折叠(展开 240px,折叠 64px),动画平滑
  3. 内容区独立滚动,顶栏和侧边栏不跟着动
  4. 小于 md 断点(768px)时,侧边栏变成抽屉
  5. 刷新页面能记住折叠状态

3. 骨架:grid 还是 flex

两种都能做,但我推荐 flex,因为后台布局的各区域高度不对称,grid 模板矩阵反而复杂。

3.1 最简骨架

<!-- src/layouts/AdminLayout.vue -->
<template>
  <div class="flex h-screen bg-gray-50 dark:bg-gray-900">
    <!-- 侧边栏 -->
    <aside class="w-60 bg-white dark:bg-gray-800 border-r border-gray-200 dark:border-gray-700">
      侧边栏
    </aside>

    <!-- 右侧:顶栏 + 内容 -->
    <div class="flex flex-1 flex-col min-w-0">
      <header class="h-14 bg-white dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700">
        顶栏
      </header>
      <main class="flex-1 overflow-auto p-6">
        内容区(可滚动)
      </main>
    </div>
  </div>
</template>

关键点

把上面贴进任何 .vue 文件,骨架就立起来了。


4. 侧边栏折叠

折叠只是"宽度变化 + 过渡动画":

<!-- AdminLayout.vue -->
<script setup lang="ts">
import { ref } from 'vue'
import { useStorage } from '@vueuse/core'

// 用 useStorage 持久化,刷新后记住状态(VueUse 工具库笔记里有讲)
const collapsed = useStorage('admin-sidebar-collapsed', false)
</script>

<template>
  <div class="flex h-screen bg-gray-50 dark:bg-gray-900">
    <aside
      :class="[
        'bg-white dark:bg-gray-800 border-r border-gray-200 dark:border-gray-700',
        'transition-[width] duration-200 ease-in-out',
        collapsed ? 'w-16' : 'w-60'
      ]"
    >
      <!-- 侧边栏内容 -->
    </aside>

    <div class="flex flex-1 flex-col min-w-0">
      <header class="flex h-14 items-center gap-3 px-4 bg-white dark:bg-gray-800 border-b">
        <button
          class="p-2 rounded hover:bg-gray-100 dark:hover:bg-gray-700"
          @click="collapsed = !collapsed"
        >
          <!-- 汉堡图标:三条横线 -->
          <el-icon><Fold v-if="!collapsed" /><Expand v-else /></el-icon>
        </button>
        <span class="font-semibold">in4vue 后台</span>
      </header>

      <main class="flex-1 overflow-auto p-6">
        <slot />
      </main>
    </div>
  </div>
</template>

三处细节

  1. transition-[width] duration-200 — 只对 width 属性做过渡,避免所有属性变化都动画(防抖动)
  2. useStorage 来自 VueUse,自动同步 localStorage,刷新页面不会丢
  3. 折叠时宽度用 w-16(64px),刚好放得下一个图标

5. 菜单项:展开时显示文字,折叠时只显示图标

<template>
  <aside :class="[...]">
    <nav class="py-4">
      <router-link
        v-for="item in menuItems"
        :key="item.path"
        :to="item.path"
        class="flex items-center gap-3 px-4 py-2.5 mx-2 rounded-md text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700"
        active-class="bg-blue-50 text-blue-600 dark:bg-blue-900/40 dark:text-blue-300"
      >
        <el-icon class="text-lg shrink-0"><component :is="item.icon" /></el-icon>
        <!-- 折叠时隐藏文字 -->
        <span v-show="!collapsed" class="truncate">{{ item.label }}</span>
      </router-link>
    </nav>
  </aside>
</template>

<script setup lang="ts">
import { Document, Reading, ChatDotRound, Setting } from '@element-plus/icons-vue'

const menuItems = [
  { path: '/notes', label: '笔记管理', icon: Document },
  { path: '/categories', label: '分类', icon: Reading },
  { path: '/ai-logs', label: 'AI 调用日志', icon: ChatDotRound },
  { path: '/settings', label: '设置', icon: Setting },
]
</script>

关键点


6. 响应式:小屏变抽屉

桌面端侧边栏常驻,手机端变成从左滑入的抽屉。思路:断点切换实现方式,而不是同一套硬撑两端

<script setup lang="ts">
import { useStorage, useMediaQuery } from '@vueuse/core'

const isMobile = useMediaQuery('(max-width: 767px)') // md 以下

// 桌面:展开/折叠
const collapsed = useStorage('admin-sidebar-collapsed', false)
// 手机:抽屉开/关
const drawerOpen = ref(false)
</script>

<template>
  <div class="flex h-screen bg-gray-50 dark:bg-gray-900">
    <!-- 桌面侧边栏 -->
    <aside
      v-if="!isMobile"
      :class="[
        'bg-white dark:bg-gray-800 border-r transition-[width] duration-200',
        collapsed ? 'w-16' : 'w-60'
      ]"
    >
      <SidebarMenu :collapsed="collapsed" />
    </aside>

    <!-- 手机抽屉:用 Element Plus 的 el-drawer -->
    <el-drawer
      v-else
      v-model="drawerOpen"
      direction="ltr"
      :with-header="false"
      size="240px"
    >
      <SidebarMenu :collapsed="false" />
    </el-drawer>

    <!-- 右侧 -->
    <div class="flex flex-1 flex-col min-w-0">
      <header class="flex h-14 items-center gap-3 px-4 bg-white dark:bg-gray-800 border-b">
        <button
          class="p-2 rounded hover:bg-gray-100"
          @click="isMobile ? (drawerOpen = true) : (collapsed = !collapsed)"
        >
          <el-icon><Expand /></el-icon>
        </button>
        <span class="font-semibold">in4vue 后台</span>
      </header>
      <main class="flex-1 overflow-auto p-4 md:p-6">
        <slot />
      </main>
    </div>
  </div>
</template>

三处细节

  1. useMediaQuery 响应式地监听屏幕尺寸,切换断点时自动重渲染
  2. SidebarMenu 抽成独立组件,桌面和抽屉共用菜单定义,避免重复
  3. 抽屉用 Element Plus 现成的,不自己手搓——和 TailwindCSS 笔记 §8 的分工原则一致

7. 和 Vue Router 嵌套路由的配合

后台通常是"多个页面共用一个布局"。路由配置:

// src/router/index.ts
const routes = [
  {
    path: '/',
    component: () => import('@/layouts/AdminLayout.vue'),
    children: [
      { path: '', redirect: '/notes' },
      { path: 'notes', component: () => import('@/pages/NoteList.vue') },
      { path: 'notes/:id', component: () => import('@/pages/NoteDetail.vue') },
      { path: 'categories', component: () => import('@/pages/Categories.vue') },
      { path: 'settings', component: () => import('@/pages/Settings.vue') },
    ],
  },
  { path: '/login', component: () => import('@/pages/Login.vue') }, // 独立布局
]

布局组件里用 <router-view> 占位:

<main class="flex-1 overflow-auto p-6">
  <router-view />
</main>

Java 对照:类似 Spring 的 @ControllerAdvice + 模板方法——父容器统一处理共同部分(导航、认证校验),子路由只关心自己的业务。

登录页不要布局:把 /login 放外层(不嵌在 AdminLayout 下),它会用自己的全屏居中样式——类似 @RequestMapping 有的不走 filter chain。


8. 面包屑:根据路由自动生成

<!-- AdminLayout.vue 顶栏里 -->
<script setup lang="ts">
import { useRoute } from 'vue-router'
import { computed } from 'vue'

const route = useRoute()

// 路由 meta 里放 title,面包屑自动组装
const breadcrumbs = computed(() => {
  return route.matched
    .filter(r => r.meta?.title)
    .map(r => ({ title: r.meta.title as string, path: r.path }))
})
</script>

<template>
  <header class="flex h-14 items-center gap-4 px-4 bg-white border-b">
    <button @click="...">...</button>

    <el-breadcrumb separator="/" class="text-sm">
      <el-breadcrumb-item
        v-for="(item, idx) in breadcrumbs"
        :key="item.path"
        :to="idx < breadcrumbs.length - 1 ? item.path : undefined"
      >
        {{ item.title }}
      </el-breadcrumb-item>
    </el-breadcrumb>
  </header>
</template>

路由里加 meta.title

{
  path: 'notes',
  component: () => import('@/pages/NoteList.vue'),
  meta: { title: '笔记管理' },
  children: [
    {
      path: ':id',
      component: () => import('@/pages/NoteDetail.vue'),
      meta: { title: '详情' },
    },
  ],
}

访问 /notes/123 时面包屑自动变成:笔记管理 / 详情

Java 对照:类似 Spring MVC 的 HandlerInterceptor 在请求处理前收集路径信息——route.matched 就是这条请求链上匹配到的所有路由层级。


9. 常见坑点与排查思路

9.1 内容区不能滚动

现象:页面长内容时整个窗口跟着滚,不是内容区独立滚。 原因:漏了某层的 overflow-auto,或者父级没给明确高度。 排查:从最外层往里看,每层必须满足"有明确高度 + 子元素能溢出"。

9.2 侧边栏高度溢出顶栏

现象:侧边栏盖住顶栏,或者顶栏底下露一条白边。 原因:侧边栏放在顶栏外面(并排),但自己撑满了 h-screen 而不是"屏幕高度 - 顶栏高度"。 解法:用第 3 节的"外层 flex(水平)→ 右侧 flex-col(顶栏+内容)"结构,侧边栏自然在顶栏左边。

9.3 折叠动画抖动

现象:折叠/展开时菜单文字跟着抽搐。 原因transition-all 把所有属性都过渡了,包括 opacitycolor 等。 解法:只对 width 做过渡,transition-[width] duration-200

9.4 Element Plus 组件样式被 Tailwind 的 preflight 重置

现象<el-button> 看起来没样式或样式怪。 原因:Tailwind 的 preflight 重置了浏览器默认样式,某些情况下覆盖 Element 的基础样式。 解法:保证 main.ts 里 Element Plus 的样式在 style.css(含 Tailwind)之后引入。


10. 完整示例清单

看完这篇,你可以在 in4vue 里这样组织:

src/
├── layouts/
│   ├── AdminLayout.vue        ← 本篇产出
│   └── BlankLayout.vue        ← 登录页用,什么都不包
├── components/
│   └── admin/
│       ├── SidebarMenu.vue    ← 菜单抽出来,抽屉和桌面共用
│       └── Breadcrumb.vue     ← 面包屑
├── pages/
│   ├── admin/
│   │   ├── NoteList.vue
│   │   ├── Categories.vue
│   │   └── Settings.vue
│   └── Login.vue
└── router/
    └── index.ts               ← 嵌套路由

小练习

  1. 按本篇搭一个 AdminLayout,接入 in4vue 现有的笔记列表页
  2. 加一个"用户菜单":顶栏右侧头像,点击后弹出下拉菜单(用 el-dropdown
  3. 在折叠状态下给菜单项加 tooltip:el-tooltip 包住菜单项,鼠标悬停时显示文字
  4. 加一个"深色模式切换"按钮到顶栏(切换 <html>.dark 类)
  5. 手动把浏览器宽度从 1200px 拖到 400px,观察抽屉切换是否平滑

延伸阅读