摘要:角色(Role)是连接“用户”与“权限”的桥梁。本篇将实现角色管理的核心模块,重点攻克权限分配中的三大难题:树形菜单的加载、 “半选状态”的回显 Bug、以及数据库事务的数据一致性保障。
学习之前先浏览 前置专栏文章
一、 引言:为什么权限分配这么难?
在“用户管理”中,我们只需要处理扁平的数据。但在“角色管理”中,我们点击“分配权限”时,面临的是一颗树(Tree) :
-
展示难:菜单是无限层级的,需要递归展示。
-
回显难(核心坑点) :
- 数据库里存了“父节点 ID”和“子节点 ID”。
- 但在前端 el-tree 组件中,如果你直接把“父节点 ID”传给它,它会自动把该父节点下的所有子节点都勾选上(全选)。
- 后果:管理员明明只给了“用户查询”权限(半选),结果回显变成了“用户新增、修改、删除”全都勾上了。
-
保存难:保存时,既要保存全选的子节点,也要保存半选的父节点,否则菜单栏显示不出来。
二、 后端实现:事务与递归
我们需要三个接口来支撑这个功能。
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
}
}
五、 下篇预告
用户列表做好了,但我们还需要给用户分配角色,给角色分配权限。这是 RBAC 的核心枢纽。
在下一篇 《企业级 全栈 RBAC 实战 (12):菜单管理》
敬请期待!
具备完整功能后台管理系统的代码仓库:
感谢大家的star
前端:前端
后端:后端