资讯详情

资讯详情

Vue权限控制实战:从菜单到按钮的三层闭环设计

简介这份资源面向Vue前端开发者与后台管理系统搭建者聚焦权限控制这一核心场景帮助解决不同角色用户只能访问被授权功能的问题。内容围绕两种主流限制方式展开一是路由不可见借助Vue Router的meta字段存储权限列表并通过全局前置守卫beforeEach完成校验与重定向二是元素不可见通过自定义指令v-acl在元素插入时判断权限并隐藏无权操作的按钮或链接避免无效点击引发接口报错。资源包为1个PDF文件大小约55KB内容紧凑适合作为权限控制入门与速查参考。目前已有778人学习下载说明该主题在后台管理系统开发中具有普遍需求。读者可从中获得可直接套用的路由守卫与指令代码模板、权限判断函数的实现思路以及侧边栏导航过滤等扩展方向便于按项目实际需求定制更复杂的权限方案。1. 权限控制不是加个 v-if从菜单到按钮的完整闭环很多团队第一次做后台系统时权限控制就是登录后拿一个role字段然后在模板里到处写v-ifrole admin。项目小的时候没问题一旦角色从 3 个变成 8 个、页面从 10 个变成 80 个这套写法就会集体翻车——改一个权限要翻遍几十个文件测试还测不全。Vue 项目实现简单的权限控制管理功能核心不是某个 API而是把用户能看什么菜单、能进什么路由、能点什么按钮这三件事拆成三层每层各管一段互不越界。这篇文章面向正在做中后台、需要一套能落地又不至于上 Casbin 那种重型方案的开发者从数据结构讲到路由守卫和自定义指令每一步都能直接抄进项目里跑起来。2. 先把权限数据模型定下来角色、路由、按钮三张表怎么设计权限控制做不下去八成是数据模型一开始就歪了。常见做法是后端返回一个扁平的permissions数组前端自己判断但更稳的是让后端按角色 → 权限码返回前端只负责消费。这一章先把三张核心数据结构定清楚后面所有代码都围绕它们展开。2.1 角色与权限码的映射关系权限码permission code是整个系统的通用货币。它是一串字符串格式建议用模块:资源:动作比如system:user:add、system:user:delete、order:list:view。为什么不用数字 ID因为字符串可读排查问题时一眼能看出这个权限是干嘛的日志里也友好。后端登录接口返回的结构大致长这样{ token: eyJhbGciOi..., userInfo: { userId: 1001, userName: operator_a, roles: [operator], permissions: [ order:list:view, order:detail:view, order:export:excel ] } }这里有个关键决策前端要不要自己根据 roles 推导 permissions我的建议是不要。让后端把最终权限码算好直接给前端前端只做展示层判断。原因很简单——如果前端也维护一份角色对应哪些权限的映射那这份映射和后端就是两份真相迟早对不上。前端推导只适合权限极简、角色固定的场景一旦角色可配置就会失控。拿到permissions后存到 Pinia或 Vuex里同时做两件事一是持久化到 localStorage 防止刷新丢失二是提供一个hasPermission(code)方法供全局调用。// stores/permission.js import { defineStore } from pinia export const usePermissionStore defineStore(permission, { state: () ({ permissions: [], // 权限码数组如 [order:list:view] roles: [], // 角色数组如 [operator] routesLoaded: false // 动态路由是否已挂载防止重复添加 }), actions: { // 登录成功后调用写入权限数据 setPermissions(permissions, roles) { this.permissions permissions || [] this.roles roles || [] }, // 判断是否拥有某个权限码 hasPermission(code) { if (!code) return true // 不传权限码视为不校验 return this.permissions.includes(code) }, // 判断是否拥有其中任意一个权限码 hasAnyPermission(codes) { if (!codes || codes.length 0) return true return codes.some(code this.permissions.includes(code)) }, // 退出登录时清空 reset() { this.permissions [] this.roles [] this.routesLoaded false } } })hasPermission里那个if (!code) return true是个容易被忽略的细节很多按钮组件不传权限码时应该默认放行否则每个不需要权限的按钮都得写一个假权限码反而累赘。hasAnyPermission用于满足任一即可的场景比如编辑和删除都能进详情页。2.2 路由表怎么标记权限meta 字段的三种写法路由权限的标记全部放在meta里这是 Vue Router 的官方约定。我一般用三个字段meta.permission单个权限码、meta.permissions权限码数组满足任一即可、meta.roles角色数组。为什么三个都要因为实际项目里这三种粒度都会遇到——有的页面只认一个权限码有的页面运营和财务都能进有的页面干脆按角色粗粒度控制。// router/asyncRoutes.js export const asyncRoutes [ { path: /order, component: () import(/layout/index.vue), meta: { title: 订单管理, icon: order }, children: [ { path: list, name: OrderList, component: () import(/views/order/list.vue), meta: { title: 订单列表, permission: order:list:view } }, { path: detail/:id, name: OrderDetail, component: () import(/views/order/detail.vue), meta: { title: 订单详情, permissions: [order:detail:view, order:edit:view] } } ] }, { path: /system, component: () import(/layout/index.vue), meta: { title: 系统管理, roles: [admin] }, children: [ { path: user, name: UserManage, component: () import(/views/system/user.vue), meta: { title: 用户管理, permission: system:user:list } } ] } ]注意meta是逐层继承判断的父路由写了roles: [admin]子路由即使没写也应该被父级的规则拦住。这一点在写过滤函数时必须显式处理否则会出现父级限了 admin子级却被 operator 访问到的漏洞。我见过不止一个项目栽在这里。2.3 静态路由与动态路由的边界不是所有路由都要走权限过滤。登录页、404 页、首页框架这些必须无条件可访问否则用户没登录就被守卫拦在登录页外直接死循环。所以路由要分两组分组内容是否过滤挂载时机constantRoutes登录页、404、重定向页否应用初始化时asyncRoutes业务页面是登录后按权限过滤constantRoutes在createRouter时就注册好asyncRoutes先不注册等拿到用户权限后再router.addRoute()动态挂载。这个先不注册是关键——如果一开始就把所有路由注册进去那路由守卫只能拦导航但用户仍然能通过直接输入 URL 触发组件加载虽然最终会被守卫重定向但组件代码已经下载了存在信息泄露风险。动态挂载则从根上让无权限的路由根本不存在。3. 路由守卫 动态挂载让无权限的页面根本不存在数据模型定好了接下来是执行层。这一章讲清楚两件事登录后怎么把asyncRoutes过滤成这个人能看的菜单以及路由守卫里怎么处理刷新、跳转、越权访问这三种情况。3.1 写一个能递归过滤的权限判断函数过滤函数要处理三种 meta 标记还要处理父子继承。直接上代码// utils/filterRoutes.js import { usePermissionStore } from /stores/permission /** * 递归过滤路由表返回当前用户有权限访问的路由 * param {Array} routes 待过滤的路由表 * returns {Array} 过滤后的路由表 */ export function filterAsyncRoutes(routes) { const permissionStore usePermissionStore() const result [] routes.forEach(route { const tmp { ...route } if (hasRoutePermission(tmp, permissionStore)) { // 有子路由则递归过滤 if (tmp.children tmp.children.length 0) { tmp.children filterAsyncRoutes(tmp.children) // 子路由全被过滤掉且自身不是纯容器页则丢弃 if (tmp.children.length 0 !tmp.component) { return } } result.push(tmp) } }) return result } /** * 判断单条路由是否有权限 * 规则meta 里没写任何权限标记 放行 */ function hasRoutePermission(route, store) { const meta route.meta || {} // 角色判断 if (meta.roles meta.roles.length 0) { const hasRole meta.roles.some(r store.roles.includes(r)) if (!hasRole) return false } // 单权限码判断 if (meta.permission !store.hasPermission(meta.permission)) { return false } // 多权限码判断满足任一 if (meta.permissions meta.permissions.length 0) { if (!store.hasAnyPermission(meta.permissions)) { return false } } return true }这里有个血泪经验tmp.children.length 0 !tmp.component这个判断不能省。父路由如果只是个布局容器component是 Layout子路由全被过滤后它就成了空壳挂上去只会在侧边栏留一个点不进去的菜单项。但如果父路由本身有component比如一个独立页面带子路由就不能丢。这个边界我调过好几次才稳定。3.2 路由守卫里处理刷新丢失和重复挂载路由守卫是整个权限控制的总闸。它要处理四种情况没 token 去登录页、有 token 但没拉过用户信息、拉过信息但路由没挂载、路由已挂载正常放行。用routesLoaded标志位区分后两种。// router/guard.js import router from ./index import { usePermissionStore } from /stores/permission import { useUserStore } from /stores/user import { asyncRoutes } from ./asyncRoutes import { filterAsyncRoutes } from /utils/filterRoutes const WHITE_LIST [/login, /404] router.beforeEach(async (to, from, next) { const userStore useUserStore() const permissionStore usePermissionStore() const token userStore.token // 情况一无 token if (!token) { if (WHITE_LIST.includes(to.path)) { next() } else { next(/login?redirect${encodeURIComponent(to.fullPath)}) } return } // 情况二有 token 但访问登录页直接回首页 if (to.path /login) { next({ path: / }) return } // 情况三有 token 但路由还没挂载首次进入或刷新 if (!permissionStore.routesLoaded) { try { // 拉取用户信息含 permissions 和 roles await userStore.fetchUserInfo() const accessibleRoutes filterAsyncRoutes(asyncRoutes) accessibleRoutes.forEach(route { router.addRoute(route) }) // 兜底 404必须在动态路由之后加 router.addRoute({ path: /:pathMatch(.*)*, redirect: /404 }) permissionStore.routesLoaded true // replace: true 避免在历史记录里留下这次重定向 next({ ...to, replace: true }) } catch (err) { // 拉取失败清空登录态回登录页 userStore.resetToken() permissionStore.reset() next(/login?redirect${encodeURIComponent(to.fullPath)}) } return } // 情况四一切就绪正常放行 next() })几个必须注意的点。第一next({ ...to, replace: true })这行是精髓——动态路由刚挂载时当前这次导航匹配的还是旧路由表直接next()会白屏或 404必须重新触发一次导航让 router 用新表重新匹配。第二404 兜底路由一定要在动态路由挂载之后再加否则它会抢先匹配所有路径。第三fetchUserInfo失败要清 token不然用户会卡在一个永远加载不出来的状态里。3.3 侧边栏菜单从过滤后的路由自动生成菜单不需要单独维护一份数据直接从permissionStore里存好的可访问路由递归渲染即可。这样菜单和路由永远一致不会出现菜单里有但点进去 404的尴尬。!-- components/SidebarItem.vue -- template template v-forroute in routes :keyroute.path !-- 有子菜单且子项多于一个 -- el-sub-menu v-ifroute.children route.children.length 1 :indexresolvePath(route.path) template #title span{{ route.meta.title }}/span /template sidebar-item :routesroute.children :base-pathroute.path / /el-sub-menu !-- 单个子项直接渲染成菜单项 -- el-menu-item v-else :indexresolvePath(route.children ? route.children[0].path : route.path) span{{ route.meta.title }}/span /el-menu-item /template /template script setup import { usePermissionStore } from /stores/permission const props defineProps({ routes: { type: Array, required: true }, basePath: { type: String, default: } }) const permissionStore usePermissionStore() // 拼接父子路径处理绝对路径和相对路径 function resolvePath(childPath) { if (childPath.startsWith(/)) return childPath if (!props.basePath) return /${childPath} return ${props.basePath}/${childPath}.replace(/\//g, /) } /scriptresolvePath里那个replace(/\//g, /)是防手滑的——父子路径拼接时很容易出现//双斜杠菜单高亮就会失效。这种小坑不踩一次根本想不到。4. 按钮级权限自定义指令比 v-if 更省心路由管住了能不能进页面但页面里的新增/删除/导出按钮还得单独控制。用v-if当然能实现但每个按钮都写一遍hasPermission(xxx)太啰嗦而且权限码散落在模板里改起来痛苦。自定义指令是更优雅的方案。4.1 实现 v-permission 指令// directives/permission.js import { usePermissionStore } from /stores/permission /** * v-permission 指令 * 用法一v-permissionorder:add 单个权限码 * 用法二v-permission[order:add,order:edit] 满足任一 * 用法三v-permission.role[admin] 按角色判断 */ export const permission { mounted(el, binding) { const store usePermissionStore() const { value, modifiers } binding let hasAuth false if (modifiers.role) { // 角色模式 const roles Array.isArray(value) ? value : [value] hasAuth roles.some(r store.roles.includes(r)) } else { // 权限码模式 if (Array.isArray(value)) { hasAuth store.hasAnyPermission(value) } else { hasAuth store.hasPermission(value) } } if (!hasAuth) { // 直接从 DOM 移除比 display:none 更彻底 el.parentNode el.parentNode.removeChild(el) } } }为什么用removeChild而不是display: none因为隐藏的元素仍然在 DOM 里稍微懂点前端的人打开控制台改一下样式就能点虽然后端还会校验但前端这层形同虚设。直接移除更干净。当然如果按钮有过渡动画或者需要动态恢复那就得用v-if配合响应式数据指令方案适合权限在会话期内不变的场景。4.2 在 main.js 里注册并处理权限变更// main.js import { createApp } from vue import App from ./App.vue import router from ./router import { permission } from ./directives/permission const app createApp(App) app.directive(permission, permission) app.use(router) app.mount(#app)注册后模板里就能直接用template div classtoolbar el-button v-permissionorder:add typeprimary新增订单/el-button el-button v-permission[order:edit, order:audit]编辑/el-button el-button v-permissionorder:delete typedanger删除/el-button el-button v-permission.role[admin]系统配置/el-button /div /template有个坑要提前说指令的mounted只在元素挂载时执行一次。如果用户在会话中途切换了角色比如管理员给自己降权已经渲染的按钮不会自动消失。这种场景要么刷新页面要么把权限判断改成响应式的v-if。绝大多数后台系统不需要运行时切换角色所以指令方案够用。4.3 权限码命名规范与后端对齐前端权限码必须和后端完全一致否则hasPermission永远返回 false。我一般会在项目里放一个常量文件把权限码集中管理// constants/permissions.js export const PERM { ORDER_ADD: order:add, ORDER_EDIT: order:edit, ORDER_DELETE: order:delete, ORDER_EXPORT: order:export:excel, USER_LIST: system:user:list, USER_RESET_PWD: system:user:resetPwd }模板里写v-permissionPERM.ORDER_ADD这样权限码改了只需要改一处而且 IDE 能自动补全拼错会立刻报错。这个习惯能省掉大量为什么按钮不显示的排查时间。5. 避坑指南权限控制里最容易翻车的五个地方权限控制逻辑不复杂但细节极多下面这五条都是我或身边同事真实踩过的按现象 → 原因 → 解决整理。现象一刷新页面后白屏控制台报 No match found for location。原因是动态路由还没挂载路由守卫就放行了router 用旧表匹配不到当前路径。解决守卫里判断routesLoaded为 false 时拉完权限、挂完路由后必须next({ ...to, replace: true })重新导航一次不能直接next()。现象二退出登录再登录另一个账号看到的还是上一个账号的菜单。原因是router.addRoute添加的路由没有在退出时移除新账号登录后旧路由还在。解决退出登录时调用router.removeRoute()逐个移除或者更省事的做法——退出时直接location.reload()整页刷新让路由表回到初始状态。我一般用后者简单可靠。现象三父路由限了 admin子路由却被普通用户访问到。原因是过滤函数只判断了当前路由的 meta没有继承父级规则。解决过滤时把父级的权限要求往下传或者干脆在过滤子路由前先判断父级是否通过父级不通过整个分支丢弃。现象四按钮权限用v-if写结果权限码拼错一个字母按钮永远不显示排查半天。原因是权限码是裸字符串没有类型检查。解决用常量文件集中管理权限码模板里引用常量而非字面量拼错立刻在编译期暴露。现象五后端返回的 permissions 是 null前端includes报错。原因是接口在某些边界情况下返回了 null 而非空数组。解决setPermissions里做兜底this.permissions permissions || []所有消费权限的地方都假设它至少是空数组。防御性写法在权限模块里尤其重要因为这里一出错就是白屏。注意前端权限控制永远只是体验层真正的安全边界在后端。前端隐藏了删除按钮不代表用户不能直接调删除接口。所有涉及数据的操作后端必须独立校验权限前端这层只是让界面更干净、减少误操作。6. 进阶把权限判断抽成组合式函数顺带解决动态按钮指令方案有个天然短板——它只在挂载时判断一次遇到按钮要根据数据状态动态显示的场景就力不从心。比如订单列表里只有状态为待审核的行才显示审核按钮同时还要有审核权限。这时候需要把权限判断和业务条件结合起来用组合式函数更灵活。// composables/useAuth.js import { computed } from vue import { usePermissionStore } from /stores/permission export function useAuth() { const store usePermissionStore() // 返回一个判断函数模板里可直接调用 const hasPerm (code) store.hasPermission(code) // 返回一个组合判断权限 自定义条件 const hasPermWith (code, condition) { return computed(() store.hasPermission(code) !!condition.value) } return { hasPerm, hasPermWith } }在组件里这样用script setup import { ref } from vue import { useAuth } from /composables/useAuth const { hasPerm, hasPermWith } useAuth() const rowStatus ref(pending) // 审核按钮需要权限 状态为待审核 const canAudit hasPermWith(order:audit, computed(() rowStatus.value pending)) /script template el-button v-ifcanAudit typeprimary审核/el-button el-button v-ifhasPerm(order:export:excel)导出/el-button /templatehasPermWith返回的是computed所以当rowStatus变化时按钮会自动更新这是指令做不到的。代价是模板里要写v-if但换来的是动态响应能力值得。再补一个验证技巧权限功能做完后别只用管理员账号点一遍就完事。准备三个账号——全权限管理员、部分权限操作员、无权限访客分别测这四件事直接输入无权限 URL 能否被拦、刷新后菜单是否还在、按钮是否按预期显隐、退出再登录是否串权限。这四步跑通基本就稳了。我自己吃过亏曾经只测了管理员上线后操作员登录发现菜单全空原因是过滤函数里roles判断写反了some写成了every。这种低级错误多账号测一遍就能当场抓出来。希望帮到你。本文还有配套的精品资源点击获取
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →