问题
几乎所有后台管理系统都会遇到权限控制的问题,典型的故障现场是这样的:
- 菜单显示不对:普通用户看到了管理员的菜单项
- 路由可以访问:直接在浏览器输入
/admin/user就能进入管理页面 - 按钮权限失效:没有删除权限的用户也能看到删除按钮
- 刷新后权限丢失:F5刷新页面,动态路由消失,页面白屏
- 前后端权限不一致:前端隐藏了菜单,但接口没有校验,直接调用API仍能操作
这类问题的根本原因不是"权限逻辑写错了",而是权限控制的链路被拆散到了太多地方:
- 路由配置写在 router.js 里
- 菜单渲染在 Sidebar 组件里
- 按钮权限在各个页面的 v-if 里
- 接口鉴权在后端 Controller 里
- 用户信息存储在 Vuex/Pinia 里
当这些分散的逻辑没有统一协调时,任何一个环节出问题都会导致权限失控。
更麻烦的是,不同公司对权限的需求差异很大:
- 有的公司只需要简单的角色控制(admin/editor/viewer)
- 有的公司需要细粒度的按钮权限(user:add, user:edit, user:delete)
- 有的公司需要数据权限(只能看本部门的数据)
- 有的公司需要动态配置(运营人员可以在后台配置角色权限)
如果一开始没有设计好扩展性,后面每增加一种需求都要大改代码。
解决方案
解决权限问题的核心思路是:建立统一的权限控制体系,把路由、菜单、按钮、接口四层权限收束成单一入口。
这套体系需要明确规定:
- 谁能发起路由注册(前端智能模式 vs 后端全控模式)
- 谁能修改用户权限状态(只能通过登录/刷新接口)
- 谁负责串联权限校验流程(路由守卫统一处理)
- 谁决定失败时如何降级(重定向到403或首页)
一个真正可用的权限控制方案,至少应该统一这几件事:
1. 两种权限模式的切换
- 前端智能模式(intelligence):前端维护完整路由表,根据用户角色过滤
- 后端全控模式(all):后端返回用户可访问的路由配置,前端动态注册
2. 四层权限的统一校验
- 路由级:路由守卫中校验是否有访问权限
- 菜单级:根据权限动态渲染侧边栏菜单
- 按钮级:自定义指令
v-hasPermi控制按钮显示 - 接口级:后端拦截器校验 Token 和权限标识
3. 权限数据的持久化
- 登录后保存用户信息和权限列表到 Store
- 刷新页面时从 localStorage 恢复权限状态
- Token 过期时自动登出并清除权限
4. 异常情况的降级处理
- 无权限访问时重定向到 403 页面
- 路由不存在时重定向到 404 页面
- Token 失效时重定向到登录页
如果用一句话概括它的本质,那就是:
权限控制不是在前端藏几个菜单,而是要建立从路由到接口的完整防护链。
具体实现
最小示例
下面先给一个最小但完整的 Demo。它展示了前端智能模式下,如何根据用户角色过滤路由并动态注册。
// router/index.tsimport{createRouter,createWebHistory}from'vue-router';// 常量路由(所有人可访问)exportconstconstantRoutes=[{path:'/login',component:()=>import('@/views/login.vue')},{path:'/',component:()=>import('@/views/dashboard.vue')},];// 异步路由(需要根据权限动态添加)exportconstasyncRoutes=[{path:'/system',component:()=>import('@/layout/index.vue'),meta:{roles:['admin']},children:[{path:'user',component:()=>import('@/views/system/user.vue')},{path:'role',component:()=>import('@/views/system/role.vue')},],},{path:'/editor',component:()=>import('@/layout/index.vue'),meta:{roles:['admin','editor']},children:[{path:'article',component:()=>import('@/views/editor/article.vue')},],},];constrouter=createRouter({history:createWebHistory(),routes:constantRoutes,});exportdefaultrouter;// store/modules/permission.tsimport{defineStore}from'pinia';import{asyncRoutes,constantRoutes}from'@/router';// 判断用户是否有权限访问某个路由functionhasPermission(route:any,roles:string[]){if(route.meta&&route.meta.roles){returnroles.some(role=>route.meta.roles.includes(role));}returntrue;// 没有配置roles表示所有人可访问}// 递归过滤有权限的路由functionfilterAsyncRoutes(routes:any[],roles:string[]){constresult:any[]=[];routes.forEach(route=>{consttmp={...route};if(hasPermission(tmp,roles)){if(tmp.children){tmp.children=filterAsyncRoutes(tmp.children,roles);}result.push(tmp);}});returnresult;}exportconstusePermissionStore=defineStore('permission',{state:()=>({routes:[],addRoutes:[],}),actions:{generateRoutes(roles:string[]){returnnewPromise(resolve=>{letaccessedRoutes;// admin角色可以访问所有路由if(roles.includes('admin')){accessedRoutes=asyncRoutes||[];}else{accessedRoutes=filterAsyncRoutes(asyncRoutes,roles);}this.routes=constantRoutes.concat(accessedRoutes);this.addRoutes=accessedRoutes;resolve(accessedRoutes);});},},});// permission.ts - 路由守卫importrouterfrom'./router';import{useUserStore}from'@/store/modules/user';import{usePermissionStore}from'@/store/modules/permission';importNProgressfrom'nprogress';constwhiteList=['/login'];// 白名单router.beforeEach(async(to,from,next)=>