SpringBoot后台动态路由菜单编辑与权限管理实战解析
发布时间:2026/10/13 11:54:23 锦皓数字建站

搞后台管理系统这几年我几乎在每个项目里都会碰到同一个需求动态维护后台菜单今天改个路由明天加个页面后天又要调整权限层级。SpringBoot作为后端主力框架Admin管理后台里的路由菜单编辑看似只是对左侧导航栏做增删改查实际上牵一发而动全身——菜单结构决定前端路由映射菜单权限字段决定角色可见性父级ID决定层级树的递归逻辑。这篇文章我就以一次完整的迭代为例把SpringBoot里编辑Admin路由菜单从表设计、后端接口、前端联动到常见坑位完整梳理一遍适合正在做后台权限模块、或者想搞清楚动态菜单原理的同学直接参考。1. 这个功能到底在做什么从权限系统的全局视角看菜单编辑1.1 菜单不只是左侧导航它是权限体系的骨架很多同学第一次接手Admin后台时会觉得菜单管理不就是维护一张表、提供一个编辑弹窗嘛前端把数据拉下来渲染成菜单栏就完事了。但真正在项目里跑过一轮就会发现菜单这个实体是整个RBAC权限模型的骨架。先说路由。Admin后台的前端框架像Vue Element Admin、React Ant Design Pro这类本质上都是路由驱动页面渲染。你访问/admin/user前端路由组件去加载用户管理页面。如果这个路由不是动态注册的那就只能靠前端写死。而权限系统要求的是不同角色登录后看到的菜单不一样能访问的路由也不一样。这个动态能力只能依靠后端返回的菜单数据来驱动。再说权限。菜单表里通常会有perms字段权限标识比如system:user:list。后端做接口鉴权时PreAuthorize(hasAuthority(system:user:list))就是拿当前登录用户的权限集合和这个标识做匹配。如果菜单数据和权限标识脱钩就会出现菜单看得见、接口调不通或者反过来菜单没有、接口却能暴力访问的混乱状态。所以编辑Admin路由菜单表面上是一个CRUD功能实际是在维护一套前端路由表 后端权限表的联动数据源。每次新增一个页面模块不是写一行菜单记录就完事而是要同步考虑路由地址、组件路径、图标、排序、父子层级、可见状态还有权限标识这六个维度。1.2 编辑动作背后涉及的四大核心模型把功能拆开看一次完整的菜单编辑交互会牵动如下几个模型菜单实体Menu描述菜单本身的属性包括菜单名称、路由地址、组件路径、权限标识、菜单类型目录/菜单/按钮、排序号、父级ID、可见状态。角色实体Role系统里会有超级管理员、普通管理员、运营人员等角色每个角色对应一个roleId。角色-菜单关系RoleMenu这是经典的多对多关联表一个角色可以拥有多个菜单一个菜单也可以被多个角色拥有。前端路由表RouteTable它由后端根据当前登录用户拥有的菜单数据动态拼接而成每次登录后重新生成。在动手写代码之前先在脑子里把编辑一个菜单这个动作可能引发的连锁反应过一遍编辑名称影响左侧菜单文案编辑路由地址影响URL访问编辑组件路径影响页面加载编辑权限标识影响接口鉴权结果调整父级ID影响树结构删除菜单需要清理角色关联。这样想清楚了后面开发时才不会漏掉逻辑。1.3 适合谁来读这篇文章如果你正在写后台管理系统的用户权限模块或者手头的项目正好需要把死菜单改成活菜单这篇文章的内容会比较对口。我会把第28章功能的完整实现链路拆开讲从表结构、接口设计、递归树处理再到前端动态路由注册的配合方式最后是实际开发中常见的异常场景。前端侧重Vue体系后端是SpringBoot但核心思路换到其他技术栈同样适用。2. 表结构设计先想清楚数据怎么存代码才不会白写2.1 菜单表字段详解路由地址、组件路径、权限标识一个都不能少我先给出一个经过实践检验的菜单表结构再用文字逐个解释关键字段的用途和取舍。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 父菜单ID0表示根菜单, order_num int(4) DEFAULT 0 COMMENT 显示顺序, path varchar(200) DEFAULT COMMENT 路由地址, component varchar(255) DEFAULT NULL COMMENT 组件路径, query_param varchar(255) DEFAULT NULL COMMENT 路由参数, is_frame tinyint(1) DEFAULT 1 COMMENT 是否外链1是 0否, is_cache tinyint(1) DEFAULT 0 COMMENT 是否缓存1缓存 0不缓存, menu_type char(1) DEFAULT COMMENT 菜单类型M目录 C菜单 F按钮, visible tinyint(1) DEFAULT 0 COMMENT 显示状态0显示 1隐藏, status tinyint(1) DEFAULT 0 COMMENT 状态0正常 1停用, perms varchar(100) DEFAULT NULL COMMENT 权限标识, icon varchar(100) DEFAULT NULL 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 更新时间, PRIMARY KEY (menu_id) ) ENGINEInnoDB AUTO_INCREMENT2000 DEFAULT CHARSETutf8mb4 COMMENT菜单权限表;这里有几个字段我重点说一下为什么必须有。path和component这两个字段容易混淆。path是浏览器地址栏里的路由地址比如/system/user它负责决定URL长什么样component是前端页面的物理文件路径比如system/user/indexVue Router在动态添加路由时会把字符串转成真正的组件加载函数。后台编辑菜单时操作者改的是path和component这两个字符串前端拿到新值后重新注册路由。两者一旦不匹配前端就会白屏或者404后面会细说。menu_type字段区分的是目录、菜单、按钮三类。这个设计一开始容易被忽略但实际作用很大。目录M是最顶层的分组只承载子菜单本身不指向具体页面菜单C是真正可以点击跳转的页面入口按钮F用于页面内部的权限控制比如新增、删除、导出按钮要不要显示。三类数据放在同一张表里好处是权限标识可以统一管理坏处是树结构拼接时要做类型判断。perms字段就是权限标识。约定俗成的格式是模块:功能:操作比如system:menu:add。给某个角色分配菜单后后端服务启动时扫描所有PreAuthorize注解里的权限标识和用户拥有的权限标识集合做匹配。所以菜单表里的perms是后端鉴权的钥匙编辑它时要特别小心改错了会导致接口权限集体失效。visible字段控制菜单在前端的显示/隐藏。有一个细节是隐藏菜单不等于移除路由只是不在侧边栏渲染出来。有些页面需要直接通过URL访问但是不想出现在菜单里比如详情页就可以用这个字段实现。2.2 角色和菜单的关联关系经典RBAC的多对多建模菜单表只是静态资源真正生效要靠角色和菜单建立关系。完整的模型至少需要两张表角色表和角色-菜单关联表。角色表的字段比较固定这里重点看关联表。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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT角色和菜单关联表;这张表不加自增主键是为了防止重复。联合主键role_id menu_id天然保证一个角色对一个菜单只能关联一次开发时省掉一次唯一性校验。有人会问为什么不让sys_menu表里直接放一个role_id字段或者把角色和菜单的关系做成JSON字段存在角色表里我的回答是多对多关系在关系型数据库里最合理的设计就是独立关联表。因为菜单和角色各自都有独立的增删改查场景如果菜单表里放角色ID那一个菜单被多个角色共享的时候就只能拆成多条记录菜单本身的公共属性名称、路由就被大量冗余了改一次名字要同步多条记录很容易不一致。关联表是直接的、不容易出错的做法。2.3 树结构与递归存储为什么用parent_id而不是层级字段菜单的层级管理用的是经典的parent_id自关联方式根节点的parent_id设为0。每一条记录只记录自己的直接父级整棵树靠递归查询拼出来。还有一种方案是在表里加ancestors字段存储从根节点到当前节点的完整ID链路比如0,1,10,25。它的好处是查询某个节点下的所有子节点时只需WHERE ancestors LIKE 0,1,%不需要递归性能更可控缺点是维护成本更高每次移动节点都要重新计算整个子树的祖先链。对于后台管理系统的菜单数量级通常几百条以内parent_id加内存递归完全够用。实际开发中我甚至建议把菜单树拼好了直接缓存起来而不是每次请求都查数据库这样递归的性能问题在根源上就解决了。表结构保持简单树形结构在服务层做可读性和维护性都更好。菜单排序靠order_num字段。同一个父节点下的子节点按order_num升序排列。排序逻辑在查树形结构时执行ORDER BY order_num ASC。3. 后端接口实现从Controller到Service的完整链路3.1 接口设计先分清楚哪些场景在编辑菜单后端接口我会按树查询、详情、新增、修改、删除来规划。每个接口的定位和注意事项都很清晰代码写起来反而简单。RestController RequestMapping(/api/menu) public class SysMenuController { Autowired private ISysMenuService menuService; /** * 查询菜单列表树形 */ GetMapping(/list) PreAuthorize(ss.hasPermi(system:menu:list)) public AjaxResult list(SysMenu menu) { ListSysMenu menus menuService.selectMenuList(menu); return AjaxResult.success(menuService.buildMenuTree(menus)); } /** * 根据菜单ID获取详情 */ GetMapping(/{menuId}) PreAuthorize(ss.hasPermi(system:menu:query)) public AjaxResult getInfo(PathVariable Long menuId) { return AjaxResult.success(menuService.selectMenuById(menuId)); } /** * 新增菜单 */ PostMapping PreAuthorize(ss.hasPermi(system:menu:add)) public AjaxResult add(RequestBody SysMenu menu) { return toAjax(menuService.insertMenu(menu)); } /** * 修改菜单 */ PutMapping PreAuthorize(ss.hasPermi(system:menu:edit)) public AjaxResult edit(RequestBody SysMenu menu) { return toAjax(menuService.updateMenu(menu)); } /** * 删除菜单 */ DeleteMapping(/{menuId}) PreAuthorize(ss.hasPermi(system:menu:remove)) public AjaxResult remove(PathVariable Long menuId) { return toAjax(menuService.deleteMenuById(menuId)); } }接口设计的核心原则是所有写操作都要校验操作者的权限而且权限标识要和菜单表里定义的perms字段配套。比如前端要能调用新增菜单接口那么这个用户必须有system:menu:add这个权限标识而这个权限标识恰恰又是在菜单表里配置、通过角色分配出去的。这就形成了一个闭环没有菜单管理权限的人连管理菜单的按钮都看不到。ss.hasPermi这个写法是自定义权限校验注解的典型用法实际项目中会根据登录用户Session里保存的权限集合做匹配。校验失败时Spring Security会抛出AccessDeniedException返回403状态码。3.2 新增与编辑菜单的核心业务逻辑校验都做了哪些事我先从新增菜单的核心代码看起。Override public int insertMenu(SysMenu menu) { // 合法性校验 verifyMenu(menu); // 处理父菜单ID为空的情况 if (menu.getParentId() null) { menu.setParentId(0L); } // 如果菜单关联了父菜单检查父菜单是否存在、是否被停用 if (menu.getParentId() ! 0 !checkMenuExist(menu.getParentId())) { throw new ServiceException(父菜单不存在); } // 新增菜单默认状态为正常 if (menu.getStatus() null) { menu.setStatus(0); } // 写入数据库 return menuMapper.insertMenu(menu); }新增菜单时的校验逻辑看起来简单实际有几个容易被忽略的检查点第一menu_type合法值只允许M、C、F三种。目录M不应该有component字段按钮F不需要path和component。如果前端传来的数据不符合这个约束应该在服务端直接拒绝避免脏数据进入数据库。第二同一父节点下menu_name不能重复。这是业务层面的唯一性约束数据库层面可以不加联合唯一索引但服务层一定要查。否则后台左侧导航会出现两个一模一样的顶层菜单用户根本分不清该点哪个。第三编辑操作时不允许把父节点设置成自己或者自己的子孙节点。否则会形成循环引用树形递归直接变成死循环整个过程要特别小心。这里给出一段防环校验逻辑Override public int updateMenu(SysMenu menu) { // 防环校验不允许把父节点设置为自己或自己的子孙 if (menu.getMenuId().equals(menu.getParentId())) { throw new ServiceException(上级菜单不能选择自己); } SysMenu oldMenu menuMapper.selectMenuById(menu.getMenuId()); if (oldMenu null) { throw new ServiceException(菜单不存在); } // 检查新父节点是否在旧菜单的子树中 ListSysMenu children menuMapper.selectChildrenMenuById(menu.getMenuId()); for (SysMenu child : children) { if (child.getMenuId().equals(menu.getParentId())) { throw new ServiceException(上级菜单不能选择自己的子菜单); } } return menuMapper.updateMenu(menu); }这里用了查所有子孙节点的方式来做防环校验逻辑直观。还有一种方案是在数据库层面用ancestors字段维护链路校验时直接比较祖先链是否包含当前菜单ID性能更好但表结构和维护逻辑会更复杂。我之前在项目里用过ancestors方案如果是中小型后台用parent_id加服务端校验就够了不用过度设计。3.3 删除菜单时的三件套检查子级、清理角色关联、递归处理删除菜单是所有操作里面坑最多的地方。因为菜单不是孤立存在的它有孩子、有关联角色、有前端路由缓存三个环节都要处理到位。Override public int deleteMenuById(Long menuId) { // 1. 检查是否存在子菜单 int childCount menuMapper.selectCountByParentId(menuId); if (childCount 0) { throw new ServiceException(存在子菜单不允许删除); } // 2. 删除角色-菜单关联记录 roleMenuMapper.deleteRoleMenuByMenuId(menuId); // 3. 删除菜单本身 return menuMapper.deleteMenuById(menuId); }第一步存在子菜单不允许删除是业务上比较常见的约定。好处是操作简单一次只删一个节点不会误删大范围数据坏处是用户要删一个带子节点的父菜单时得先手动删掉所有子菜单体验一般。也有项目用递归删除方案就是先查所有子孙节点一次性全删掉。两种方案我都用过如果走递归删除要注意清理角色关联时用IN语句批量删别一条条循环删性能差很多。第二步删除角色菜单关联很多人容易漏。如果只删sys_menu表不删sys_role_menu表关联表里会残留一堆无效数据。表面上不影响前端功能但只要后面做角色授权列表检索比如查询某些角色已分配的菜单ID这些脏数据就会让列表里出现已经不存在的菜单ID前端比对时就对不上排查起来非常费劲。3.4 动态菜单加载后端怎么把当前用户可见菜单树返回给前端菜单编辑好了最终目标是把当前登录用户能看到的菜单树返回给前端渲染。这一步的核心逻辑是根据用户角色去关联菜单再拼树。Override public ListSysMenu selectMenuTreeByUserId(Long userId) { ListSysMenu menus null; // 超级管理员拥有全部菜单 if (userId ! null isAdmin(userId)) { menus menuMapper.selectMenuAll(); } else { // 普通用户按角色关联的菜单查 menus menuMapper.selectMenuListByUserId(userId); } // 递归构建树 return buildMenuTree(menus); }这里有一个关键的性能问题如果每次登录都实时查数据库拼树菜单多一点、用户多一点数据库压力会明显上升。因为菜单数据属于低频变动数据我在实际项目中会在第一次查询后把菜单树放进Redis缓存键名按用户区分比如menu:tree:{userId}编辑菜单或角色授权变更时主动删掉相关缓存让下次请求重新加载这样效果会比较理想。构建菜单树的递归代码值得单独看一下递归本身不难但有几个细节容易出错。public ListSysMenu buildMenuTree(ListSysMenu menus) { ListSysMenu returnList new ArrayList(); ListLong tempList new ArrayList(); // 先把所有菜单ID存起来 for (SysMenu menu : menus) { tempList.add(menu.getMenuId()); } // 遍历找根节点父ID不在当前列表中的或者父ID为0的 for (SysMenu menu : menus) { if (!tempList.contains(menu.getParentId())) { recursionFn(menus, menu); returnList.add(menu); } } // 如果根节点为空极端情况所有节点的父ID都在列表内但不存在真正的根 if (returnList.isEmpty()) { returnList menus; } return returnList; } private void recursionFn(ListSysMenu allMenus, SysMenu parent) { ListSysMenu childList getChildList(allMenus, parent); parent.setChildren(childList); for (SysMenu child : childList) { recursionFn(allMenus, child); } }这段递归代码的核心思路是先找根节点再从根往下挂子节点。第一层循环里用!tempList.contains(menu.getParentId())来判断根节点意思是如果这个菜单的父ID在所有菜单列表里都不存在那它只能是最顶层的根节点。这种写法比起单纯判断parentId 0要更健壮因为万一用户把父菜单删了导致子菜单的parentId指向一个不存在的ID这个子菜单也会被当作根节点查出来前端不至于完全看不到这条菜单。实际开发中我还遇到过一种情况菜单的parentId指向了自身这就是之前防环校验没做好的结果。三层以上的树一旦有环递归栈就爆了所以防环校验不能省。4. 前端联动路由菜单动态渲染的核心原理4.1 前端路由的两种模式静态写死 vs 动态生成如果前端路由全部写死那后端菜单表就没意义了。动态菜单的精髓是前端只定义路由组件如何加载的规则具体有哪些路由、层级结构、显示顺序全部由后端返回的菜单数据决定。在Vue体系中通常采用的做法是路由配置文件里只保留登录页、404页、布局框架这些固定页面业务模块路由由后端菜单数据在用户登录后动态生成。核心代码如下// 递归把后端菜单数据转换成Vue Router需要的路由对象 function buildRoutes(menus) { const routes [] menus.forEach(menu { if (menu.menuType M || menu.menuType C) { const route { path: menu.path, name: menu.menuName, component: loadComponent(menu.component), meta: { title: menu.menuName, icon: menu.icon }, children: [] } if (menu.children menu.children.length 0) { route.children buildRoutes(menu.children) } routes.push(route) } }) return routes } // 登录后动态添加路由 const dynamicRoutes buildRoutes(menuTree) router.addRoutes(dynamicRoutes)注意看loadComponent函数这是动态路由最常见的坑点之一。function loadComponent(componentPath) { // 如果组件路径为空说明是目录节点不加载组件 if (!componentPath) { return undefined } // 关键使用动态import路径必须是相对src的 return () import(/views/${componentPath}) }Vue的动态import在Webpack构建时有严格的路径约束变量不能完全动态拼接必须包含固定的目录前缀/views/。这就是为什么菜单表里的component字段一般存的是相对于views目录的路径比如system/user/index而不是/src/views/system/user/index。如果存了绝对路径前端这里就容易出问题。4.2 前端布局与二级菜单的层级映射后端返回的菜单树通常是三级结构顶级目录如系统管理、二级菜单如用户管理、按钮权限如新增用户。前端布局组件会按这个层级渲染侧边栏。目录类型的节点M在侧边栏渲染成一个可以展开的分组它的path只用于前端路由的父子嵌套并不会真的匹配到页面。菜单类型的节点C是叶子节点点击后跳转。按钮类型的节点F不会渲染到侧边栏它通常用于页面内部的权限控制比如通过v-ifcheckPermi([system:user:add])来控制新增按钮的显示。这里有个容易踩的坑后端返回的菜单树里按钮节点如果被当作普通节点递归处理了前端构建路由时就会把按钮也当成路由节点生成一个无法访问的空页面。所以前端在buildRoutes函数里要做类型过滤只处理M和C类型F类型跳过。4.3 刷新后菜单消失的处理方案动态路由最大的痛点是刷新浏览器后Vue Router恢复初始状态动态添加的路由全部丢失。如果不做处理用户一按F5就会变成404。解决办法通常有两种。第一种是每次刷新后重新调用获取用户信息接口让后端重新返回菜单树前端重新addRoutes。这个方案在用户登录时和页面刷新时都会执行一次。第二种是把菜单信息存储在本地持久化存储里刷新后先读本地缓存没有再请求后端。第二种体验更快但有个隐患是如果管理员在后台改了菜单权限用户本地缓存的旧菜单不会自动更新至少会影响一次会话这个现状要接受或者在接口层做版本号对比来规避。我推荐第三种方案整合起来用用户登录成功后立刻拉取菜单树存到本地持久化存储每次路由刷新时先读本地同时异步请求后端最新菜单做版本对比发现不一致就更新本地并强制刷新路由表。这套方案兼顾速度和一致性实际项目里效果比较好。5. 实战中的坑与排查记录5.1 新增菜单后前端看不到三层缓存问题开发环境里新增一个菜单前端怎么刷新都看不到。这类问题出现后建议按顺序排查三层缓存第一层是Redis缓存。很多后端项目为了性能会把用户菜单树缓存起来管理员新增菜单后没有主动清缓存用户拿到的还是旧数据。这种一般是顺带的开发问题解决方法是编辑菜单的Service方法里主动清除该用户的菜单缓存或者干脆在角色授权变更时统一清理。第二层是前端本地持久化存储里的路由表。前面提到过方案里会把菜单数据存本地如果删除菜单时逻辑只做了后端数据变更前端缓存的旧路由还留在本地侧边栏就会一直显示旧菜单。第三层是浏览器路由自身的缓存。单页应用本身没有这个问题但如果你用了路由懒加载并且浏览器缓存了旧的JS文件偶尔也会出现改了路由地址但跳转后还是旧页面的情况。这种一般强刷CtrlF5能解决如果还不行就检查Webpack的chunkhash命名是否正确。5.2 递归查询导致的栈溢出与N1问题菜单树拼接通常是一次性查出所有菜单在内存里递归构建不存在N1问题。但如果一开始没有做树结构而是写成了每次查子节点都发一次SQL那菜单一多数据库压力就会上去而且很容易被领导和同事吐槽性能。我的建议是菜单数据量小全量查出来内存构建完全足够。即便是几千条菜单一次查询加内存递归也只需要几十毫秒。当然如果菜单表数据量到了几万条那就得考虑用ancestors链路方案或者引入其他处理方式不过这种规模在后台管理系统里很少见。循环引用的栈溢出问题前面已经提过一旦菜单的parentId成环递归函数永远找不到出口直接StackOverflowError。排查方法也很简单构造树之前先遍历一次检查每条菜单的parentId链路是否最终指向0发现超过树深度的链路直接报错。代码里可以加一个深度限制参数比如递归深度超过20就终止并抛出异常。5.3 删除父菜单导致子系统崩溃如果删除逻辑没有检查子菜单删了父菜单后子菜单的parentId成了野指针前端构建菜单树时把孤儿节点强行提升为根节点侧边栏出现一群没名字的菜单。更麻烦的是角色菜单关联表里一大堆失效数据用户授权界面会出现选项对不上号的情况。我在项目中采用的是存在子菜单禁止删除的策略虽然多了一步手动删除操作但数据安全性大大提高。如果产品和用户都接受不了这个交互那递归删除方案要记得删除前先查出所有子孙菜单ID集合然后在角色菜单表里一次性DELETE FROM sys_role_menu WHERE menu_id IN (...),不要一条条删。5.4 路由地址path与组件component错配的排查前端点击某个菜单跳转后页面空白或者报Failed to resolve component几乎都是path和component没配对。举个例子path存了/system/usercomponent存了system/user/index这种是对的。但有时候前端框架的路由要求path不加斜杠或者要加上层级前缀否则父子路由嵌套后URL对不上。排查时打开浏览器开发者工具看路由表里实际注册的路径是什么再和菜单表里的path字段比对。组件路径错配是另一个常见原因常见如后端在菜单里存了views/system/user/index前端动态import又自动加了/views/前缀实际变成了/views/views/system/user/index这种就会白屏。约定好存储格式非常重要统一存相对路径不带头尾斜杠并且要反复跟前端同学强调。6. 从菜单编辑到权限管控的延伸思考6.1 按钮权限与菜单权限的联动设计菜单表里的menu_type字段支持按钮F类型意味着菜单编辑功能天然可以做到页面级 操作级两级权限控制。比如编辑Admin路由菜单里面新增菜单按钮、 删除菜单按钮、 分配权限按钮都可以在菜单表里各占一条F类型记录分配角色时按需勾选。前端配合v-permission这类自定义指令后端配合PreAuthorize就能做到角色A能看到菜单页面但看不到删除按钮角色B既能看到页面又能删除。这个组合拳做好以后整个后台的权限精细度会提升一个档次。6.2 数据权限的引入菜单权限解决的是能不能看到这个页面/按钮的问题数据权限解决的是看到数据后只能操作其中一部分的问题。比如销售管理菜单普通销售只能看自己的客户销售总监能看整个团队的客户。这类需求靠菜单表是解决不了的需要在业务表里加入数据权限维度比如创建人ID、部门ID然后在查询时做数据过滤。## 写在最后说句实在话编辑Admin路由菜单这个功能如果只当成一个简单的CRUD来做半天就能写完。但要是想把它做成一个经得起上线考验的权限模块表结构、树构建、防环校验、缓存清理、前后端路由联动、异常场景处理每一环节都有不少细节需要考虑。我在多个项目里用过这套设计目前来看稳定性和可维护性都比较可靠。如果你在做的后台管理系统正好处于菜单写死、权限混乱的阶段完全可以参照这里的思路逐步重构。还有一个小提醒任何菜单数据变更之后记得先清缓存再测试不然你会被各种改了没生效的问题搞到怀疑人生这是我踩过坑之后最想分享的经验。
锦
锦皓数字建站
深耕本土企业品牌数字化升级,专注原创端正雅致商务官网,从视觉设计到稳定运维全程保驾护航。