开门见山地说,做后台管理系统,十有八九会撞上权限体系这堵墙。菜单要按角色区分、页面要按权限隔离、按钮要按身份显隐,这一套东西看着简单,真做起来坑比想象多得多。我最近在一个 Vue 3 后台项目里把这套东西彻彻底底捋了一遍,从登录认证、路由守卫一直到按钮级控制,整条链路全部打通。这篇文章就聊聊我实际操作中的完整思路和踩过的坑,内容包括 Vue 3 + Pinia + Vue Router 4 的组合方案,以及 RBAC 模型的落地细节。正在做后台管理系统,或者被权限需求搞得头大的同学,可以参考一下。
1. 需求拆解:一套后台权限体系到底要管哪些事
1.1 先搞清楚权限的三个层级
我习惯把权限拆成三个层级来理解:登录权限、路由权限、按钮权限。三个层级对应三个问题:你能不能进这个系统、你能看到哪些页面、你能对页面里的东西做什么操作。
拿我这次做的管理后台举例,需求方给的角色分了三种:管理员、运营人员、访客角色。管理员能看所有页面、能增删改查所有数据;运营人员能看数据看板和数据列表页面,能导出表格但不能删除数据;访客角色只能看数据看板和报表,连导出按钮都没有。
如果只用路由权限控制,运营人员照样能看到删除按钮,点击之后才会被后端拦截,体验很差。所以必须做到按钮级控制,让没有权限的人直接看不到操作入口。这是三个层级的核心差异。
1.2 为什么主流方案都选 RBAC 模型
权限模型有很多种,ACL、RBAC、ABAC,各有各的适用场景。我在这个项目里选了 RBAC(基于角色的访问控制),原因很简单:现实业务里权限往往是跟着角色走的,而不是跟着人走。
RBAC 的核心思想是“用户-角色-权限”三层关系。用户不直接绑定权限,而是绑定角色,角色再关联一堆权限点。比如“运营人员”这个角色,关联了“数据列表页访问权限”“导出权限”,但不关联“删除权限”。新增一个运营人员时,只需要给他分配角色,权限自然就到位了。
对比一下两种常见模型:
| 模型 | 核心思想 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|---|
| ACL | 用户直接关联权限 | 灵活、精确 | 用户多了以后维护成本爆炸 | 用户量小、权限需求简单 |
| RBAC | 用户关联角色,角色关联权限 | 易维护、扩展性好 | 权限需要按角色批量设置 | 绝大多数后台管理系统 |
ABAC 则基于属性动态判断,比如“部门=财务且职级=经理”,适合大型企业复杂业务,后台管理系统用不上那么重,徒增开发成本。
1.3 整体数据流:权限是怎么在系统里流转起来的
我梳理了一套闭环流程,整个权限链路的起点是登录,终点是按钮显隐。第一步用户输入账号密码,后端校验成功后返回 token;第二步前端拿着 token 请求获取用户信息,接口返回用户角色、用户名、头像;第三步根据角色从后端拿权限列表(包括可访问路由和按钮权限标识);第四步前端用权限列表动态生成路由、渲染菜单、控制按钮。
这个流程里最关键的点在于“权限列表必须由后端返回”,不能写死在前端。否则后端改个权限,前端还得重新打包发版,而且权限逻辑暴露在前端代码里也不安全。我把这条原则贯穿了整个项目。
2. 登录认证:权限体系的入口关卡
2.1 登录流程设计与 token 生命周期
登录是整个权限链条的第一环。我设计登录模块的时候,没有只做“发请求、存 token”这两步,而是把登录完整拆成了五个环节:表单校验、发起登录请求、存储 token、拉取用户信息、跳转原目标页面。
表单校验用的是项目里现成的表单组件,规则就两条:用户名不能为空、密码长度不能少于 6 位。校验通过后调登录接口。后端返回的数据结构约定为:
{ "code": 0, "data": { "token": "xxxxx", "expiresIn": 7200 } }这里有一个细节:我把 token 和用户信息分开存储了。token 只负责认证,用户信息和权限列表在登录后单独用接口拉取。为什么不一步到位全返回?因为 token 的有效期是 2 小时,用户信息可能在这期间被修改(比如角色调整),单独拉取可以保证用户刷新页面后拿到的是最新权限数据。
token 过期处理也是容易被忽略的点。我在响应拦截器里做了统一处理:如果接口返回 401,说明 token 失效,清空本地存储,跳转登录页,同时用提示组件告知用户“登录状态已过期”。这里有一个关键细节:跳转登录页时要用redirect参数记录当前路由地址,登录成功后直接跳回原页面,而不是简单粗暴地跳回首页。
2.2 token 存哪里:localStorage、Pinia 与 Cookie 的取舍
很多初学者习惯把 token 一股脑塞进 localStorage,简单是简单,但刷新页面之后 store 里的数据全没了,还得重新读一遍 localStorage 再写回 store,多一步操作不说,还容易出 bug。
我的做法是双层存储:状态放 Pinia,持久化放 localStorage。Pinia 负责运行时读写,localStorage 负责刷新后恢复。这样既能保证响应式,又不会刷新丢状态。Cookie 方案我这次没考虑,因为 token 存在 Cookie 里要额外配置跨域和 CSRF 防护,管理后台场景下不划算。
每种方案的特点对比:
| 存储位置 | 特点 | 适合场景 | 注意点 |
|---|---|---|---|
| localStorage | 持久化、读写简单 | 后台管理系统标配 | 不参与请求自动发送,需手动封装 |
| Pinia | 响应式、运行时高效 | 配合 Vue 3 组合式 API | 刷新丢失,必须搭配持久化 |
| Cookie | 请求自动携带 | 需要自动带上 Cookie 的场景 | 跨域配置麻烦,有 CSRF 风险 |
实际操作中,登录成功后我做三件事:调useUserStore().setToken(token)写入 Pinia、调封装的存储工具写入 localStorage、请求拦截器把 token 放进 Authorization 请求头。
2.3 请求拦截器与响应拦截器的完整封装
axios 封装这块我写了很多遍,每次都觉得应该长记性,这次专门做成了一套完整的。请求拦截器核心逻辑就两条,一是读 token,二是附带请求头:
service.interceptors.request.use((config) => { const token = localStorage.getItem('token') if (token) { config.headers.Authorization = `Bearer ${token}` } return config })响应拦截器需要处理三种情况:成功返回、业务错误、登录失效。我的写法是统一解构后端的code字段,code === 0直接返回data给调用方,非 0 则弹出错误提示并抛出Promise.reject。401 的情况走专门的登出处理,这个我在上面说过了,有两点必须注意:一是要防止多个请求同时 401 导致重复跳转登录页,我加了一个标志位isRelogin拦截;二是登出时要调用接口通知后端销毁 token,不能只清前端缓存。
3. 路由权限:动态路由是怎么“长”出来的
3.1 静态路由与动态路由的分工
Vue Router 4 默认是静态路由配置,表里有什么就注册什么。但后台系统的路由不能静态写死,因为不同角色能访问的页面不一样。如果全写静态路由,用户直接输入 URL 就能绕过菜单访问无权限页面。
我的分工方案是:静态路由只放公共页面和兜底页面,包括登录页/login、404 页和根路径/。动态路由在登录后根据权限列表用addRoute逐个注册。这样还有个额外好处:路由表分成两段,代码好维护,排查问题也方便。
// 静态路由 export const constantRoutes = [ { path: '/login', component: () => import('@/views/login/index.vue') }, { path: '/404', component: () => import('@/views/error/404.vue') }, { path: '/', redirect: '/dashboard' } ]3.2 后端返回路由 vs 前端维护路由映射表
这是动态路由实现路线上的一个分岔口。后端返回什么,前端怎么处理,决定了整套方案的复杂度。
方案 A 是后端直接返回完整路由表,包括path、component字符串、name、meta等信息。前端拿到后遍历注册。这种方案灵活,但有一个明显的坑:后端返回的组件路径是字符串,前端需要把组件映射表提前准备好,否则运行时找不到组件。我这次项目用了这种方案,映射表写了一个模块统一收集:
// 组件映射表 const modules = import.meta.glob('@/views/**/*.vue') function loadView(component) { if (!component) return null const path = `/src/views/${component}.vue` return modules[path] }方案 B 是后端只返回权限标识数组(比如['dashboard', 'data-list', 'system-user']),前端维护一套“权限标识 → 路由配置”的映射表。后端返回什么标识,前端就过滤出对应的路由去注册。好处是对后端要求低,前端可控性强;坏处是新增页面要改前端代码再发版。
这两个方案我用表格总结下:
| 方案 | 优点 | 缺点 | 推荐场景 |
|---|---|---|---|
| 后端返回完整路由 | 后端控制灵活,前端不用维护映射 | 组件映射必须处理,首次加载慢 | 大型系统、权限频繁调整 |
| 后端返回权限标识 | 前端可控、实现简单 | 新增路由需前端发版 | 中小型后台管理系统 |
我这次项目最终选了方案 A。因为需求里管理员可以在后台上线新页面,不想每次都等前端发版。如果你的项目没有这种动态扩展需求,我更推荐方案 B,省心很多。
3.3 路由守卫的三个关键阶段
路由守卫是权限控制的核心执行者。我把它拆成了三个阶段:白名单判断、token 判断、权限判断。
白名单就是不需要登录就能访问的页面,常见的有登录页、注册页、404 页。白名单判断放在最前面,不涉及权限判断的页面直接放行,减少不必要的逻辑。
token 判断相对直接:没有 token 且访问的不是白名单页面,就跳转登录页,同时记录当前地址用于登录后回跳。有 token 但访问的是登录页,就直接送去首页,避免登录页和主界面来回跳。
权限判断是核心逻辑所在。这一步要判断当前用户是不是已经生成了动态路由。我设置了一个标志位isRoutesGenerated,默认为 false。首次进入守卫时,如果为 false,就从后端拉取权限数据、生成动态路由、next({ ...to, replace: true })强制重新进入当前路由;如果已经生成过,就直接next()。
这段逻辑里最容易出问题的就是死循环。我写过一版在next()时会重复触发自己,然后用to.path === '/login'做判断,结果一转圈就蹦出个“无限重定向”警告。后来我悟了:标志位判断必须放在最前面,生成完路由之后立刻标记为 true,并且通过重复导航当前地址来触发新的路由匹配,而不是直接粗暴next()。
4. 按钮级权限:最细粒度的控制手段
4.1 自定义指令 v-permission 的实现思路
按钮级别控制是后台权限体系里最能体现用户体验的细节。没有按钮权限控制的时候,用户看到一个删除按钮,点一下弹“无权限”,很打击人。有了控制之后,没权限的直接看不到,干脆利落。
Vue 3 里实现按钮权限我用了自定义指令。指令的名字我定为v-permission,使用方式是在按钮上挂一个权限标识:
<button v-permission="['data:delete']">删除数据</button>指令背后的逻辑不复杂,把权限标识数组传进去,判断当前用户是否拥有对应的任一权限,如果都没有,就把这个 DOM 元素移出文档。
app.directive('permission', { mounted(el, binding) { const value = binding.value const hasPermission = useUserStore().permissions.some((item) => value.includes(item)) if (!hasPermission) { el.parentNode.removeChild(el) } } })这里有一个细节:我用.some()而不是.every()。因为有的按钮是“拥有任一权限就能操作”,比如导出按钮可能同时面向运营人员和高级访客,这种情况下只要匹配到其中一个就显示。
4.2 权限点的获取时机与 store 管理
权限点跟着用户信息一起从后端接口返回。后端返回的权限列表是一个扁平数组,比如['dashboard:view', 'data:list', 'data:export', 'user:add']。我把这个数组直接存放在用户 store 里,所有判断都从 store 读,保证了响应式。
export const useUserStore = defineStore('user', { state: () => ({ token: '', roles: [], permissions: [] }), actions: { async fetchUserInfo() { const res = await apiGetUserInfo() this.roles = res.roles this.permissions = res.permissions } } })权限判断函数我也封装了一个工具方法,方便在非模板场景下使用。比如路由守卫里需要判断某个操作是否允许,直接调用checkPermission('data:delete')就行。这样权限逻辑就收敛到了一个入口,避免各处各写一套。
4.3 三种控制方式怎么选
除了自定义指令,还有一种常见的做法是封装一个权限组件,用包裹按钮的方式来控制显隐:
<authority permission="data:delete"> <button>删除</button> </authority>组件方案的好处是可以在包裹层统一处理“无权限时显示什么”的逻辑,比如显示一个引导开启权限的提示框。坏处是每个按钮都要套一层组件,代码略啰嗦。
函数方案则适合用在非 UI 场景,比如事件处理函数里判断某个逻辑是否执行:
async function handleDelete(row) { if (!checkPermission('data:delete')) { message.warning('您没有删除权限') return } await apiDeleteData(row.id) }我这套项目里三种方式都在用:模板显隐用指令、逻辑判断用函数、需要统一兜底提示的场景用组件。指令方案在模板里最干净,函数方案在逻辑里最灵活。具体用哪个,看你的诉求更偏哪一边。
5. 动态菜单:让侧边栏“听你指挥”
5.1 菜单数据从哪来,长什么样
动态菜单和动态路由是同一条数据线的两个消费端。前端从后端拿到的权限数据里,除了路由信息,还有一个菜单树结构。一个典型的菜单数据长这样:
[ { "path": "/data", "title": "数据中心", "icon": "chart", "children": [ { "path": "/data/list", "title": "数据列表", "icon": "table" }, { "path": "/data/report", "title": "报表分析", "icon": "document" } ] } ]我项目里菜单数据直接从后端接口返回,前端不做任何加工,直接渲染。好处是后端可以随时调整菜单顺序和层级,前端只负责展示。
5.2 用递归组件渲染无限层级菜单
菜单在真实项目里经常有多级甚至三级嵌套,所以渲染组件必须支持递归。我实现了一个SidebarItem组件,判断当前节点是否还有children,如果有就继续渲染自己,没有就渲染菜单项。
<template> <el-menu-item v-if="!item.children || item.children.length === 0" :index="item.path"> <span>{{ item.title }}</span> </el-menu-item> <el-sub-menu v-else :index="item.path"> <template #title>{{ item.title }}</template> <sidebar-item v-for="child in item.children" :key="child.path" :item="child" /> </el-sub-menu> </template>这种递归写法我调试的时间比写的时间还长,因为很容易踩到无限递归导致页面卡死。排查下来发现是父组件名称引用问题,组件内部自引用必须在script setup中用defineOptions声明name,不然递归没法生效。
5.3 菜单高亮与面包屑的联动
菜单做好之后,还有一个日常体验问题:菜单高亮和面包屑导航。菜单高亮最简单的方式是从当前路由的path匹配菜单项。但嵌套路由场景下,高亮需要考虑父级菜单。
面包屑则需要根据当前路由的matched数组来生成。这个数组包含当前路由所有父级路由信息,比如访问/data/report,matched里会有父级/data和当前级/data/report。遍历matched,取出每个路由的title,面包屑就出来了。
这块不算权限体系的核心,但和菜单联动关系紧密,我在项目里顺手一起做了。实际效果是整个后台的导航状态和权限数据完全同步,换账号后菜单、高亮、面包屑全部自动更新,不需要手动刷新。
6. 排查实录:权限体系里最常见的四个坑
6.1 刷新页面后直接 404
这是动态路由方案下“重量级”的坑,我几乎每次都会被它绊一跤。问题出在哪呢:刷新页面时程序需要重新执行一遍初始化流程,如果动态路由还没生成完,页面就尝试匹配当前路径,结果自然是 404。
解决思路有两条。一条是在路由守卫里重复导航当前地址,等动态路由生成完,我们再next({ ...to, replace: true })重新匹配;另一条是把 404 页面的注册顺序放在动态路由之后,避免提前匹配。我两条都做了,效果稳定。你如果遇到了刷新 404,先检查有没有这两步,基本能解决。
6.2 按钮权限在组件初始化时不生效
这个坑我印象很深。自定义指令mounted的时候去读 store 的 permissions,但如果父组件的初始化逻辑比 store 填充数据跑得更早,读到 permissions 是空数组,按钮就会被错误移除。
排查的时候我打印了一下 store 数据,发现接口返回权限列表之前页面已经渲染了。解决方式有两种:要么在模板上加v-if等待用户信息加载完成再渲染按钮区域,要么把权限指令的判断逻辑从mounted改成路由守卫放行之后才执行。我项目里选择了第一种,在布局组件的模板里加了一层:
<template v-if="userStore.isUserInfoLoaded"> <button v-permission="['data:delete']">删除</button> </template>这样权限指令执行时 store 里已经有数据了,判断结果才可靠。
6.3 切换账号后权限残留
测试同学报了一个 bug:从管理员账号退出,换运营账号登录之后,菜单还是管理员的那一套。排查下来发现,root cause 是退出登录时只清掉了 token,没有清空 store 里的 roles、permissions 和动态路由。
解决方式是做一套完整的 reset 逻辑:退出时清 Pinia、清 localStorage、移除动态路由。移除动态路由不是简单地路由表置空,遍历路由实例,把通过addRoute加进去的路由用removeRoute全部移除。我封装了一个冲掉全部变量和路由的方法,在退出登录时统一执行,bug 就再没出现过。
6.4 路由守卫死循环怎么排查
死循环是权限系统里最让人心态爆炸的问题。表现是浏览器一直提示 “Maximum recursive updates exceeded” 或者红屏。常见原因就是我上面提到的next()重复触发守卫自身,或者权限判断分支没有完整的出口。
排查手段我用的是最笨也最有效的办法:在守卫里每一步加console.log打印to.path和关键标志位。基本上打出三轮日志就能看出循环在哪。牢记一个原则:路由守卫里所有可能的分支都必须有明确的next()或return出口,不要让逻辑进入一个无法终结的状态。
对比几个典型的解决路径:
| 现象 | 可能原因 | 解决方案 |
|---|---|---|
| 刷新 404 | 动态路由未注册就匹配 | 重复导航当前地址 |
| 按钮全没了 | permissions 尚未拉取 | 模板上等用户信息加载完成 |
| 换号权限残留 | 退出未重置状态 | 统一 reset store 与路由 |
| 无限重定向 | 守卫里 next 触发自身 | 用标志位记录生成状态 |
后记:关于这套方案的一些心里话
这套权限体系我从搭骨架到稳定运行,反复调了将近两个星期。回头看看,真正难的根本不是某个 API 或某个组件的写法,而是把整条链路理顺:登录拿 token、token 换用户信息、用户信息生成路由、路由驱动菜单、菜单再叠上按钮权限,每一环都跟下一环咬合。
如果你现在也要在项目里落地权限体系,我给三个建议。第一,先和后端约定好数据结构再动手,权限列表、菜单树、用户信息这些字段口径统一,能省掉一大半联调成本。第二,路由守卫和按钮指令里所有分支都要想好出口,权限判断最怕的就是闭环循环,一个标志位就能避免很多麻烦。第三,权限相关代码集中管理,不要散落在各个业务组件里,哪怕只是多封装一层工具函数,以后维护起来会感谢现在的自己。
我个人实际做这一套下来,最大的体会是权限系统没有银弹,别人的方案只能参考骨架,细节必须结合自己项目的角色和业务来打磨。骨架稳了,后面的持续迭代就轻松了。