一、动态菜单概述
动态菜单是指菜单项不是硬编码在前端,而是从后端数据库动态获取,根据用户权限动态生成的菜单系统。这种架构具有以下优势:
- 灵活性:无需重新部署即可修改菜单结构
- 权限控制:不同角色看到不同菜单
- 可维护性:菜单数据集中管理
二、数据库设计
1. 菜单表 (sys_menu)
CREATE TABLE `sys_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', `order_num` int(4) DEFAULT '0' COMMENT '显示顺序', `path` varchar(200) DEFAULT '' COMMENT '路由地址', `component` varchar(255) DEFAULT NULL COMMENT '组件路径', `is_frame` int(1) DEFAULT '1' COMMENT '是否为外链(0是 1否)', `menu_type` char(1) DEFAULT '' COMMENT '菜单类型(M目录 C菜单 F按钮)', `visible` char(1) DEFAULT '0' COMMENT '菜单状态(0显示 1隐藏)', `status` char(1) DEFAULT '0' COMMENT '菜单状态(0正常 1停用)', `perms` varchar(100) DEFAULT NULL COMMENT '权限标识', `icon` varchar(100) DEFAULT '#' COMMENT '菜单图标', `create_by` varchar(64) DEFAULT '' COMMENT '创建者', `create_time` datetime DEFAULT NULL COMMENT '创建时间', `update_by` varchar(64) DEFAULT '' COMMENT '更新者', `update_time` datetime DEFAULT NULL COMMENT '更新时间', `remark` varchar(500) DEFAULT '' COMMENT '备注', PRIMARY KEY (`id`) ) ENGINE=InnoDB AUTO_INCREMENT=2000 DEFAULT CHARSET=utf8 COMMENT='菜单权限表';
2. 角色表 (sys_role)
CREATE TABLE `sys_role` ( `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '角色ID', `role_name` varchar(30) NOT NULL COMMENT '角色名称', `role_key` varchar(100) NOT NULL COMMENT '角色权限字符串', `role_sort` int(4) NOT NULL COMMENT '显示顺序', `status` char(1) NOT NULL COMMENT '角色状态(0正常 1停用)', `create_by` varchar(64) DEFAULT '' COMMENT '创建者', `create_time` datetime DEFAULT NULL COMMENT '创建时间', `update_by` varchar(64) DEFAULT '' COMMENT '更新者', `update_time` datetime DEFAULT NULL COMMENT '更新时间', `remark` varchar(500) DEFAULT NULL COMMENT '备注', PRIMARY KEY (`id`) ) ENGINE=InnoDB AUTO_INCREMENT=100 DEFAULT CHARSET=utf8 COMMENT='角色信息表';
3. 角色菜单关联表 (sys_role_menu)
CREATE TABLE `sys_role_menu` ( `role_id` bigint(20) NOT NULL COMMENT '角色ID', `menu_id` bigint(20) NOT NULL COMMENT '菜单ID', PRIMARY KEY (`role_id`,`menu_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8 COMMENT='角色和菜单关联表';
三、SpringBoot 后端实现
1. 实体类
Menu.java
@Data @TableName("sys_menu") public class Menu { private static final long serialVersionUID = 1L; @TableId(value = "id", type = IdType.AUTO) private Long id; private String menuName; private Long parentId; private Integer orderNum; private String path; private String component; private Integer isFrame; private String menuType; private String visible; private String status; private String perms; private String icon; private String createBy; private Date createTime; private String updateBy; private Date updateTime; private String remark; /** 子菜单 */ @TableField(exist = false) private List<Menu> children = new ArrayList<>(); }
Role.java
@Data @TableName("sys_role") public class Role { private static final long serialVersionUID = 1L; @TableId(value = "id", type = IdType.AUTO) private Long id; private String roleName; private String roleKey; private Integer roleSort; private String status; private String createBy; private Date createTime; private String updateBy; private Date updateTime; private String remark; /** 菜单组 */ @TableField(exist = false) private List<Long> menuIds; }
2. Mapper 接口
MenuMapper.java
public interface MenuMapper extends BaseMapper<Menu> { /** * 根据用户ID查询菜单 * * @param userId 用户ID * @return 菜单列表 */ List<Menu> selectMenuTreeByUserId(Long userId); /** * 根据角色ID查询菜单树信息 * * @param roleId 角色ID * @return 选中菜单列表 */ List<Integer> selectMenuListByRoleId(Long roleId); }
对应 XML:
<select id="selectMenuTreeByUserId" resultType="Menu"> select distinct m.id, m.parent_id, m.menu_name, m.path, m.component, m.visible, m.status, ifnull(m.perms,'') as perms, m.is_frame, m.menu_type, m.icon, m.order_num, m.create_time from sys_menu m left join sys_role_menu rm on m.id = rm.menu_id left join sys_user_role ur on rm.role_id = ur.role_id left join sys_role ro on ur.role_id = ro.id where ur.user_id = #{userId} and m.menu_type in ('M', 'C') and m.status = '0' order by m.parent_id, m.order_num </select> <select id="selectMenuListByRoleId" resultType="Integer"> select m.id from sys_menu m left join sys_role_menu rm on m.id = rm.menu_id where rm.role_id = #{roleId} order by m.parent_id, m.order_num </select>
3. Service 层
MenuService.java
public interface MenuService { /** * 根据用户ID查询菜单树 * * @param userId 用户ID * @return 菜单列表 */ List<Menu> selectMenuTreeByUserId(Long userId); /** * 构建前端路由所需要的菜单 * * @param menus 菜单列表 * @return 路由列表 */ List<RouterVo> buildMenus(List<Menu> menus); /** * 根据角色ID查询菜单树信息 * * @param roleId 角色ID * @return 选中菜单列表 */ List<Long> selectMenuListByRoleId(Long roleId); }
MenuServiceImpl.java
@Service public class MenuServiceImpl implements MenuService { @Autowired private MenuMapper menuMapper; @Override public List<Menu> selectMenuTreeByUserId(Long userId) { List<Menu> menus = null; if (SecurityUtils.isAdmin(userId)) { menus = menuMapper.selectMenuTreeAll(); } else { menus = menuMapper.selectMenuTreeByUserId(userId); } return getChildPerms(menus, 0); } @Override public List<RouterVo> buildMenus(List<Menu> menus) { List<RouterVo> routers = new LinkedList<RouterVo>(); for (Menu menu : menus) { RouterVo router = new RouterVo(); router.setHidden("1".equals(menu.getVisible())); router.setName(getRouteName(menu)); router.setPath(getRouterPath(menu)); router.setComponent(getComponent(menu)); router.setMeta(new MetaVo(menu.getMenuName(), menu.getIcon())); List<Menu> cMenus = menu.getChildren(); if (!cMenus.isEmpty() && cMenus.size() > 0 && "M".equals(menu.getMenuType())) { router.setAlwaysShow(true); router.setRedirect("noRedirect"); router.setChildren(buildMenus(cMenus)); } routers.add(router); } return routers; } @Override public List<Long> selectMenuListByRoleId(Long roleId) { return menuMapper.selectMenuListByRoleId(roleId); } /** * 根据父节点的ID获取所有子节点 * * @param list 分类表 * @param parentId 传入的父节点ID * @return String */ private List<Menu> getChildPerms(List<Menu> list, int parentId) { List<Menu> returnList = new ArrayList<Menu>(); for (Menu menu : list) { // 一、根据传入的某个父节点ID,遍历该父节点的所有子节点 if (menu.getParentId() == parentId) { recursionFn(list, menu); returnList.add(menu); } } return returnList; } /** * 递归列表 * * @param list * @param t */ private void recursionFn(List<Menu> list, Menu t) { // 得到子节点列表 List<Menu> childList = getChildList(list, t); t.setChildren(childList); for (Menu tChild : childList) { if (hasChild(list, tChild)) { recursionFn(list, tChild); } } } /** * 得到子节点列表 */ private List<Menu> getChildList(List<Menu> list, Menu t) { List<Menu> tlist = new ArrayList<Menu>(); for (Menu menu : list) { if (menu.getParentId().longValue() == t.getId().longValue()) { tlist.add(menu); } } return tlist; } /** * 判断是否有子节点 */ private boolean hasChild(List<Menu> list, Menu t) { return getChildList(list, t).size() > 0; } /** * 获取路由名称 */ private String getRouteName(Menu menu) { String routerName = StringUtils.capitalize(menu.getPath()); return routerName; } /** * 获取路由地址 */ private String getRouterPath(Menu menu) { String routerPath = menu.getPath(); // 非外链并且是一级目录 if (0 == menu.getParentId() && "1".equals(menu.getIsFrame())) { routerPath = "/" + menu.getPath(); } return routerPath; } /** * 获取组件信息 */ private String getComponent(Menu menu) { String component = "Layout"; if (StringUtils.isNotEmpty(menu.getComponent())) { component = menu.getComponent(); } else if (menu.getParentId().intValue() != 0 && "C".equals(menu.getMenuType())) { component = "ParentView"; } return component; } }
4. Controller 层
MenuController.java
@RestController @RequestMapping("/system/menu") public class MenuController { @Autowired private MenuService menuService; /** * 获取菜单列表 */ @GetMapping("/list") public AjaxResult list(Menu menu) { List<Menu> menus = menuService.selectMenuList(menu); return AjaxResult.success(menus); } /** * 根据菜单编号获取详细信息 */ @GetMapping(value = "/{menuId}") public AjaxResult getInfo(@PathVariable Long menuId) { return AjaxResult.success(menuService.selectMenuById(menuId)); } /** * 获取菜单下拉树列表 */ @GetMapping("/treeselect") public AjaxResult treeselect() { List<Menu> menus = menuService.selectMenuList(new Menu()); return AjaxResult.success(menuService.buildMenuTreeSelect(menus)); } /** * 加载对应角色菜单列表树 */ @GetMapping(value = "/roleMenuTreeselect/{roleId}") public AjaxResult roleMenuTreeselect(@PathVariable("roleId") Long roleId) { List<Menu> menus = menuService.selectMenuList(new Menu()); AjaxResult ajax = AjaxResult.success(); ajax.put("checkedKeys", menuService.selectMenuListByRoleId(roleId)); ajax.put("menus", menuService.buildMenuTreeSelect(menus)); return ajax; } /** * 新增菜单 */ @PostMapping public AjaxResult add(@RequestBody Menu menu) { if (!menuService.checkMenuNameUnique(menu)) { return AjaxResult.error("新增菜单'" + menu.getMenuName() + "'失败,菜单名称已存在"); } menu.setCreateBy(SecurityUtils.getUsername()); return toAjax(menuService.insertMenu(menu)); } /** * 修改菜单 */ @PutMapping public AjaxResult edit(@RequestBody Menu menu) { if (!menuService.checkMenuNameUnique(menu)) { return AjaxResult.error("修改菜单'" + menu.getMenuName() + "'失败,菜单名称已存在"); } menu.setUpdateBy(SecurityUtils.getUsername()); return toAjax(menuService.updateMenu(menu)); } /** * 删除菜单 */ @DeleteMapping("/{menuId}") public AjaxResult remove(@PathVariable("menuId") Long menuId) { if (menuService.hasChildByMenuId(menuId)) { return AjaxResult.error("存在子菜单,不允许删除"); } if (menuService.checkMenuExistRole(menuId)) { return AjaxResult.error("菜单已分配,不允许删除"); } return toAjax(menuService.deleteMenuById(menuId)); } }
RouterController.java
@RestController @RequestMapping("/system/menu") public class RouterController { @Autowired private MenuService menuService; /** * 获取路由信息 */ @GetMapping("getRouters") public AjaxResult getRouters() { Long userId = SecurityUtils.getUserId(); List<Menu> menus = menuService.selectMenuTreeByUserId(userId); return AjaxResult.success(menuService.buildMenus(menus)); } }
四、前端实现 (Vue + Element UI)
1. 路由配置
router/index.js
import Vue from 'vue' import Router from 'vue-router' import Layout from '@/layout' Vue.use(Router) // 公共路由 export const constantRoutes = [ { path: '/login', component: () => import('@/views/login'), hidden: true }, { path: '/404', component: () => import('@/views/error/404'), hidden: true }, { path: '/', component: Layout, redirect: '/index', children: [ { path: 'index', component: () => import('@/views/index'), name: 'Index', meta: { title: '首页', icon: 'dashboard', affix: true } } ] } ] // 动态路由,基于用户权限动态加载 export const asyncRoutes = [] const createRouter = () => new Router({ scrollBehavior: () => ({ y: 0 }), routes: constantRoutes }) const router = createRouter() // 重置路由 export function resetRouter() { const newRouter = createRouter() router.matcher = newRouter.matcher } export default router
2. 动态路由加载
permission.js
import router from './router' import store from './store' import { Message } from 'element-ui' import NProgress from 'nprogress' import 'nprogress/nprogress.css' import { getToken } from '@/utils/auth' NProgress.configure({ showSpinner: false }) const whiteList = ['/login'] router.beforeEach(async (to, from, next) => { NProgress.start() // 确定用户是否已登录 const hasToken = getToken() if (hasToken) { if (to.path === '/login') { next({ path: '/' }) NProgress.done() } else { const hasRoles = store.getters.roles && store.getters.roles.length > 0 if (hasRoles) { next() } else { try { // 获取用户信息 const { roles } = await store.dispatch('user/getInfo') // 根据角色生成动态路由 const accessRoutes = await store.dispatch('permission/generateRoutes', roles) // 添加动态路由 router.addRoutes(accessRoutes) // 确保addRoutes已完成 next({ ...to, replace: true }) } catch (error) { // 移除token并跳转到登录页 await store.dispatch('user/resetToken') Message.error(error || 'Has Error') next(`/login?redirect=${to.path}`) NProgress.done() } } } } else { if (whiteList.indexOf(to.path) !== -1) { next() } else { next(`/login?redirect=${to.path}`) NProgress.done() } } }) router.afterEach(() => { NProgress.done() })
3. 菜单组件
Sidebar/index.vue
<template> <div :class="{'has-logo':showLogo}"> <logo v-if="showLogo" :collapse="isCollapse" /> <el-scrollbar wrap-class="scrollbar-wrapper"> <el-menu :default-active="activeMenu" :collapse="isCollapse" :background-color="variables.menuBg" :text-color="variables.menuText" :unique-opened="false" :active-text-color="variables.menuActiveText" :collapse-transition="false" mode="vertical" > <sidebar-item v-for="route in routes" :key="route.path" :item="route" :base-path="route.path" :is-collapse="isCollapse" /> </el-menu> </el-scrollbar> </div> </template> <script> import { mapGetters } from 'vuex' import Logo from './Logo' import SidebarItem from './SidebarItem' import variables from '@/styles/variables.scss' export default { components: { SidebarItem, Logo }, computed: { ...mapGetters([ 'sidebar' ]), routes() { return this.$router.options.routes }, activeMenu() { const route = this.$route const { meta, path } = route if (meta.activeMenu) { return meta.activeMenu } return path }, showLogo() { return this.$store.state.settings.sidebarLogo }, variables() { return variables }, isCollapse() { return !this.sidebar.opened } } } </script>
SidebarItem.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)"> <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 in item.children" :key="child.path" :is-nest="true" :item="child" :base-path="resolvePath(child.path)" class="nest-menu" /> </el-submenu> </div> </template> <script> import path from 'path' import { isExternal } from '@/utils/validate' import Item from './Item' import AppLink from './Link' export default { name: 'SidebarItem', components: { Item, AppLink }, props: { // route object item: { type: Object, required: true }, isNest: { type: Boolean, default: false }, basePath: { type: String, default: '' } }, data() { this.onlyOneChild = null return {} }, methods: { hasOneShowingChild(children = [], parent) { const showingChildren = children.filter(item => { if (item.hidden) { return false } else { // Temp set(will be used if only has one showing child) this.onlyOneChild = item return true } }) // When there is only one child router, the child router is displayed by default if (showingChildren.length === 1) { return true } // Show parent if there are no child router to display if (showingChildren.length === 0) { this.onlyOneChild = { ... parent, path: '', noShowingChildren: true } return true } return false }, resolvePath(routePath) { if (isExternal(routePath)) { return routePath } if (isExternal(this.basePath)) { return this.basePath } return path.resolve(this.basePath, routePath) } } } </script>
4. 权限存储
store/modules/permission.js
import { asyncRoutes, constantRoutes } from '@/router' /** * 使用meta.role确定当前用户是否具有权限 * @param roles * @param route */ function hasPermission(roles, route) { if (route.meta && route.meta.roles) { return roles.some(role => route.meta.roles.includes(role)) } else { return true } } /** * 递归过滤异步路由表 * @param routes asyncRoutes * @param roles */ export function filterAsyncRoutes(routes, roles) { const res = [] routes.forEach(route => { const tmp = { ...route } if (hasPermission(roles, tmp)) { if (tmp.children) { tmp.children = filterAsyncRoutes(tmp.children, roles) } res.push(tmp) } }) return res } const state = { routes: [], addRoutes: [] } const mutations = { SET_ROUTES: (state, routes) => { state.addRoutes = routes state.routes = constantRoutes.concat(routes) } } const actions = { generateRoutes({ commit }, roles) { return new Promise(resolve => { let accessedRoutes if (roles.includes('admin')) { accessedRoutes = asyncRoutes || [] } else { accessedRoutes = filterAsyncRoutes(asyncRoutes, roles) } commit('SET_ROUTES', accessedRoutes) resolve(accessedRoutes) }) } } export default { namespaced: true, state, mutations, actions }
五、功能扩展
1. 菜单缓存
// router/index.js { path: 'user', component: () => import('@/views/system/user/index'), name: 'User', meta: { title: '用户管理', icon: 'user', noCache: true } }
2. 面包屑导航
// 在路由meta中添加breadcrumb属性 { path: 'edit', component: () => import('@/views/system/user/edit'), name: 'UserEdit', meta: { title: '用户编辑', breadcrumb: false } }
3. 菜单权限按钮控制
// 在菜单表中添加按钮权限 { path: 'add', component: () => import('@/views/system/user/components/AddUser'), name: 'AddUser', meta: { title: '新增用户', roles: ['admin'] } }
六、常见问题解决方案
1. 刷新后动态路由丢失
解决方案:在app.vue中监听路由变化
// app.vue watch: { $route: { handler: function(val, oldVal) { if (val.path !== oldVal?.path) { this.$store.dispatch('tagsView/delAllCachedViews') } }, immediate: true } }
2. 菜单图标不显示
解决方案:确保图标名称与Element UI图标一致
meta: { title: '用户管理', icon: 'user' }
3. 路由跳转404
解决方案:确保动态路由添加完成后才跳转
// permission.js next({ ...to, replace: true })
4. 菜单排序问题
解决方案:在数据库中添加order_num字段并在查询时排序
SELECT * FROM sys_menu ORDER BY parent_id, order_num
七、性能优化建议
- 菜单缓存:使用localStorage缓存菜单数据,减少请求
- 懒加载:路由组件使用懒加载
- 按需加载:只加载当前用户有权限的菜单
- 减少重绘:使用keep-alive缓存常用页面
八、总结
通过本文的实现方案,你可以构建一个完整的动态菜单系统,具有以下特点:
- 灵活配置:通过数据库管理菜单结构
- 权限控制:基于角色的细粒度权限控制
- 前后端分离:清晰的API接口定义
- 易于扩展:支持菜单缓存、面包屑等功能扩展
这种架构适合大多数中后台管理系统,能够满足企业级应用的权限管理需求。