根据用户角色权限,渲染菜单的一个问题记录

个人博客:无奈何杨(wnhyang)

个人语雀:wnhyang

共享语雀:在线知识共享

Github:wnhyang - Overview


背景

之前一直讲过自己独立在做一个中后台管理系统,当然这个只是开始,未来会基于此开发其他项目,因为时间的原因,这项目算是搁置了一段时间,最近又重新拾取来完善。

项目链接如下

GitHub - wnhyang/okay-boot

GitHub - wnhyang/okay-vben-admin

其前端采用vben中后台开发框架,后端就是常用的Spirng Boot那一套,用户角色菜单设计也是最常用RABC的方案。

问题

如下是菜单管理查询到的菜单列表,展示为树形结构。

image

在给角色分配菜单权限时,使用的是一个TreeSelect的组件,该组件提供了可多选的树形结构菜单,当然这个组件本身就有很多配置项,可以自定义很多内容。

image

选中如上菜单时,收集的数据是如上“用户更新”、“角色新增”、“角色更新”、“角色删除”、“角色查询”这些菜单对应的id。然后通过类似于下面的方法新增或修改角色。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
@Transactional(rollbackFor = Exception.class)
public Long createRole(RoleCreateVO reqVO) {
validateRoleForCreateOrUpdate(null, reqVO.getName(), reqVO.getValue());
RolePO role = RoleConvert.INSTANCE.convert(reqVO);
roleMapper.insert(role);
if (CollectionUtil.isNotEmpty(reqVO.getMenuIds())) {
roleMenuMapper.insertBatch(CollectionUtils.convertList(reqVO.getMenuIds(),
menuId -> new RoleMenuPO().setRoleId(role.getId()).setMenuId(menuId)));
}
return role.getId();
}

@Transactional(rollbackFor = Exception.class)
public void updateRole(RoleUpdateVO reqVO) {
// 校验是否可以更新
validateRoleForUpdate(reqVO.getId());
// 校验角色的唯一字段是否重复
validateRoleForCreateOrUpdate(reqVO.getId(), reqVO.getName(), reqVO.getValue());

// 更新到数据库
RolePO role = RoleConvert.INSTANCE.convert(reqVO);
roleMenuMapper.deleteByRoleId(role.getId());
if (CollectionUtil.isNotEmpty(reqVO.getMenuIds())) {
roleMenuMapper.insertBatch(CollectionUtils.convertList(reqVO.getMenuIds(),
menuId -> new RoleMenuPO().setRoleId(role.getId()).setMenuId(menuId)));
}
roleMapper.updateById(role);
}

这样本身是没有问题,数据的修改和回显都是可以的。

问题是用户关联角色,角色关联菜单,如果角色关联的菜单不是顶级菜单,前端动态渲染菜单时就会有问题。如:用户A有“用户查询”权限,应该能正确显示系统管理/用户管理页面,只是只有查询权限,但是如果按上面的树形菜单收集数据并通过新增和修改角色的关联菜单后,角色关联表里只有选中的角色id和菜单id的数据,没有指定菜单父菜单的关联关系,所以这里要处理一下,不然会有问题。

方案

1、利用前端Tree组件相关方法,选中子节点时,带上其父节点,这个角色就能关联上虽有需要的菜单节点了。唯一要注意的是数据回显,因为Tree组件通常是粘性的,选中父节点的同时会选中其所有子节点,所以回显时要注意些,要么数据查回时去除一些父节点再渲染,要么利用组件的其他方法。

2、前面所有方案不变,数据存储还是只是选中菜单节点和角色的关联,只是在用户第一次登录需要根据权限渲染菜单时把菜单节点处理一下,也就是把所有菜单查到根结点后返回。

查菜单时带上父菜单直到根结点

以下仅供参考

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public List<UserInfoVO.MenuVO> getLoginUserMenuTreeList(boolean removeButton) {
Login loginUser = LoginUtil.getLoginUser();

if (loginUser == null) {
throw exception(UNAUTHORIZED);
}
Long id = loginUser.getId();

List<MenuPO> all = menuMapper.selectList();
if (LoginUtil.isAdministrator(id)) {
return buildUserMenuTree(all, removeButton);
}
Set<Long> menuIds = convertSet(roleMenuMapper.selectListByRoleId(loginUser.getRoleIds()), RoleMenuPO::getMenuId);
Set<MenuPO> menuSet = findMenusWithParentsOrChildrenByIds(all, menuIds, true, false);

return buildUserMenuTree(new ArrayList<>(menuSet), removeButton);
}

查询菜单的父/子菜单

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
/**
* 查找菜单的父/子菜单集合
*
* @param all 所有菜单
* @param menuIds 需要的菜单集合
* @param withParent 是否包含父菜单
* @param withChildren 是否包含子菜单
* @return 结果
*/
private Set<MenuPO> findMenusWithParentsOrChildrenByIds(List<MenuPO> all, Set<Long> menuIds, boolean withParent, boolean withChildren) {
Map<Long, MenuPO> menuMap = new HashMap<>();
for (MenuPO menu : all) {
menuMap.put(menu.getId(), menu);
}

// 使用LinkedHashSet保持插入顺序
Set<MenuPO> result = new LinkedHashSet<>();
// 存储已处理过的菜单ID
Set<Long> processedIds = new HashSet<>();
for (Long menuId : menuIds) {
if (withParent) {
collectMenuParents(result, menuMap, menuId, processedIds);
}
if (withChildren) {
collectMenuChildren(result, menuMap, menuId);
}
}

return result;
}

递归查找当前菜单的所有父菜单

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
/**
* 递归查找当前菜单的所有父菜单
*
* @param resultSet 结果
* @param menuMap menuMap
* @param menuId 需要的菜单id
* @param processedIds 存储已处理过的菜单id
*/
private void collectMenuParents(Set<MenuPO> resultSet, Map<Long, MenuPO> menuMap, Long menuId, Set<Long> processedIds) {
if (processedIds.contains(menuId)) {
return; // 如果已经处理过此菜单,则不再处理
}

processedIds.add(menuId);
MenuPO menu = menuMap.get(menuId);
if (menu != null) {
resultSet.add(menu);

// 如果当前菜单不是根节点(即parentId不为0),继续查找其父菜单
if (!Objects.equals(menu.getParentId(), ID_ROOT) && !processedIds.contains(menu.getParentId())) {
collectMenuParents(resultSet, menuMap, menu.getParentId(), processedIds);
}
}
}

递归查找当前菜单的所有子菜单

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
/**
* 递归查找当前菜单的所有子菜单
*
* @param resultSet 结果
* @param menuMap menuMap
* @param menuId 需要的菜单id
*/
private void collectMenuChildren(Set<MenuPO> resultSet, Map<Long, MenuPO> menuMap, Long menuId) {
MenuPO menu = menuMap.get(menuId);
if (menu != null) {
resultSet.add(menu);

// 添加当前菜单的所有子菜单
for (MenuPO child : menuMap.values()) {
if (child.getParentId().equals(menu.getId())) {
collectMenuChildren(resultSet, menuMap, child.getId());
}
}
}
}

构建菜单树

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
public List<MenuTreeRespVO> buildMenuTree(List<MenuPO> menuList, boolean removeButton) {

if (removeButton) {
// 移除按钮
menuList.removeIf(menu -> menu.getType().equals(MenuType.BUTTON.getType()));
}

List<MenuTreeRespVO> convert = MenuConvert.INSTANCE.convert2TreeRespList(menuList);

Map<Long, MenuTreeRespVO> menuTreeMap = new HashMap<>();
for (MenuTreeRespVO menu : convert) {
menuTreeMap.put(menu.getId(), menu);
}

menuTreeMap.values().stream().filter(menu -> !ID_ROOT.equals(menu.getParentId())).forEach(childMenu -> {
MenuTreeRespVO parentMenu = menuTreeMap.get(childMenu.getParentId());
if (parentMenu == null) {
log.info("id:{} 找不到父菜单 parentId:{}", childMenu.getId(), childMenu.getParentId());
return;
}
// 将自己添加到父节点中
if (parentMenu.getChildren() == null) {
parentMenu.setChildren(new ArrayList<>());
}
parentMenu.getChildren().add(childMenu);
}

);

return menuTreeMap.values().stream().filter(menu -> ID_ROOT.equals(menu.getParentId())).collect(Collectors.toList());
}

写在最后

拙作艰辛,字句心血,望诸君垂青,多予支持,不胜感激。


个人博客:无奈何杨(wnhyang)

个人语雀:wnhyang

共享语雀:在线知识共享

Github:wnhyang - Overview