企业级全栈 RBAC 实战 (11):菜单管理与无限层级树形表格

摘要:系统如何管理它自己的菜单?这是一个经典的"先有鸡还是先有蛋"的问题。本文将介绍如何通过数据库初始化解决冷启动问题,并深入讲解如何利用 Element Plus 的树形表格展示无限层级数据,以及如何实现"目录、菜单、按钮"三种类型的动态表单交互。

一、 引言:先有鸡还是先有蛋?

在前面的文章中,我们实现了动态路由,前端的菜单是根据数据库里的 sys_menus 表自动生成的。

现在问题来了:我们想要在页面上通过图形化界面来新增、修改菜单,但是数据库里如果没有"菜单管理"这个页面,我们就进不去这个功能。这就是典型的系统**自举(Bootstrapping)**问题。

本篇的核心任务:

  1. 数据初始化:手动 SQL 插入第一条数据,打破僵局。
  2. 树形表格:不用递归组件,利用 el-table 原生特性展示树数据。
  3. 动态表单:根据是"目录"还是"按钮",动态控制表单项的显示与校验。

二、 数据库与初始化:打破僵局

在开发任何 CRUD 功能之前,先确保数据库结构支持我们的业务需求(如缓存控制、显隐控制)。

. 表结构确认

除了基础的 path 和 component,我们需要重点关注 菜单类型 type:

  • M (Directory) : 目录。有路由地址,无组件路径(通常是 Layout),不显示在内容区,只展开子菜单。
  • C (Menu) : 菜单。有路由,有组件,是真正的页面。
  • F (Button) : 按钮。无路由,无组件,只用于控制页面内的按钮权限(如 system:user:add)。

2. 初始化 SQL (种子数据)

我们需要手动插入"菜单管理"本身,并把它分配给超级管理员。

sql 复制代码
-- 1. 插入菜单管理页面 (作为系统管理的子菜单)
-- 假设系统管理 ID=1
INSERT INTO `sys_menus` 
(`parent_id`, `menu_name`, `path`, `component`, `perms`, `icon`, `type`, `hidden`, `sort`) 
VALUES 
(1, '菜单管理', 'menu', 'system/menu/index', 'system:menu:list', 'list', 'C', 0, 3);

-- 2. 给角色分配权限 (Role ID = 1)
INSERT INTO `sys_role_menus` (`role_id`, `menu_id`) 
VALUES (1, (SELECT id FROM sys_menus WHERE path='menu' LIMIT 1));

执行完这条 SQL,刷新页面,侧边栏就会神奇地出现"菜单管理"了。

三、 后端实现:逻辑校验与树形组装

后端除了常规的 CRUD,主要有两个逻辑点:列表转树删除保护

1. 列表接口 (GET /menu/list)

js 复制代码
// routes/menu.js
router.get('/list', authMiddleware, async (req, res, next) => {
  try {
    const { menuName, status } = req.query
    let sql = 'SELECT * FROM sys_menus WHERE 1=1'
    let params = []

    // ... 拼接查询条件 ...
    
    const [rows] = await pool.query(sql, params)
    
    // 【关键】将扁平数组转换为树形结构
    // 这里的 buildTree 与之前动态路由使用的是同一个辅助函数
    const list = buildTree(JSON.parse(JSON.stringify(rows)))

    res.json({ code: 200, data: list })
  } catch (err) { next(err) }
})

四、 前端实现:树形表格与动态表单

1. 树形表格 (el-table)

Element Plus 的 Table 组件自带树形展示功能,只需配置 row-key 和 tree-props。

vue 复制代码
<el-table
  :data="menuList"
  row-key="id" 
  border
  :tree-props="{ children: 'children', hasChildren: 'hasChildren' }"
>
  <el-table-column prop="menu_name" label="菜单名称" width="160" />
  
  <!-- 动态图标展示 -->
  <el-table-column prop="icon" label="图标" align="center" width="60">
    <template #default="{ row }">
      <el-icon v-if="row.icon">
        <component :is="row.icon" />
      </el-icon>
    </template>
  </el-table-column>
  
  <el-table-column prop="perms" label="权限标识" />
  <el-table-column prop="component" label="组件路径" />
  
  <!-- 状态展示 -->
  <el-table-column prop="hidden" label="状态" width="80">
    <template #default="{ row }">
      <el-tag v-if="row.hidden === 0" type="success">显示</el-tag>
      <el-tag v-else type="info">隐藏</el-tag>
    </template>
  </el-table-column>
</el-table>
  • row-key="id" : 告诉表格每行的唯一标识,这是折叠展开的关键。
  • tree-props: 告诉表格子节点在哪个字段里(默认是 children)。

2. 动态表单交互 (核心亮点)

在"新增/修改"弹窗中,我们需要根据 type 的不同,动态控制表单项的显示/隐藏校验规则

  • 目录 (M) : 需要路由地址,不需要组件路径。
  • 菜单 (C) : 需要路由地址 + 组件路径。
  • 按钮 (F) : 只需要权限字符,不需要路由和组件。
vue 复制代码
<el-form ref="menuFormRef" :model="form" :rules="rules" label-width="100px">
  
  <!-- 上级菜单选择:使用 TreeSelect 组件 -->
  <el-form-item label="上级菜单">
    <el-tree-select
      v-model="form.parentId"
      :data="menuOptions"
      :props="{ value: 'id', label: 'label', children: 'children' }"
      check-strictly
    />
  </el-form-item>

  <!-- 类型切换 -->
  <el-form-item label="菜单类型" prop="menuType">
    <el-radio-group v-model="form.menuType">
      <el-radio value="M">目录</el-radio>
      <el-radio value="C">菜单</el-radio>
      <el-radio value="F">按钮</el-radio>
    </el-radio-group>
  </el-form-item>

  <!-- 动态逻辑 v-if -->
  <el-form-item label="路由地址" prop="path" v-if="form.menuType !== 'F'">
    <el-input v-model="form.path" />
  </el-form-item>

  <el-form-item label="组件路径" prop="component" v-if="form.menuType === 'C'">
    <el-input v-model="form.component" placeholder="views下的路径" />
  </el-form-item>

  <el-form-item label="权限字符" v-if="form.menuType !== 'M'">
    <el-input v-model="form.perms" placeholder="system:user:add" />
  </el-form-item>
  
  <!-- ... 其他通用项 ... -->
</el-form>

3. 构建上级菜单下拉树

在选择"上级菜单"时,我们需要展示一个树形下拉框。这里有个小技巧:我们需要在数据顶层手动添加一个"主类目"节点,否则用户无法将菜单创建为一级菜单。

js 复制代码
const getTreeselect = async () => {
  const res = await listMenu({}); // 复用列表接口
  // 手动构造根节点
  const menu = { id: 0, label: '主类目', children: [] };
  
  // 简单的字段映射 (menu_name -> label)
  menu.children = res.data.map(node => mapTreeSelect(node));
  
  menuOptions.value = [menu];
};

通过本篇,我们完成了 RBAC 系统管理的最后一块拼图------菜单管理

现在,你可以直接在页面上配置新的路由,设置图标,定义权限,无需修改一行代码,刷新页面即可生效。这标志着我们的系统具备了动态扩展能力

但是,我们在菜单里配置了 system:user:add 这种权限标识,它到底有什么用?前端如何利用它来控制按钮的显示和隐藏?

下一篇:《全栈 RBAC 实战 (12):操作日志与按钮级别权限》 ,我们将深入 Vue 3 自定义指令的实现原理,实现细粒度的按钮级权限控制,并顺带搞定操作日志记录。

具备完整功能后台管理系统的代码仓库:

感谢大家的star

前端:前端

后端:后端

相关推荐
鲸落落丶2 小时前
Vue Router路由
前端·javascript·vue.js
阿呜的边城2 小时前
终于还是吃上了react-i18next的细糠
前端·前端框架
米方2 小时前
ElementPlus 穿梭框支持批量穿梭
前端·javascript·vue.js
InkHeart2 小时前
uni-app开发路上的坑
前端·vue.js
还算善良_2 小时前
【Vue】表格实现表头多彩
javascript·vue.js·ecmascript
苏打水com3 小时前
第十二篇:Day34-36 前端工程化进阶——从“单人开发”到“团队协作”(对标职场“大型项目协作”需求)
前端·javascript·css·vue.js·html
钝挫力PROGRAMER4 小时前
Vue中选项式和组合式API的学习
javascript·vue.js
3秒一个大4 小时前
Vue 任务清单开发:数据驱动 vs 传统 DOM 操作
前端·javascript·vue.js
an86950014 小时前
vue自定义组件this.$emit(“refresh“);
前端·javascript·vue.js