首页 / 资讯中心 / 文章详情

Vue 3后台权限体系实战:从登录认证到按钮级控制,RBAC模型完整落地

Vue 3后台权限体系实战:从登录认证到按钮级控制,RBAC模型完整落地 ★ FEATURED ARTICLE
开门见山地说做后台管理系统十有八九会撞上权限体系这堵墙。菜单要按角色区分、页面要按权限隔离、按钮要按身份显隐这一套东西看着简单真做起来坑比想象多得多。我最近在一个 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 permissiondata: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 :indexitem.path span{{ item.title }}/span /el-menu-item el-sub-menu v-else :indexitem.path template #title{{ item.title }}/template sidebar-item v-forchild in item.children :keychild.path :itemchild / /el-sub-menu /template这种递归写法我调试的时间比写的时间还长因为很容易踩到无限递归导致页面卡死。排查下来发现是父组件名称引用问题组件内部自引用必须在script setup中用defineOptions声明name不然递归没法生效。5.3 菜单高亮与面包屑的联动菜单做好之后还有一个日常体验问题菜单高亮和面包屑导航。菜单高亮最简单的方式是从当前路由的path匹配菜单项。但嵌套路由场景下高亮需要考虑父级菜单。面包屑则需要根据当前路由的matched数组来生成。这个数组包含当前路由所有父级路由信息比如访问/data/reportmatched里会有父级/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-ifuserStore.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 换用户信息、用户信息生成路由、路由驱动菜单、菜单再叠上按钮权限每一环都跟下一环咬合。如果你现在也要在项目里落地权限体系我给三个建议。第一先和后端约定好数据结构再动手权限列表、菜单树、用户信息这些字段口径统一能省掉一大半联调成本。第二路由守卫和按钮指令里所有分支都要想好出口权限判断最怕的就是闭环循环一个标志位就能避免很多麻烦。第三权限相关代码集中管理不要散落在各个业务组件里哪怕只是多封装一层工具函数以后维护起来会感谢现在的自己。我个人实际做这一套下来最大的体会是权限系统没有银弹别人的方案只能参考骨架细节必须结合自己项目的角色和业务来打磨。骨架稳了后面的持续迭代就轻松了。
阅读完成 · 觉得有帮助?
咨询建站