主布局:顶栏 + 侧栏的工程化设计
后台系统的"骨架"搭得好不好,决定了未来 10 个页面的开发效率。脚手架把房子盖起来了,现在该砌墙和装门了------主布局就是这堵墙和这扇门。
一、后台布局的通用模式
打开任意一个企业级后台------Element Plus Admin、Ant Design Pro、Vue Admin------你会发现它们的布局结构出奇一致:
┌──────────────────────────────────────────┐
│ 顶栏(TopBar): Logo + 用户 + 通知 │
├────────┬─────────────────────────────────┤
│ 侧栏 │ 内容区(Content Area) │
│ │ 面包屑 + 页面主体 │
│ 菜单1 │ │
│ 菜单2 │ │
│ 菜单3 │ │
│ │ │
└────────┴─────────────────────────────────┘
这不是巧合。后台系统天然需要这些功能:
| 区域 | 核心功能 | 用户需求 |
|---|---|---|
| 顶栏 | Logo、用户头像、通知、退出 | "我是谁、有没有新消息" |
| 侧栏 | 一级菜单 + 二级菜单、模块切换 | "我要去哪、现在在哪" |
| 内容区 | 面包屑 + 页面主体 | "我在哪、我该做什么" |
"管网模型工具"的 Figma 设计稿也是这个经典结构。设计稿里顶栏高度 120px、侧栏宽度 200px、内容区卡片化------这三个数字,就是我们要在代码里精确还原的目标。
但还原不等于照搬。布局不是一块大组件,拆得不好,代码会迅速失控。
二、布局组件拆分:粒度决定可维护性
2.1 拆分前的直觉陷阱
新手最容易犯的错误是把整个布局写在一个 MainLayout.vue 里------顶栏、侧栏、内容区全部塞进一个 template,每个子区域的样式全靠 scoped CSS 里写死。
结果呢?改一个顶栏的圆角要翻 500 行代码,侧栏的菜单逻辑和路由混在一起,加一个通知红点要改三个地方。
2.2 本项目的拆分方案
我把布局拆成了 4 个文件:
src/layout/
├── MainLayout.vue # 布局骨架(仅负责三区排列)
├── TopBar.vue # 顶栏
├── SideNav.vue # 侧边导航
├── UserMenu.vue # 用户下拉菜单
└── MessageBell.vue # 通知铃铛(带红点)
MainLayout.vue 只做一件事:用 CSS Grid 把页面分成三块------顶栏 + 左栏 + 右栏,然后把三个子组件放进去:
vue
<!-- MainLayout.vue -->
<template>
<div class="main-layout">
<TopBar class="layout-topbar" />
<div class="layout-body">
<SideNav class="layout-sidenav" />
<main class="layout-content">
<router-view />
</main>
</div>
</div>
</template>
<script setup>
import TopBar from './TopBar.vue'
import SideNav from './SideNav.vue'
</script>
MainLayout.vue 不知道顶栏里有什么、侧栏有哪些菜单、菜单怎么高亮。它只负责"骨头",不管"肉"。
2.3 拆分原则:一个组件一个理由
每次纠结"这个逻辑该放哪"时,问自己一句:这个组件被删除的理由有几个?
- 删除顶栏的理由:1 个(不需要顶栏了)
- 删除侧栏的理由:1 个(不需要侧栏了)
- 删除用户菜单的理由:1 个(用户需求变了)
- 把顶栏和侧栏的逻辑混在一起的理由:0 个
组件的边界 = 它被删除的独立理由。 这是我在项目里最常用的一条拆分原则。
三、顶栏设计:不只是放个 Logo
3.1 从 Figma 到 CSS
Figma 中顶栏的设计参数:
| 属性 | 值 | 说明 |
|---|---|---|
| 高度 | 120px | 设计稿精确值 |
| 圆角 | 30px 30px 0 0 |
仅顶部两侧圆角 |
| 背景色 | #F0F5FC |
浅蓝底,与页面背景 #C8E1FF 形成层次 |
| 内容 | Logo(左)+ 通知铃铛(中右)+ Admin 头像(右) | 三段式布局 |
在代码里,顶栏就是一段 flex 排布:
vue
<!-- TopBar.vue -->
<template>
<header class="topbar">
<div class="topbar-left">
<img src="@/assets/logo.svg" class="topbar-logo" alt="管网模型工具" />
</div>
<div class="topbar-right">
<MessageBell />
<UserMenu />
</div>
</header>
</template>
<script setup>
import MessageBell from './MessageBell.vue'
import UserMenu from './UserMenu.vue'
</script>
<style lang="scss" scoped>
.topbar {
height: 120px;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 32px;
background-color: #F0F5FC;
border-radius: 30px 30px 0 0;
&-left {
display: flex;
align-items: center;
}
&-logo {
height: 40px;
}
&-right {
display: flex;
align-items: center;
gap: 24px;
}
}
</style>
几个值得注意的点:
为什么圆角设成 30px 30px 0 0? 因为它是主布局的顶部边界,上面是浏览器窗口边缘,不需要圆角;但顶部和侧边栏的衔接处、以及左右两侧需要圆角来形成"卡片化"的视觉效果。这是一种常见的后台设计手法------让整体看起来像一张浮在背景上的卡片。
为什么背景是 #F0F5FC 而不是纯白? 为了和页面背景 #C8E1FF 形成视觉层次。如果顶栏也是纯白,整个页面会"糊"在一起;浅蓝底色让顶栏成了页面的"视觉锚点"。
3.2 用户菜单和通知铃铛
UserMenu.vue 很简单------一个头像图片 + 点击展开的下拉菜单(个人信息、退出登录):
vue
<!-- UserMenu.vue -->
<template>
<el-dropdown trigger="click" @command="handleCommand">
<img src="@/assets/admin-avatar.png" class="user-avatar" alt="Admin" />
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="profile">个人信息</el-dropdown-item>
<el-dropdown-item command="logout" divided>退出登录</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</template>
<script setup>
import { useRouter } from 'vue-router'
const router = useRouter()
function handleCommand(cmd) {
if (cmd === 'logout') {
router.push('/login')
}
}
</script>
MessageBell.vue 更简单------一个铃铛图标 + 条件显示的红点:
vue
<template>
<el-badge :value="unreadCount" :hidden="unreadCount === 0" :max="99">
<el-icon :size="20"><Bell /></el-icon>
</el-badge>
</template>
<script setup>
import { ref } from 'vue'
const unreadCount = ref(3) // TODO: 从 store 读取
</script>
这两个组件虽然小,但独立出来之后,顶栏的 template 只有 4 行------未来加功能(比如全局搜索框、快捷操作按钮),改顶栏模板就行,不影响菜单和通知的逻辑。
四、侧栏设计:别让菜单"活"在路由外面
4.1 设计参数
| 属性 | 值 |
|---|---|
| 宽度 | 200px |
| 一级菜单 | 5 个(API 注册管理 / 模型汇聚 / 模型标准发布 / 系统设置 / 帮助) |
| 选中态 | 主色 #0073FB 背景 + 阴影 |
| 未选中态 | 白色背景,文字 #333 |
4.2 从硬编码到配置驱动
侧栏最直观的写法是把每个菜单项硬编码在 template 里:
vue
<!-- ❌ 不推荐:硬编码 -->
<el-menu>
<el-menu-item index="1">API 注册管理</el-menu-item>
<el-menu-item index="2">模型汇聚</el-menu-item>
<el-menu-item index="3">模型标准发布</el-menu-item>
</el-menu>
这能工作------但加一个菜单改模板,减一个菜单改模板,菜单多了 template 越来越长。而且路由和菜单是强相关的,"去 API 注册管理" = "跳转到 /api-manage/registry",模板里找不到这个映射,你得去翻 router 配置。
更好的做法:配置驱动。 把菜单定义成数据,侧栏组件只负责渲染:
vue
<!-- SideNav.vue -->
<template>
<el-menu
:default-active="activeMenu"
class="sidenav-menu"
router
@select="handleSelect"
>
<el-menu-item
v-for="item in menuItems"
:key="item.path"
:index="item.path"
>
<el-icon><component :is="item.icon" /></el-icon>
<span>{{ item.title }}</span>
</el-menu-item>
</el-menu>
</template>
<script setup>
import { computed } from 'vue'
import { useRoute } from 'vue-router'
const route = useRoute()
const menuItems = [
{ title: 'API 注册管理', path: '/api-manage/registry', icon: 'Document' },
{ title: '模型汇聚', path: '/model-gather/list', icon: 'Collection' },
{ title: '模型标准发布', path: '/model-publish/list', icon: 'Upload' },
{ title: '系统设置', path: '/settings', icon: 'Setting' },
{ title: '帮助', path: '/help', icon: 'QuestionFilled' },
]
const activeMenu = computed(() => {
// 用路由的 meta.module 字段判断当前属于哪个一级菜单
return route.meta.module || route.path
})
</script>
router prop 是 Element Plus Menu 组件的关键配置------传入后,点击菜单项自动触发路由跳转,不需要手动 router.push()。菜单和路由的映射写在了 menuItems 数组里,加一个菜单改一行数据,删一个菜单也删一行数据。
4.3 选中态高亮的工程实现
侧栏的选中态是设计稿里最精细的部分------不是简单的文字变色,而是:
选中态:背景 #0073FB + 白色文字 + box-shadow
未选中态:白色背景 + #333 文字
用 Element Plus 的 el-menu 实现,关键是覆盖它的默认选中样式:
scss
.sidenav-menu {
width: 200px;
height: 100%;
border-right: none;
.el-menu-item {
height: 48px;
line-height: 48px;
margin: 4px 8px;
border-radius: 8px;
color: #333333;
font-size: 14px;
&.is-active {
background-color: #0073FB;
color: #ffffff;
box-shadow: 0 2px 8px rgba(0, 115, 251, 0.3);
}
&:hover:not(.is-active) {
background-color: #E8F1FE;
color: #0073FB;
}
}
}
route.meta.module 是什么?它在路由配置中定义,下一节展开。
五、路由嵌套设计:让布局找到"当前选中菜单"
路由嵌套是"布局 + 路由联动"的核心机制。设计方案如下:
js
// router/index.js
import { createRouter, createWebHashHistory } from 'vue-router'
const routes = [
{
path: '/login',
name: 'Login',
component: () => import('@/views/login/LoginPage.vue'),
meta: { layout: 'blank' } // 登录页不用主布局
},
{
path: '/',
component: () => import('@/layout/MainLayout.vue'),
redirect: '/api-manage/registry',
children: [
// API 注册管理模块
{
path: 'api-manage/registry',
name: 'ApiRegistry',
component: () => import('@/views/api-manage/ApiRegistry.vue'),
meta: { title: 'API 注册管理', module: '/api-manage/registry' }
},
{
path: 'api-manage/detail/:id',
name: 'ApiDetail',
component: () => import('@/views/api-manage/ApiDetail.vue'),
meta: { title: 'API 详情', module: '/api-manage/registry', hidden: true }
},
// 模型汇聚模块
{
path: 'model-gather/list',
name: 'ModelGather',
component: () => import('@/views/model-gather/ModelGather.vue'),
meta: { title: '模型汇聚', module: '/model-gather/list' }
},
// ... 其他模块
]
}
]
const router = createRouter({
history: createWebHashHistory(),
routes
})
export default router
5.1 三个关键设计
(1)children 里的每一个路由,都在 MainLayout 的 <router-view> 中渲染。
这就是路由嵌套的核心:MainLayout 作为"壳",children 是"内容"。"壳"不变,"内容"随路由切换。不需要在每个页面里写顶栏和侧栏。
(2)meta.module 是"当前选中菜单"的判断依据。
侧栏组件里的 activeMenu 计算属性从 route.meta.module 取值。比如你访问 /api-manage/detail/123(API 详情页),meta.module 仍然是 /api-manage/registry,侧栏高亮不会丢。如果把 module 设成当前路径,那详情页就会因为 URL 不匹配而丢失菜单高亮。
(3)详情页的 hidden: true 不代表它不在侧栏里------它本来就不在侧栏里。
hidden 是给后续做面包屑用的标记,表示"这个页面不在侧栏菜单中,但需要在面包屑里展示来源"。
5.2 路由命名规范
本项目用了一套简单的 path 命名规范:
| 类型 | 格式 | 示例 |
|---|---|---|
| 列表页 | {module}/list 或 {module}/registry |
/api-manage/registry |
| 详情页 | {module}/detail/:id |
/api-manage/detail/123 |
| 新增/编辑 | {module}/edit/:id? |
/model-gather/edit/456 |
这种命名方式在 8 个页面后仍能保持清晰------不用翻路由文件也能猜出 URL。
六、内容区设计:卡片化 + 面包屑
内容区是 <router-view> 渲染出来的,但它的外框样式是主布局定义的:
scss
.layout-content {
flex: 1;
padding: 24px;
overflow-y: auto;
}
// 页面级卡片(在具体页面组件中)
.page-card {
background: #ffffff;
border-radius: 14px;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.06);
padding: 24px;
}
面包屑放在每个页面模板里:
vue
<template>
<div class="page-card">
<el-breadcrumb separator=">">
<el-breadcrumb-item :to="{ path: '/' }">首页</el-breadcrumb-item>
<el-breadcrumb-item>API 注册管理</el-breadcrumb-item>
</el-breadcrumb>
<!-- 页面主体 -->
</div>
</template>
为什么面包屑不放 MainLayout 里?因为面包屑的内容依赖具体页面------"API 注册管理"和"模型汇聚"的面包屑不一样。放 MainLayout 里就需要额外维护一个路由到面包屑的映射,增加耦合。
放在页面里虽然有一点重复(每个页面都要写面包屑),但这个重复是"好的重复"------它让每个页面独立可控,改一个页面的面包屑不影响其他页面。
七、响应式策略:做还是不做?
后台系统的响应式是一个典型的"过度工程"陷阱。
7.1 现实情况
- 后台系统的使用场景是公司内网 PC,屏幕至少 1920×1080
- 设计稿是 1920×1080 定稿,没有移动端/平板的设计稿
- 响应式需要额外至少 30% 的 CSS 工作量
7.2 本项目的选择
不做复杂响应式,但保证不破版。
具体做法:
- 侧栏宽度固定 200px,不做折叠/缩放
- 内容区
flex: 1,自适应剩余宽度 - 表格列宽用百分比,不用固定 px
- 最小宽度设
min-width: 1280px,低于此宽度出现横向滚动条
scss
.main-layout {
min-width: 1280px;
height: 100vh;
background-color: var(--color-bg-page);
}
这不是偷懒,是务实。原型的首要目标是在目标环境(1920×1080 的会议室投屏)里完美展示。把精力花在响应式上,不如把精力花在像素级还原和交互细节上。
八、小结:布局是"约定",不是"界面"
回过头看,布局设计本质上是在定义一套约定:
- 顶栏、侧栏、内容区的位置是约定
- 菜单项和路由的映射是约定
- 选中态由
meta.module决定是约定 - 面包屑在每个页面里写是约定
这些约定一旦定下来,后续 7 个业务页面(API 注册管理、模型汇聚、模型标准发布......)的开发就变成了"填空"------把内容填进这些约定好的位置里。
一个好的布局,不是因为它"好看",而是因为它"让后续开发不需要思考布局本身"。
下一篇预告:布局就绪,接下来该砌"积木"了------通用组件(分页器、表格、对话框)的封装,如何用一种 API 驱动三个业务模块?