全栈 RBAC 实战(10)角色管理与权限分配的深度递归逻辑

49 阅读4分钟

摘要:角色(Role)是连接“用户”与“权限”的桥梁。本篇将实现角色管理的核心模块,重点攻克权限分配中的三大难题:树形菜单的加载“半选状态”的回显 Bug、以及数据库事务的数据一致性保障

学习之前先浏览 前置专栏文章

一、 引言:为什么权限分配这么难?

在“用户管理”中,我们只需要处理扁平的数据。但在“角色管理”中,我们点击“分配权限”时,面临的是一颗树(Tree)

  1. 展示难:菜单是无限层级的,需要递归展示。

  2. 回显难(核心坑点)

    • 数据库里存了“父节点 ID”和“子节点 ID”。
    • 但在前端 el-tree 组件中,如果你直接把“父节点 ID”传给它,它会自动把该父节点下的所有子节点都勾选上(全选)。
    • 后果:管理员明明只给了“用户查询”权限(半选),结果回显变成了“用户新增、修改、删除”全都勾上了。
  3. 保存难:保存时,既要保存全选的子节点,也要保存半选的父节点,否则菜单栏显示不出来。

二、 后端实现:事务与递归

我们需要三个接口来支撑这个功能。

1. 获取所有菜单树 (GET /menu/treeselect)

这个接口我们在上一篇已经实现过,它负责返回完整的菜单结构供管理员勾选。
(代码在上一篇 routes/menu.js 中,此处不再赘述)

2. 获取角色已有的权限 (GET /role/roleMenuTreeselect/:roleId)

这个接口需要返回一个树形结构

文件:routes/role.js

function buildTree(items, parentId = 0) {
  const result = []
  for (const item of items) {
    if (item.parent_id == parentId) {
      const children = buildTree(items, item.id)
      if (children.length > 0) {
        item.children = children
      }
      result.push(item)
    }
  }
  return result
}

/**
 * 根据角色id获取菜单ID列表 (用于分配权限时回显)
 */
router.get('/roleMenuTreeselect/:roleId', async (req, res, next) => {
  try {
    const { roleId } = req.params
    // 查询该角色已分配的菜单ID列表
    const sql = `
      SELECT m.id, m.parent_id, m.menu_name 
      FROM sys_menus m
      INNER JOIN sys_role_menus rm ON m.id = rm.menu_id
      WHERE rm.role_id = ?
      ORDER BY m.sort ASC
    `
    const [rows] = await pool.query(sql, [roleId])

    // 2. 深拷贝防止引用问题
    const cleanRows = JSON.parse(JSON.stringify(rows))

    // 3. 构建树形结构
    // 注意:这里构建出来的树,只包含该角色拥有的菜单
    const menuTree = buildTree(cleanRows, 0)
    res.json({
      code: 200,
      message: '获取成功',
      data: menuTree,
    })
  } catch (error) {}
})

3. 保存角色权限 (PUT /role/authorization)

这是一个典型的**事务(Transaction)**操作。因为我们需要先“清空旧权限”,再“插入新权限”。如果中间断电了或报错了,必须回滚,不能让角色变成“没有任何权限”的中间状态。

文件:routes/role.js

// 修改用户的权限 (分配角色)
router.post('/authorization', async (req, res, next) => {
  let connection
  try {
    const { roleId, menuIds } = req.body // menuIds 是一个数字数组 [1, 2, 3]
    if (!roleId) throw new HttpError(400, '角色ID不能为空')
    connection = await pool.getConnection()
    await connection.beginTransaction() // 开始事务
    // 1. 删除该角色已有的菜单权限
    await connection.query('DELETE FROM sys_role_menus WHERE role_id = ?', [
      roleId,
    ])
    // 2. 批量插入新的菜单权限
    if (Array.isArray(menuIds) && menuIds.length > 0) {
      const values = menuIds.map((menuId) => [roleId, menuId]) // 构建二维数组
      await connection.query(
        'INSERT INTO sys_role_menus (role_id, menu_id) VALUES ?',
        [values]
      )
    }
    await connection.commit() // 提交事务
    res.json({
      code: 200,
      message: '权限分配成功',
    })
  } catch (error) {
    // 回滚事务
    if (connection) await connection.rollback()
  } finally {
    if (connection) connection.release() // 释放连接
  }
})

三、 前端实现:攻克“半选”难题

1. 页面布局

我们在角色列表页使用 ElDrawer (抽屉) 来展示权限树,比弹窗体验更好。

文件:views/system/role.vue

  <el-button
  link
  type="success"
  icon="Setting"
  @click="handlePermission(row)"
  >权限</el-button>
  
  
  
  
  const extractIdsFromTree = (treeData: any[]) => {
  let ids: number[] = []
  treeData.forEach((node) => {
    ids.push(node.id)
    if (node.children && node.children.length > 0) {
      ids = ids.concat(extractIdsFromTree(node.children))
    }
  })
  return ids
}

// 权限分配
const handlePermission = async (row: any) => {
  permissionDrawer.roleId = row.id
  permissionDrawer.roleName = row.roleName
  permissionDrawer.visible = true
  permissionDrawer.loading = true

  try {
    const [menuRes, roleMenuRes] = await Promise.all([
      getMenuTreeSelect(), // 获取完整的菜单树 (用于展示)
      getRoleMenuTreeselect(row.id), // 获取角色拥有的菜单树 (用于回显)
    ])

    menuOptions.value = menuRes.data

    // --- 核心修改开始 ---

    // 1. 后端现在返回的是 Tree 结构,我们先把它拍扁成 ID 数组
    // 比如:[{id:1, children:[{id:2}]}]  --->  [1, 2]
    const roleOwnedIds = extractIdsFromTree(roleMenuRes.data)

    console.log('后端返回的树:', roleMenuRes.data) // 你可以在控制台看到嵌套结构
    console.log('拍扁后的ID:', roleOwnedIds)

    // 2. 依然使用 getLeafKeys 过滤掉父节点,只保留叶子节点ID,防止el-tree全选Bug
    const leafKeys = getLeafKeys(menuOptions.value, roleOwnedIds)

    console.log('叶子节点leafKeys:', leafKeys)

    nextTick(() => {
      menuTreeRef.value.setCheckedKeys(leafKeys)
      permissionDrawer.loading = false
    })

    // --- 核心修改结束 ---
  } catch (error) {
    console.error(error)
    permissionDrawer.loading = false
  }
}

// 辅助函数:遍历树,筛选出存在于 checkedKeys 中的叶子节点
const getLeafKeys = (nodes: any[], checkedKeys: number[]) => {
  console.log('nodes', nodes)

  console.log('checkedKeys', checkedKeys)
  let res: number[] = []
  nodes.forEach((node) => {
    if (node.children && node.children.length > 0) {
      console.log('有子节点', node.id)
      // 如果有子节点,递归去找
      res.push(...getLeafKeys(node.children, checkedKeys))
    } else {
      // 如果是叶子节点,且在后端返回的列表中,则加入
      if (checkedKeys.includes(node.id)) {
        console.log('是叶子结点', node.id)

        res.push(node.id)
      }
    }
  })
  return res
}

const submitPermission = async () => {
  permissionDrawer.loading = true
  try {
    // 1. 获取全选的节点
    const checkedKeys = menuTreeRef.value.getCheckedKeys()
    // 2. 获取半选的节点 (父节点) -> 这个很重要!
    // 比如你勾选了“用户管理”,那么“系统管理”这个父节点也是必须存入数据库的,否则菜单渲染不出来
    const halfCheckedKeys = menuTreeRef.value.getHalfCheckedKeys()

    // 合并 ID
    const finalMenuIds = [...checkedKeys, ...halfCheckedKeys]

    // 3. 发送给后端
    await updateRolePermission({
      roleId: permissionDrawer.roleId,
      menuIds: finalMenuIds,
    })

    ElMessage.success('权限分配成功')
    permissionDrawer.visible = false

    // 可选:如果是修改自己的权限,可能需要提示刷新页面
  } catch (error) {
    console.error(error)
  } finally {
    permissionDrawer.loading = false
  }
}
              

image.png

五、 下篇预告

用户列表做好了,但我们还需要给用户分配角色,给角色分配权限。这是 RBAC 的核心枢纽。

在下一篇  《企业级 全栈 RBAC 实战 (12):菜单管理》  

敬请期待!

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

感谢大家的star

前端:前端

后端:后端