Vue3 还原一个企业级后台-06-主布局设计

主布局:顶栏 + 侧栏的工程化设计

后台系统的"骨架"搭得好不好,决定了未来 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 个

组件的边界 = 它被删除的独立理由。 这是我在项目里最常用的一条拆分原则。


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 本项目的选择

不做复杂响应式,但保证不破版。

具体做法:

  1. 侧栏宽度固定 200px,不做折叠/缩放
  2. 内容区 flex: 1,自适应剩余宽度
  3. 表格列宽用百分比,不用固定 px
  4. 最小宽度设 min-width: 1280px,低于此宽度出现横向滚动条
scss 复制代码
.main-layout {
  min-width: 1280px;
  height: 100vh;
  background-color: var(--color-bg-page);
}

这不是偷懒,是务实。原型的首要目标是在目标环境(1920×1080 的会议室投屏)里完美展示。把精力花在响应式上,不如把精力花在像素级还原和交互细节上。


八、小结:布局是"约定",不是"界面"

回过头看,布局设计本质上是在定义一套约定:

  • 顶栏、侧栏、内容区的位置是约定
  • 菜单项和路由的映射是约定
  • 选中态由 meta.module 决定是约定
  • 面包屑在每个页面里写是约定

这些约定一旦定下来,后续 7 个业务页面(API 注册管理、模型汇聚、模型标准发布......)的开发就变成了"填空"------把内容填进这些约定好的位置里。

一个好的布局,不是因为它"好看",而是因为它"让后续开发不需要思考布局本身"。


上一篇:05 - 工程脚手架:5 分钟搭出标准项目结构

下一篇预告:布局就绪,接下来该砌"积木"了------通用组件(分页器、表格、对话框)的封装,如何用一种 API 驱动三个业务模块?

相关推荐
美团技术团队2 小时前
KDD'26 美团学术论文精选及KDD Cup'26 DataAgents赛道冠军思路解读
人工智能
程序员cxuan2 小时前
Anthropic:session 之间可以相互通信了
人工智能·后端·程序员
GEOshijie1233 小时前
GEO服务商算法适配承诺怎么验收?48小时响应的合同化考核方案
人工智能·python
wordbaby3 小时前
大模型 API 核心概念笔记
人工智能
hrrrrxeeeee3 小时前
业务岗简历 AI 加分指南:CAIE、国考证书搭配量化案例写法
人工智能
yingyuecom3 小时前
Hi,导演:AI视频创作正在从“模型调用”走向“生产工作流”
大数据·人工智能
雨晨源码(同名B站)3 小时前
基于深度学习YoloV11农业病害虫害检测系统 智慧农业信息化综合管理平台 (附源码+lw文档+ppt)
数据库·人工智能·深度学习·yolo·信息可视化
weixin_446260853 小时前
列表式交叉编码器微调 vs 智能体指令优化大模型重排器:医保医疗流程重排序系统性研究
人工智能
liuyunshengsir4 小时前
从 TVM 到 TileLang:一文读懂深度学习编译器为什么走向 Tile 化
人工智能·深度学习·tvm·tilelang