Vue3 动态路由与按钮级权限:后台管理系统的权限设计
登录后看到什么菜单、能点什么按钮——权限是后台系统的骨架。这篇文章从若依的 RBAC 模型出发讲清楚前端动态路由怎么生成、按钮级权限怎么控制、以及四个你一定踩过的坑。一、权限这件事难在哪里做后台管理系统权限永远是最先碰到、最后才做对的东西。大多数项目的权限演化路径是这样的阶段一hardcode 几个角色 → if (role admin) 满天飞 阶段二角色-菜单绑定 → 但菜单变更要改代码 阶段三RBAC 模型 → 用户-角色-菜单动态配置 阶段四按钮级 数据级权限 → 精细化到每个操作若依框架帮我们直接跳过了前两个阶段实现了阶段三。阶段四需要自己扩展。本文基于若依的权限模型讲清楚前端侧的完整实现。二、若依的 RBAC 权限模型2.1 五张核心表sys_user ──┐ ├── sys_user_role ── sys_role ── sys_role_menu ── sys_menu sys_dept ──┘表作用sys_user用户表sys_role角色表admin、普通用户、审核员...sys_menu菜单/权限表目录、菜单、按钮三类sys_user_role用户-角色关联sys_role_menu角色-菜单/权限关联权限粒度分三级类型例子存储位置目录系统管理、CRM 管理sys_menu(menu_typeM)菜单用户列表、客户管理sys_menu(menu_typeC)按钮新增、删除、导出sys_menu(menu_typeF)存权限标识2.2 权限标识的设计这是若依权限体系最精妙的地方——每个按钮在数据库里对应一条sys_menu记录字段perms存的是权限字符串system:user:list —— 用户列表查询 system:user:add —— 用户新增 system:user:edit —— 用户编辑 system:user:remove —— 用户删除一个字符串 一个权限点。前端用指令控制显示后端用注解二次校验。双重保障缺一不可。三、前端动态路由登录后看到什么菜单3.1 整体流程用户登录 → 后端返回 token 用户信息 ↓ 前端请求路由数据GET /getRouters ↓ 后端根据用户角色查询对应菜单组装成树形路由 ↓ 前端用 router.addRoute() 动态注册 ↓ 左侧菜单渲染完成3.2 后端返回的路由结构[ { name: System, path: /system, component: Layout, meta: { title: 系统管理, icon: system }, children: [ { name: User, path: user, component: system/user/index, meta: { title: 用户管理, icon: user }, children: [] } ] } ]后端把菜单表转成前端路由树关键逻辑在若依的SysMenuServiceImpl.buildMenus()方法中。3.3 前端动态注册// router/index.js import { getRouters } from /api/menu const router createRouter({ history: createWebHistory(), routes: constantRoutes // 只有登录页、404 等静态路由 }) // 路由守卫 router.beforeEach(async (to, from, next) { const userStore useUserStore() if (!userStore.token) { // 未登录跳登录页白名单除外 return to.path /login ? next() : next(/login) } if (!userStore.menus.length) { try { // 第一次登录拉取动态路由 const res await getRouters() userStore.setMenus(res.data) // 动态注册 const asyncRoutes buildAsyncRoutes(res.data) asyncRoutes.forEach(route router.addRoute(route)) // 重定向到目标页解决刷新后 404 return next({ ...to, replace: true }) } catch (err) { userStore.logout() return next(/login) } } next() })3.4 把后端路由数据转成 Vue Router 格式// utils/generateRoutes.js export function buildAsyncRoutes(menuList, parentPath ) { return menuList .filter(item item.component) // 过滤掉目录类型 .map(item { const route { path: parentPath / item.path.replace(/^\//, ), name: item.name, component: loadComponent(item.component), // 动态 import meta: { title: item.meta?.title || item.name, icon: item.meta?.icon || } } if (item.children?.length) { route.children buildAsyncRoutes(item.children, route.path) } return route }) } // 动态加载组件关键 function loadComponent(componentPath) { // component 格式system/user/index return () import(/views/${componentPath}.vue) }四、按钮级权限能点什么按钮4.1 权限数据从哪来登录时后端返回当前用户的所有权限标识数组// userStore { roles: [admin], permissions: [ system:user:list, system:user:add, system:user:edit, system:user:remove ] }4.2 若依自带的 v-hasPermi 指令el-button v-hasPermi[system:user:add] typeprimary新增/el-button el-button v-hasPermi[system:user:remove] typedanger删除/el-button指令实现// directives/permission.js export default { mounted(el, binding) { const { value } binding // [system:user:add] const permissions useUserStore().permissions if (value Array.isArray(value)) { const hasPermission value.some(perm permissions.includes(perm)) if (!hasPermission) { el.parentNode?.removeChild(el) // 直接移除 DOM } } } }4.3 扩展v-hasRole 指令若依只提供了v-hasPermi但实际业务中经常需要按角色控制// directives/role.js export default { mounted(el, binding) { const { value } binding // [admin, supervisor] const roles useUserStore().roles if (value Array.isArray(value)) { const hasRole value.some(role roles.includes(role)) if (!hasRole) { el.parentNode?.removeChild(el) } } } }el-button v-hasRole[admin] typedanger重置密码/el-button4.4 函数式判断在一些不方便用指令的场景// utils/auth.js export function checkPermi(permission) { const permissions useUserStore().permissions return permissions.includes(permission) } export function checkRole(role) { const roles useUserStore().roles return roles.includes(role) }// 在 setup 中使用 import { checkPermi } from /utils/auth const canDelete computed(() checkPermi(system:user:remove))五、四个你一定踩过的坑坑 1刷新后白屏——动态路由丢失现象登录后正常F5 刷新页面白屏或跳 404。原因Vue Router 的路由是运行时addRoute动态添加的刷新后内存清空路由没了。解决router.beforeEach(async (to) { if (userStore.token !userStore.menus.length) { // 重新拉取路由 const routes await getRouters() userStore.setMenus(routes.data) buildAsyncRoutes(routes.data).forEach(r router.addRoute(r)) return { ...to, replace: true } // 关键重定向到目标页 } })坑 2v-hasPermi 在 v-for 中不起作用现象表格操作列的按钮权限控制不了。原因v-hasPermi和v-for同时在一个元素上时指令可能在v-for渲染前就执行了。解决外包一层 templateel-table-column label操作 template #default{ row } template v-hasPermi[system:user:edit] el-button clickhandleEdit(row)编辑/el-button /template /template /el-table-column坑 3后端注解忘记加——前端控制了后端裸奔现象前端按钮隐藏了但直接调接口还是能操作。原因若依的按钮权限是前端显示控制真正的安全在后端注解PreAuthorize。// ❌ 漏了注解前端隐藏了但接口裸奔 PostMapping public AjaxResult add(RequestBody SysUser user) { userService.insertUser(user); return success(); } // ✅ 前后端双重校验 PostMapping PreAuthorize(ss.hasPermi(system:user:add)) public AjaxResult add(RequestBody SysUser user) { userService.insertUser(user); return success(); }前端权限是礼貌后端权限是安全。坑 4权限标识写死在多处代码里现象system:user:add这个字符串散落在 10 个文件中哪天改了标识名就是灾难。解决集中维护权限常量// constants/permission.js export const PERMISSION { USER: { ADD: system:user:add, EDIT: system:user:edit, DELETE: system:user:remove, LIST: system:user:list }, ROLE: { ADD: system:role:add, EDIT: system:role:edit } }el-button v-hasPermi[PERMISSION.USER.ADD]新增/el-button六、数据权限下一步的演进方向以上的菜单权限和按钮权限解决的是能不能看/能不能操作。但还有一层——数据权限销售经理只能看到自己的客户华北区经理只能看到华北区的订单。若依的数据权限通过在 Mapper 层拦截根据用户角色自动拼接 SQL 条件data_scope实现。这是后续值得单独写一篇文章展开的话题。七、总结层级控制内容前端实现后端实现菜单权限能看到什么页面动态路由addRoutegetRouters接口按钮权限能点什么按钮v-hasPermi指令PreAuthorize注解数据权限能看到哪些数据—MyBatis 拦截器三个核心原则前端权限是 UI 层面的用户体验不是安全权限标识统一管理不要散落到各处动态路由刷新后要重新注册这是最容易忘的如果你也在独立开发产品或者对制造业数字化感兴趣欢迎关注这个公众号。我会持续分享从代码到产品的全过程——包括成功的经验也包括踩过的坑。一个人的产品之路不孤单。原创作者 MqCode全栈开发者印刷包装行业 MESCRM 系统独立开发欢迎自由转发。