前端动态菜单
# 动态菜单权限
# 一、后端实现
# 1. 菜单权限表 (sys_menu
)
表作用:
sys_menu
表用于存储系统中的所有菜单信息。每个菜单对应一个功能页面或按钮,系统通过该表实现基于菜单的权限控制。
表结构和 SQL 语句:
-- 如果表已存在则删除,确保创建的是最新表
drop table if exists sys_menu;
-- 创建菜单权限表
create table sys_menu (
menu_id bigint(20) not null auto_increment comment '菜单ID', -- 菜单唯一标识,自动递增
menu_name varchar(50) not null comment '菜单名称', -- 菜单的名称
parent_id bigint(20) default 0 comment '父菜单ID', -- 上级菜单的ID,0表示顶级菜单
order_num int(4) default 0 comment '显示顺序', -- 菜单的显示顺序
path varchar(200) default '' comment '路由地址', -- 前端路由地址
component varchar(255) default null comment '组件路径', -- 前端组件路径
query varchar(255) default null comment '路由参数', -- 路由参数
route_name varchar(50) default '' comment '路由名称', -- 路由名称
is_frame int(1) default 1 comment '是否为外链(0是 1否)', -- 是否是外部链接
is_cache int(1) default 0 comment '是否缓存(0缓存 1不缓存)', -- 是否缓存该菜单
menu_type char(1) default '' comment '菜单类型(M目录 C菜单 F按钮)', -- 菜单类型
visible char(1) default 0 comment '菜单状态(0显示 1隐藏)', -- 菜单状态,0表示显示,1表示隐藏
status char(1) default 0 comment '菜单状态(0正常 1停用)', -- 菜单状态,0表示正常,1表示停用
perms varchar(100) default null comment '权限标识', -- 权限标识字符串,用于控制权限
icon varchar(100) default '#' comment '菜单图标', -- 菜单图标
create_by varchar(64) default '' comment '创建者', -- 记录表的创建者
create_time datetime comment '创建时间', -- 记录创建时间
update_by varchar(64) default '' comment '更新者', -- 记录最后更新者
update_time datetime comment '更新时间', -- 记录最后更新时间
remark varchar(500) default '' comment '备注', -- 备注信息
primary key (menu_id) -- 设定 menu_id 为主键
) engine=innodb auto_increment=2000 comment = '菜单权限表'; -- 设定存储引擎为 InnoDB,且主键从 2000 开始自增
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
详细说明:
menu_id
: 菜单的唯一标识符,自动递增。每个菜单项都有一个独立的menu_id
,用于唯一识别。menu_name
: 菜单的名称,用于前端显示,必须唯一。该字段在前端渲染菜单时显示,通常为用户友好的名称。parent_id
: 父菜单的menu_id
,用于定义菜单的层级结构。0
表示顶级菜单,其他值表示该菜单的父菜单 ID。path
: 对应的前端路由地址,该路径会映射到前端的 Vue Router 中。决定了用户点击菜单项后跳转到的页面。component
: 该菜单对应的前端组件路径,前端会根据这个路径加载相应的组件。通常为 Vue 文件的路径,如"views/dashboard/index"
。is_frame
: 标记该菜单是否为外链,如果是外链,前端会在新窗口中打开该链接。is_cache
: 控制该菜单对应页面是否启用缓存,0
表示缓存,1
表示不缓存。menu_type
: 表示菜单的类型,M
表示目录,C
表示菜单,F
表示按钮。不同类型的菜单在前端的呈现方式不同。visible
: 控制该菜单是否在前端显示,0
表示显示,1
表示隐藏。通常用于控制不希望用户看到的菜单项。perms
: 菜单的权限标识符,前端会根据用户的权限判断是否显示该菜单。通常为"system:user:add"
之类的权限标识。
# 2. 菜单控制器 (SysMenuController
)
文件路径: com.ruoyi.web.controller.system.SysMenuController
代码结构: 该类是一个 Spring Boot 控制器,负责处理菜单相关的请求,如获取菜单列表、新增菜单、修改菜单、删除菜单等。
@RestController
@RequestMapping("/system/menu") // 定义基础URL路径为 "/system/menu"
public class SysMenuController extends BaseController {
@Autowired
private ISysMenuService menuService; // 注入菜单服务接口,用于操作菜单数据
/**
* 获取菜单列表
*
* @param menu 菜单实体,用于传递查询条件
* @return AjaxResult 包含菜单列表数据的统一响应结果
*/
@PreAuthorize("@ss.hasPermi('system:menu:list')") // 权限控制,只有具有'system:menu:list'权限的用户才能访问
@GetMapping("/list")
public AjaxResult list(SysMenu menu) {
List<SysMenu> menus = menuService.selectMenuList(menu, getUserId()); // 根据用户ID获取菜单列表
return success(menus); // 返回包含菜单数据的成功响应结果
}
/**
* 根据菜单编号获取详细信息
*
* @param menuId 菜单ID
* @return AjaxResult 包含菜单详细信息的统一响应结果
*/
@PreAuthorize("@ss.hasPermi('system:menu:query')") // 权限控制,只有具有'system:menu:query'权限的用户才能访问
@GetMapping(value = "/{menuId}")
public AjaxResult getInfo(@PathVariable Long menuId) {
return success(menuService.selectMenuById(menuId)); // 根据菜单ID查询详细信息并返回
}
/**
* 获取菜单下拉树列表
*
* @param menu 菜单实体,用于传递查询条件
* @return AjaxResult 包含下拉树结构的菜单列表
*/
@GetMapping("/treeselect")
public AjaxResult treeselect(SysMenu menu) {
List<SysMenu> menus = menuService.selectMenuList(menu, getUserId()); // 获取当前用户的菜单列表
return success(menuService.buildMenuTreeSelect(menus)); // 构建下拉树结构并返回
}
/**
* 加载对应角色的菜单列表树
*
* @param roleId 角色ID
* @return AjaxResult 包含角色对应的菜单树结构和已选中的菜单项
*/
@GetMapping(value = "/roleMenuTreeselect/{roleId}")
public AjaxResult roleMenuTreeselect(@PathVariable("roleId") Long roleId) {
List<SysMenu> menus = menuService.selectMenuList(getUserId()); // 获取当前用户的菜单列表
AjaxResult ajax = AjaxResult.success(); // 创建成功的响应对象
ajax.put("checkedKeys", menuService.selectMenuListByRoleId(roleId)); // 将角色已分配的菜单ID列表放入响应中
ajax.put("menus", menuService.buildMenuTreeSelect(menus)); // 将菜单树结构放入响应中
return ajax; // 返回包含菜单数据的响应结果
}
/**
* 新增菜单
*
* @param menu 菜单实体,包含新增的菜单信息
* @return AjaxResult 包含操作结果的响应对象
*/
@PreAuthorize("@ss.hasPermi('system:menu:add')") // 权限控制,只有具有'system:menu:add'权限的用户才能访问
@Log(title = "菜单管理", businessType = BusinessType.INSERT) // 记录操作日志,日志标题为"菜单管理",业务类型为"新增"
@PostMapping
public AjaxResult add(@Validated @RequestBody SysMenu menu) {
// 检查菜单名称是否唯一,不唯一则返回错误
if (!menuService.checkMenuNameUnique(menu)) {
return error("新增菜单'" + menu.getMenuName() + "'失败,菜单名称已存在");
}
// 检查是否为外链菜单且路径是否以"http(s)://"开头,不符合则返回错误
else if (UserConstants.YES_FRAME.equals(menu.getIsFrame()) && !StringUtils.ishttp(menu.getPath())) {
return error("新增菜单'" + menu.getMenuName() + "'失败,地址必须以http(s)://开头");
}
menu.setCreateBy(getUsername()); // 设置菜单的创建者
return toAjax(menuService.insertMenu(menu)); // 执行插入操作并返回结果
}
/**
* 修改菜单
*
* @param menu 菜单实体,包含修改后的菜单信息
* @return AjaxResult 包含操作结果的响应对象
*/
@PreAuthorize("@ss.hasPermi('system:menu:edit')") // 权限控制,只有具有'system:menu:edit'权限的用户才能访问
@Log(title = "菜单管理", businessType = BusinessType.UPDATE) // 记录操作日志,日志标题为"菜单管理",业务类型为"更新"
@PutMapping
public AjaxResult edit(@Validated @RequestBody SysMenu menu) {
// 检查菜单名称是否唯一,不唯一则返回错误
if (!menuService.checkMenuNameUnique(menu)) {
return error("修改菜单'" + menu.getMenuName() + "'失败,菜单名称已存在");
}
// 检查是否为外链菜单且路径是否以"http(s)://"开头,不符合则返回错误
else if (UserConstants.YES_FRAME.equals(menu.getIsFrame()) && !StringUtils.ishttp(menu.getPath())) {
return error("修改菜单'" + menu.getMenuName() + "'失败,地址必须以http(s)://开头");
}
// 防止菜单的父菜单设置为自身
else if (menu.getMenuId().equals(menu.getParentId())) {
return error("修改菜单'" + menu.getMenuName() + "'失败,上级菜单不能选择自己");
}
menu.setUpdateBy(getUsername()); // 设置菜单的更新者
return toAjax(menuService.updateMenu(menu)); // 执行更新操作并返回结果
}
/**
* 删除菜单
*
* @param menuId 菜单ID
* @return AjaxResult 包含操作结果的响应对象
*/
@PreAuthorize("@ss.hasPermi('system:menu:remove')") // 权限控制,只有具有'system:menu:remove'权限的用户才能访问
@Log(title = "菜单管理", businessType = BusinessType.DELETE) // 记录操作日志,日志标题为"菜单管理",业务类型为"删除"
@DeleteMapping("/{menuId}")
public AjaxResult remove(@PathVariable("menuId") Long menuId) {
// 检查是否存在子菜单,存在子菜单则不允许删除
if (menuService.hasChildByMenuId(menuId)) {
return warn("存在子菜单,不允许删除");
}
// 检查菜单是否已分配给角色,已分配则不允许删除
if (menuService.checkMenuExistRole(menuId)) {
return warn("菜单已分配,不允许删除");
}
return toAjax(menuService.deleteMenuById(menuId)); // 执行删除操作并返回结果
}
}
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
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
详细说明:
@PreAuthorize("@ss.hasPermi('system:menu:list')")
: 该注解用于控制权限,确保调用该接口的用户具有"system:menu:list"
的权限。AjaxResult
: 统一封装了接口的返回数据,前端可以根据返回的数据来判断请求是否成功以及获取所需的数据。menuService
: 该服务负责与数据库交互,提供菜单的增删改查功能。getInfo(@PathVariable Long menuId)
: 根据菜单 ID 获取菜单的详细信息,通过@PathVariable
获取 URL 中的路径参数。treeselect(SysMenu menu)
: 获取菜单下拉树结构数据,通常用于在前端表单中展示菜单树,以供选择父菜单。roleMenuTreeselect(Long roleId)
: 根据角色 ID 获取对应的菜单树和选中状态,用于角色管理中菜单权限的配置。add(SysMenu menu)
: 新增菜单的接口,通过@PostMapping
定义为 POST 请求。通过@Validated
注解进行参数校验,确保传入的菜单数据符合要求。edit(SysMenu menu)
: 修改菜单的接口,与新增类似,但使用的是 PUT 请求,用于更新现有的菜单信息。remove(Long menuId)
: 删除菜单的接口,通过菜单 ID 删除对应的菜单。如果该菜单有子菜单或已分配给角色,则不允许删除。
# 3. 菜单服务 (SysMenuServiceImpl
)
文件路径: com.ruoyi.system.service.impl.SysMenuServiceImpl
代码结构: 该类实现了菜单服务接口 (ISysMenuService
),负责处理菜单的业务逻辑,包括菜单的增删改查、权限控制、树形结构构建等。
@Service
public class SysMenuServiceImpl implements ISysMenuService {
@Autowired
private SysMenuMapper menuMapper; // 注入菜单数据访问层,用于直接操作数据库中的菜单数据
/**
* 根据用户 ID 查询菜单列表
*
* @param userId 用户 ID
* @return 菜单列表
*/
@Override
public List<SysMenu> selectMenuList(Long userId) {
return selectMenuList(new SysMenu(), userId); // 调用另一个重载方法,传入一个空的菜单查询条件
}
/**
* 查询菜单列表
*
* @param menu 菜单实体,用于传递查询条件
* @param userId 用户 ID
* @return 菜单列表
*/
@Override
public List<SysMenu> selectMenuList(SysMenu menu, Long userId) {
List<SysMenu> menuList; // 用于存储查询到的菜单列表
// 如果是管理员(判断依据是用户ID是否为管理员用户的ID),则查询所有菜单
if (SecurityUtils.isAdmin(userId)) {
menuList = menuMapper.selectMenuList(menu);
} else {
// 否则查询该用户有权限访问的菜单
menu.getParams().put("userId", userId); // 将用户ID作为查询参数传递给数据访问层
menuList = menuMapper.selectMenuListByUserId(menu);
}
return menuList; // 返回查询到的菜单列表
}
/**
* 构建前端所需的菜单树结构
*
* @param menus 菜单列表
* @return 树结构列表
*/
@Override
public List<TreeSelect> buildMenuTreeSelect(List<SysMenu> menus) {
List<SysMenu> menuTrees = buildMenuTree(menus); // 构建菜单的树形结构
return menuTrees.stream().map(TreeSelect::new).collect(Collectors.toList()); // 转换为 TreeSelect 对象列表并返回
}
/**
* 根据用户 ID 查询权限标识
*
* @param userId 用户 ID
* @return 权限标识集合
*/
@Override
public Set<String> selectMenuPermsByUserId(Long userId) {
List<String> perms = menuMapper.selectMenuPermsByUserId(userId); // 查询用户的权限标识符列表
Set<String> permsSet = new HashSet<>(); // 用于存储去重后的权限标识符集合
for (String perm : perms) {
if (StringUtils.isNotEmpty(perm)) {
permsSet.addAll(Arrays.asList(perm.trim().split(","))); // 将权限标识符按照逗号分隔并添加到集合中
}
}
return permsSet; // 返回权限标识符集合
}
/**
* 构建前端路由所需的菜单
*
* @param menus 菜单列表
* @return 路由列表
*/
@Override
public List<RouterVo> buildMenus(List<SysMenu> menus) {
List<RouterVo> routers = new LinkedList<>(); // 用于存储构建好的路由列表
for (SysMenu menu : menus) {
RouterVo router = new RouterVo(); // 创建一个新的路由对象
router.setHidden("1".equals(menu.getVisible())); // 根据菜单的 visible 字段判断是否隐藏菜单
router.setName(getRouteName(menu)); // 设置路由名称,调用 getRouteName 方法获取
router.setPath(getRouterPath(menu)); // 设置路由路径,调用 getRouterPath 方法获取
router.setComponent(getComponent(menu)); // 设置路由组件,调用 getComponent 方法获取
router.setQuery(menu.getQuery()); // 设置路由查询参数
router.setMeta(new MetaVo(menu.getMenuName(), menu.getIcon(), StringUtils.equals("1", menu.getIsCache()), menu.getPath())); // 设置路由的 meta 信息,包括标题、图标等
List<SysMenu> cMenus = menu.getChildren(); // 获取子菜单
if (StringUtils.isNotEmpty(cMenus) && UserConstants.TYPE_DIR.equals(menu.getMenuType())) {
// 如果菜单有子菜单且菜单类型为目录,则递归构建子菜单的路由
router.setAlwaysShow(true);
router.setRedirect("noRedirect"); // 设置路由重定向为 noRedirect
router.setChildren(buildMenus(cMenus)); // 递归构建子菜单的路由并添加到当前路由的 children 属性中
} else if (isMenuFrame(menu)) {
// 如果菜单是框架类型,则特殊处理,将其作为一个子路由添加到当前路由的 children 属性中
router.setMeta(null);
List<RouterVo> childrenList = new ArrayList<>();
RouterVo children = new RouterVo();
children.setPath(menu.getPath());
children.setComponent(menu.getComponent());
children.setName(getRouteName(menu.getRouteName(), menu.getPath()));
children.setMeta(new MetaVo(menu.getMenuName(), menu.getIcon(), StringUtils.equals("1", menu.getIsCache()), menu.getPath()));
children.setQuery(menu.getQuery());
childrenList.add(children);
router.setChildren(childrenList);
} else if (menu.getParentId().intValue() == 0 && isInnerLink(menu)) {
// 如果菜单是顶级菜单且是内链,则特殊处理,将内链菜单作为一个子路由添加到当前路由的 children 属性中
router.setMeta(new MetaVo(menu.getMenuName(), menu.getIcon()));
router.setPath("/");
List<RouterVo> childrenList = new ArrayList<>();
RouterVo children = new RouterVo();
String routerPath = innerLinkReplaceEach(menu.getPath());
children.setPath(routerPath);
children.setComponent(UserConstants.INNER_LINK);
children.setName(getRouteName(menu.getRouteName(), routerPath));
children.setMeta(new MetaVo(menu.getMenuName(), menu.getIcon(), menu.getPath()));
childrenList.add(children);
router.setChildren(childrenList);
}
routers.add(router); // 将构建好的路由添加到路由列表中
}
return routers; // 返回构建好的路由列表
}
// 其他方法...
/**
* 获取路由名称
*
* @param menu 菜单实体
* @return 路由名称
*/
public String getRouteName(SysMenu menu) {
// 如果菜单是框架菜单且为一级目录,则返回空字符串,否则返回路由名称或路径
if (isMenuFrame(menu)) {
return StringUtils.EMPTY;
}
return getRouteName(menu.getRouteName(), menu.getPath());
}
/**
* 获取路由名称,如没有配置路由名称则取路由地址
*
* @param routerName 路由名称
* @param path 路由地址
* @return 路由名称(驼峰格式)
*/
public String getRouteName(String name, String path) {
String routerName = StringUtils.isNotEmpty(name) ? name : path; // 如果名称不为空则使用名称,否则使用路径
return StringUtils.capitalize(routerName); // 将名称转换为驼峰格式并返回
}
/**
* 获取路由地址
*
* @param menu 菜单实体
* @return 路由地址
*/
public String getRouterPath(SysMenu menu) {
String routerPath = menu.getPath(); // 获取菜单的路径
// 如果菜单是内链且不是顶级菜单,则替换路径中的特殊字符
if (menu.getParentId().intValue() != 0 && isInnerLink(menu)) {
routerPath = innerLinkReplaceEach(routerPath);
}
// 如果菜单是一级目录且不是外链,则在路径前添加 "/"
if (0 == menu.getParentId().intValue() && UserConstants.TYPE_DIR.equals(menu.getMenuType())
&& UserConstants.NO_FRAME.equals(menu.getIsFrame())) {
routerPath = "/" + menu.getPath();
}
// 如果菜单是框架菜单,则将路径设置为 "/"
else if (isMenuFrame(menu)) {
routerPath = "/";
}
return routerPath; // 返回最终的路由路径
}
/**
* 获取组件信息
*
* @param menu 菜单实体
* @return 组件路径
*/
public String getComponent(SysMenu menu) {
String component = UserConstants.LAYOUT; // 默认组件路径为 LAYOUT(主框架组件)
// 如果菜单的组件路径不为空且不是框架菜单,则使用菜单的组件路径
if (StringUtils.isNotEmpty(menu.getComponent()) && !isMenuFrame(menu)) {
component = menu.getComponent();
}
// 如果菜单的组件路径为空且菜单是内链类型,则使用 INNER_LINK 组件
else if (StringUtils.isEmpty(menu.getComponent()) && menu.getParentId().intValue() != 0 && isInnerLink(menu)) {
component = UserConstants.INNER_LINK;
}
// 如果菜单的组件路径为空且菜单是目录类型,则使用 PARENT_VIEW 组件
else if (StringUtils.isEmpty(menu.getComponent()) && isParentView(menu)) {
component = UserConstants.PARENT_VIEW;
}
return component; // 返回最终的组件路径
}
/**
* 判断是否为框架菜单
*
* @param menu 菜单实体
* @return 如果是框架菜单则返回 true,否则返回 false
*/
public boolean isMenuFrame(SysMenu menu) {
return menu.getParentId().intValue() == 0 && UserConstants.TYPE_MENU.equals(menu.getMenuType())
&& menu.getIsFrame().equals(UserConstants.NO_FRAME);
}
/**
* 判断是否为内链组件
*
* @param menu 菜单实体
* @return 如果是内链组件则返回 true,否则返回 false
*/
public boolean isInnerLink(SysMenu menu) {
return menu.getIsFrame().equals(UserConstants.NO_FRAME) && StringUtils.ishttp(menu.getPath());
}
/**
* 判断是否为 parent_view 组件
*
* @param menu 菜单实体
* @return 如果是 parent_view 组件则返回 true,否则返回 false
*/
public boolean isParentView(SysMenu menu) {
return menu.getParentId().intValue() != 0 && UserConstants.TYPE_DIR.equals(menu.getMenuType());
}
/**
* 内链域名特殊字符替换
*
* @param path 原始路径
* @return 替换后的内链域名
*/
public String innerLinkReplaceEach(String path) {
return StringUtils.replaceEach(path, new String[] { Constants.HTTP, Constants.HTTPS, Constants.WWW, ".", ":" },
new String[] { "", "", "", "/", "/" });
}
// 省略其他方法...
}
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
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
详细说明:
selectMenuList(SysMenu menu, Long userId)
: 根据用户 ID 查询菜单列表。管理员(判断方式是SecurityUtils.isAdmin(userId)
)可以查看所有菜单,普通用户只能查看有权限的菜单。buildMenuTreeSelect(List<SysMenu> menus)
: 构建前端所需的菜单树结构,将菜单数据转换为树形结构以供前端使用。返回的数据适用于前端的下拉树组件。selectMenuPermsByUserId(Long userId)
: 根据用户 ID 查询该用户拥有的权限标识,用于在前端控制菜单的显示和操作权限。将权限标识符组成的列表转化为Set<String>
以便后续使用。buildMenus(List<SysMenu> menus)
: 将菜单列表转换为前端路由所需的格式。这是后端构建前端路由的核心逻辑,涉及到对菜单类型、组件路径、是否为外链、是否缓存等多个字段的判断和处理。getRouteName(SysMenu menu)
: 获取路由名称,非外链且为一级目录时会返回空字符串,否则返回菜单的routeName
或path
。getRouterPath(SysMenu menu)
: 获取路由路径。针对内链、一级目录等不同情况,路径的构建方式有所不同。若为外链,则直接返回路径,否则根据不同层级和类型进行路径拼接。getComponent(SysMenu menu)
: 获取组件路径,确定前端应该加载哪个 Vue 组件。例如,如果component
字段为空且菜单为内链类型,则会设置为UserConstants.INNER_LINK
。isMenuFrame(SysMenu menu)
: 判断该菜单是否为框架菜单,通常一级目录且为非外链时使用。isInnerLink(SysMenu menu)
: 判断该菜单是否为内链,内链的菜单会通过外部链接进行跳转。innerLinkReplaceEach(String path)
: 对内链路径进行特殊字符替换,以符合内链菜单的路由规则。
# 二、前端实现
# 1. Vuex 中的动态路由管理 (permission.js
)
文件路径: src/store/modules/permission.js
代码结构: 这是 Vuex 的一个模块,负责管理动态路由的生成和存储。通过调用后端接口获取菜单数据,并将其转化为 Vue Router 的路由配置。
import auth from '@/plugins/auth'; // 引入权限验证插件,用于检查用户是否具有特定权限
import router, { constantRoutes, dynamicRoutes } from '@/router'; // 引入 Vue Router 实例和路由配置
import { getRouters } from '@/api/menu'; // 引入 API 方法,用于获取菜单路由数据
import Layout from '@/layout/index'; // 引入主框架组件
import ParentView from '@/components/ParentView'; // 引入 ParentView 组件,用于渲染嵌套的子路由
import InnerLink from '@/layout/components/InnerLink'; // 引入 InnerLink 组件,用于渲染内链菜单
const permission = {
state: {
routes: [], // 存储所有路由(静态路由和动态路由)
addRoutes: [], // 存储动态添加的路由
defaultRoutes: [], // 存储默认路由,通常是基础的静态路由部分
topbarRouters: [], // 存储顶部菜单的路由数据
sidebarRouters: [] // 存储侧边栏菜单的路由数据
},
mutations: {
// 设置完整的路由表
SET_ROUTES: (state, routes) => {
state.addRoutes = routes; // 存储动态添加的路由
state.routes = constantRoutes.concat(routes); // 将静态路由和动态路由合并,存储到 routes 中
},
// 设置默认路由表
SET_DEFAULT_ROUTES: (state, routes) => {
state.defaultRoutes = constantRoutes.concat(routes); // 将静态路由和动态路由合并,存储到 defaultRoutes 中
},
// 设置顶部菜单的路由表
SET_TOPBAR_ROUTES: (state, routes) => {
state.topbarRouters = routes; // 存储顶部菜单的路由数据
},
// 设置侧边栏菜单的路由表
SET_SIDEBAR_ROUTERS: (state, routes) => {
state.sidebarRouters = routes; // 存储侧边栏菜单的路由数据
},
},
actions: {
// 生成动态路由
GenerateRoutes({ commit }) {
return new Promise(resolve => {
// 调用后端 API 获取路由数据
getRouters().then(res => {
const sdata = JSON.parse(JSON.stringify(res.data)); // 深拷贝路由数据,用于构建侧边栏菜单
const rdata = JSON.parse(JSON.stringify(res.data)); // 深拷贝路由数据,用于构建路由配置
const sidebarRoutes = filterAsyncRouter(sdata); // 过滤和处理侧边栏路由数据
const rewriteRoutes = filterAsyncRouter(rdata, false, true); // 过滤和处理完整的路由数据
const asyncRoutes = filterDynamicRoutes(dynamicRoutes); // 过滤动态路由,检查权限
rewriteRoutes.push({ path: '*', redirect: '/404', hidden: true }); // 添加默认路由,处理未匹配的路径
router.addRoutes(asyncRoutes); // 动态添加可访问路由表
commit('SET_ROUTES', rewriteRoutes); // 提交 mutations 设置完整的路由表
commit('SET_SIDEBAR_ROUTERS', constantRoutes.concat(sidebarRoutes)); // 提交 mutations 设置侧边栏菜单的路由表
commit('SET_DEFAULT_ROUTES', sidebarRoutes); // 提交 mutations 设置默认路由表
commit('SET_TOPBAR_ROUTES', sidebarRoutes); // 提交 mutations 设置顶部菜单的路由表
resolve(rewriteRoutes); // 返回处理后的路由数据
});
});
}
}
};
// 递归过滤后台传来的路由数据,生成符合 Vue Router 规范的路由配置
function filterAsyncRouter(asyncRouterMap, lastRouter = false, type = false) {
return asyncRouterMap.filter(route => {
// 如果需要处理子路由
if (type && route.children) {
route.children = filterChildren(route.children); // 递归处理子路由
}
// 处理组件路径,将字符串形式的组件路径转换为实际的组件对象
if (route.component) {
// 特殊处理 Layout、ParentView 和 InnerLink 组件
if (route.component === 'Layout') {
route.component = Layout;
} else if (route.component === 'ParentView') {
route.component = ParentView;
} else if (route.component === 'InnerLink') {
route.component = InnerLink;
} else {
route.component = loadView(route.component); // 动态加载路由组件
}
}
// 递归处理子路由
if (route.children && route.children.length) {
route.children = filterAsyncRouter(route.children, route, type);
} else {
delete route['children']; // 删除空的 children 属性
delete route['redirect']; // 删除不必要的 redirect 属性
}
return true;
});
}
// 递归处理子路由,确保子路由路径的正确性
function filterChildren(childrenMap, lastRouter = false) {
var children = [];
childrenMap.forEach((el, index) => {
if (el.children && el.children.length) {
// 如果当前组件是 ParentView 且没有父级路由,则递归处理其子路由
if (el.component === 'ParentView' && !lastRouter) {
el.children.forEach(c => {
c.path = el.path + '/' + c.path; // 拼接子路由路径
if (c.children && c.children.length) {
children = children.concat(filterChildren(c.children, c)); // 递归处理嵌套的子路由
return;
}
children.push(c);
});
return;
}
}
// 如果有父级路由,则拼接子路由路径
if (lastRouter) {
el.path = lastRouter.path + '/' + el.path;
if (el.children && el.children.length) {
children = children.concat(filterChildren(el.children, el)); // 递归处理嵌套的子路由
return;
}
}
children = children.concat(el); // 将处理后的子路由添加到 children 数组中
});
return children;
}
// 过滤动态路由,检查用户是否具有访问权限
export function filterDynamicRoutes(routes) {
const res = [];
routes.forEach(route => {
// 如果路由设置了权限标识符,则检查用户是否具有权限
if (route.permissions) {
if (auth.hasPermiOr(route.permissions)) {
res.push(route);
}
// 如果路由设置了角色标识符,则检查用户是否具有相应角色
} else if (route.roles) {
if (auth.hasRoleOr(route.roles)) {
res.push(route);
}
}
});
return res; // 返回具有访问权限的路由列表
}
// 动态加载路由组件
export const loadView = (view) => {
// 开发环境使用 require 加载组件
if (process.env.NODE_ENV === 'development') {
return (resolve) => require([`@/views/${view}`], resolve);
} else {
// 生产环境使用 import 实现路由懒加载
return () => import(`@/views/${view}`);
}
}
export default permission; // 导出 permission 模块
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
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
详细说明:
Vuex State:
routes
: 存储所有的路由,包括静态路由和动态路由。addRoutes
: 仅存储动态添加的路由,通常为根据用户权限动态生成的路由。defaultRoutes
: 默认路由,通常是基础的静态路由部分。topbarRouters
: 用于顶部菜单的路由数据。sidebarRouters
: 用于侧边栏菜单的路由数据。
Vuex Mutations:
SET_ROUTES
: 将动态生成的路由添加到routes
中,并存储到addRoutes
变量中。SET_DEFAULT_ROUTES
: 将默认路由存储到defaultRoutes
变量中,便于后续使用。SET_TOPBAR_ROUTES
: 设置顶部菜单的路由数据。SET_SIDEBAR_ROUTERS
: 设置侧边栏菜单的路由数据。
Vuex Actions:
GenerateRoutes
: 核心方法,通过调用后端的getRouters
接口获取当前用户的菜单数据。然后将这些数据转换为符合 Vue Router 的路由格式,并动态添加到路由配置中。
核心方法解析:
filterAsyncRouter
: 递归遍历后端返回的路由数据,生成符合 Vue Router 规范的路由配置。route.component
: 对Layout
、ParentView
等特殊组件进行处理,将其映射到前端对应的 Vue 组件上。loadView
: 根据route.component
动态加载对应的 Vue 组件。
filterChildren
: 处理子路由的层级关系,支持嵌套子菜单的递归渲染。filterDynamicRoutes
: 检查每个路由是否符合当前用户的权限,过滤掉不符合权限的路由。
# 2. 侧边栏菜单组件 (Sidebar.vue
)
文件路径: src/layout/components/Sidebar/index.vue
代码结构: 用于渲染侧边栏菜单的 Vue 组件,通过 Vuex 存储的路由数据动态生成菜单。
<template>
<div :class="{'has-logo':showLogo}" :style="{ backgroundColor: settings.sideTheme === 'theme-dark' ? variables.menuBackground : variables.menuLightBackground }">
<logo v-if="showLogo" :collapse="isCollapse" /> <!-- 显示 Logo,传入 isCollapse 状态 -->
<el-scrollbar :class="settings.sideTheme" wrap-class="scrollbar-wrapper"> <!-- 使用 Element UI 的滚动条组件 -->
<el-menu
:default-active="activeMenu" <!-- 设置默认激活的菜单项 -->
:collapse="isCollapse" <!-- 控制侧边栏的折叠状态 -->
:background-color="settings.sideTheme === 'theme-dark' ? variables.menuBackground : variables.menuLightBackground" <!-- 动态设置菜单的背景颜色 -->
:text-color="settings.sideTheme === 'theme-dark' ? variables.menuColor : variables.menuLightColor" <!-- 动态设置菜单的文字颜色 -->
:unique-opened="true" <!-- 设置菜单项的唯一展开效果 -->
:active-text-color="settings.theme" <!-- 设置激活菜单项的文字颜色 -->
:collapse-transition="false" <!-- 禁用折叠过渡效果 -->
mode="vertical" <!-- 设置菜单的模式为垂直 -->
>
<!-- 遍历 sidebarRouters 数据,生成侧边栏菜单项 -->
<sidebar-item
v-for="(route, index) in sidebarRouters"
:key="route.path + index" <!-- 设置唯一键值,确保渲染的稳定性 -->
:item="route" <!-- 传入当前路由对象 -->
:base-path="route.path" <!-- 传入当前路由的基础路径 -->
/>
</el-menu>
</el-scrollbar>
</div>
</template>
<script>
import { mapGetters, mapState } from "vuex"; // 引入 Vuex 的辅助函数,用于获取状态和计算属性
import Logo from "./Logo"; // 引入 Logo 组件
import SidebarItem from "./SidebarItem"; // 引入侧边栏菜单项组件
import variables from "@/assets/styles/variables.scss"; // 引入自定义样式变量
export default {
components: { SidebarItem, Logo }, // 注册子组件
computed: {
...mapState(["settings"]), // 将 settings 状态映射为计算属性
...mapGetters(["sidebarRouters", "sidebar"]), // 将 sidebarRouters 和 sidebar 映射为计算属性
activeMenu() {
const route = this.$route; // 获取当前路由对象
const { meta, path } = route;
// 如果设置了 activeMenu,则使用 activeMenu 作为激活项,否则使用当前路径
if (meta.activeMenu) {
return meta.activeMenu;
}
return path;
},
showLogo() {
return this.$store.state.settings.sidebarLogo; // 根据设置判断是否显示侧边栏的 Logo
},
variables() {
return variables; // 引入的自定义样式变量
},
isCollapse() {
return !this.sidebar.opened; // 根据 sidebar 的 opened 状态判断侧边栏是否折叠
}
}
};
</script>
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
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
详细说明:
activeMenu
: 动态计算当前激活的菜单项。通常情况下,激活菜单项会高亮显示。该计算属性首先检查meta.activeMenu
是否设置,如果设置了则使用它作为激活菜单,否则使用当前路由的路径作为激活菜单。sidebarRouters
: 从 Vuex 中获取侧边栏路由数据,通过v-for
指令遍历生成菜单项。每个菜单项都通过SidebarItem
组件渲染。showLogo
: 控制是否显示侧边栏的 Logo,通常根据项目配置文件或用户设置来决定。isCollapse
: 判断侧边栏是否处于
折叠状态,根据 sidebar.opened
状态值动态改变侧边栏的显示样式。
variables
: 通过引入的variables.scss
文件获取自定义样式变量,用于控制侧边栏的背景颜色、文字颜色等样式。
# 3. 侧边栏菜单项组件 (SidebarItem.vue
)
文件路径: src/layout/components/Sidebar/SidebarItem.vue
代码结构: 用于渲染侧边栏菜单项的 Vue 组件,支持嵌套子菜单的递归渲染。
<template>
<div v-if="!item.hidden"> <!-- 如果菜单项未隐藏则显示 -->
<template v-if="hasOneShowingChild(item.children,item) && (!onlyOneChild.children||onlyOneChild.noShowingChildren)&&!item.alwaysShow">
<app-link v-if="onlyOneChild.meta" :to="resolvePath(onlyOneChild.path, onlyOneChild.query)"> <!-- 如果菜单项只有一个子菜单且未设置 alwaysShow,则直接显示子菜单 -->
<el-menu-item :index="resolvePath(onlyOneChild.path)" :class="{'submenu-title-noDropdown':!isNest}"> <!-- 设置子菜单项的路径和样式 -->
<item :icon="onlyOneChild.meta.icon||(item.meta&&item.meta.icon)" :title="onlyOneChild.meta.title" /> <!-- 显示子菜单项的图标和标题 -->
</el-menu-item>
</app-link>
</template>
<el-submenu v-else ref="subMenu" :index="resolvePath(item.path)" popper-append-to-body> <!-- 如果菜单项有多个子菜单,则显示子菜单 -->
<template slot="title">
<item v-if="item.meta" :icon="item.meta && item.meta.icon" :title="item.meta.title" /> <!-- 显示菜单项的图标和标题 -->
</template>
<sidebar-item
v-for="(child, index) in item.children" <!-- 遍历子菜单项,递归渲染子菜单 -->
:key="child.path + index"
:is-nest="true"
:item="child"
:base-path="resolvePath(child.path)"
class="nest-menu"
/>
</el-submenu>
</div>
</template>
<script>
import path from 'path'; // 引入 Node.js 的 path 模块,用于处理路径
import { isExternal } from '@/utils/validate'; // 引入工具函数,用于判断路径是否为外部链接
import Item from './Item'; // 引入 Item 组件,用于渲染菜单项
import AppLink from './Link'; // 引入 AppLink 组件,用于处理路由跳转
import FixiOSBug from './FixiOSBug'; // 引入修复 iOS Bug 的混入
export default {
name: 'SidebarItem', // 组件名称
components: { Item, AppLink }, // 注册子组件
mixins: [FixiOSBug], // 混入修复 iOS Bug 的逻辑
props: {
item: { // 接收菜单项对象
type: Object,
required: true
},
isNest: { // 是否为嵌套菜单
type: Boolean,
default: false
},
basePath: { // 基础路径
type: String,
default: ''
}
},
data() {
this.onlyOneChild = null; // 用于存储只有一个子菜单的情况
return {};
},
methods: {
// 判断菜单项是否只有一个显示的子菜单
hasOneShowingChild(children = [], parent) {
if (!children) {
children = [];
}
const showingChildren = children.filter(item => {
if (item.hidden) {
return false; // 过滤掉隐藏的子菜单
} else {
this.onlyOneChild = item; // 如果只有一个子菜单,则将其设置为 onlyOneChild
return true;
}
});
if (showingChildren.length === 1) {
return true; // 如果只有一个子菜单,则返回 true
}
if (showingChildren.length === 0) {
this.onlyOneChild = { ...parent, path: '', noShowingChildren: true }; // 如果没有显示的子菜单,则返回父菜单
return true;
}
return false;
},
// 解析路由路径,支持处理外部链接和带有查询参数的路径
resolvePath(routePath, routeQuery) {
if (isExternal(routePath)) {
return routePath; // 如果是外部链接,则直接返回路径
}
if (isExternal(this.basePath)) {
return this.basePath; // 如果基础路径是外部链接,则返回基础路径
}
if (routeQuery) {
let query = JSON.parse(routeQuery); // 将查询参数解析为对象
return { path: path.resolve(this.basePath, routePath), query: query }; // 返回解析后的路径和查询参数
}
return path.resolve(this.basePath, routePath); // 解析并返回相对路径
}
}
}
</script>
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
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
详细说明:
hasOneShowingChild(children, parent)
: 该方法用于判断当前菜单项是否只有一个子菜单。如果只有一个子菜单且父菜单设置了alwaysShow
为false
,则直接展示该子菜单而不是父菜单的下拉菜单。showingChildren
: 过滤掉隐藏的子菜单,生成一个仅包含显示子菜单的数组。this.onlyOneChild
: 当仅有一个显示的子菜单时,将该子菜单设置为onlyOneChild
,后续会直接显示该菜单项。
resolvePath(routePath, routeQuery)
: 解析路由路径。根据菜单项的basePath
和routePath
进行路径拼接,支持处理外部链接和带有查询参数的路径。isExternal(routePath)
: 判断路径是否为外部链接(以http
或https
开头的 URL)。如果是外部链接,则直接返回路径,否则拼接生成 Vue Router 所需的相对路径。path.resolve(this.basePath, routePath)
: 使用path.resolve
方法来生成绝对路径,确保路径的一致性。
el-submenu
和el-menu-item
: 这些是 Element UI 提供的组件,用于渲染侧边栏的菜单和子菜单。el-submenu
用于包含多个子菜单项,el-menu-item
用于渲染单个菜单项。AppLink
和Item
: 自定义的组件,用于进一步封装菜单项的展示和链接跳转逻辑。AppLink
用于处理菜单项的路由跳转,而Item
用于渲染菜单项的图标和标题。
总结
在若依框架的前后端分离版本中,菜单管理涉及到复杂的前后端协作流程。后端通过 sys_menu
表存储菜单信息,并通过 SysMenuController
和 SysMenuServiceImpl
提供了增删改查、权限管理、树结构构建等功能。前端通过 Vuex 动态生成路由,并使用组件化的方式渲染侧边栏菜单和子菜单。整个流程实现了菜单的动态管理和权限控制,确保不同用户看到的菜单和功能是符合其权限的。