登录后看到什么菜单、能点什么按钮——权限是后台系统的骨架。这篇文章从若依的 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_type='M') |
| 菜单 | 用户列表、客户管理 | sys_menu(menu_type='C') |
| 按钮 | 新增、删除、导出 | sys_menu(menu_type='F'),存权限标识 |
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']" type="primary">新增</el-button> <el-button v-hasPermi="['system:user:remove']" type="danger">删除</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']" type="danger">重置密码</el-button>4.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 } // 关键:重定向到目标页 } })坑 2:v-hasPermi 在 v-for 中不起作用
现象:表格操作列的按钮权限控制不了。
原因:v-hasPermi和v-for同时在一个元素上时,指令可能在v-for渲染前就执行了。
解决:外包一层 template:
<el-table-column label="操作"> <template #default="{ row }"> <template v-hasPermi="['system:user:edit']"> <el-button @click="handleEdit(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)实现。这是后续值得单独写一篇文章展开的话题。
七、总结
| 层级 | 控制内容 | 前端实现 | 后端实现 |
|---|---|---|---|
| 菜单权限 | 能看到什么页面 | 动态路由addRoute | getRouters接口 |
| 按钮权限 | 能点什么按钮 | v-hasPermi指令 | @PreAuthorize注解 |
| 数据权限 | 能看到哪些数据 | — | MyBatis 拦截器 |
三个核心原则:
前端权限是 UI 层面的用户体验,不是安全
权限标识统一管理,不要散落到各处
动态路由刷新后要重新注册,这是最容易忘的
如果你也在独立开发产品,或者对制造业数字化感兴趣,欢迎关注这个公众号。我会持续分享从代码到产品的全过程——包括成功的经验,也包括踩过的坑。
一个人的产品之路,不孤单。👇
原创作者 MqCode(全栈开发者,印刷包装行业 MES+CRM 系统独立开发),欢迎自由转发。