☰
Vue权限控制实战:从菜单到按钮的三层闭环设计
2026/10/18 17:55:51 网站建设 项目流程

简介:这份资源面向Vue前端开发者与后台管理系统搭建者,聚焦权限控制这一核心场景,帮助解决不同角色用户只能访问被授权功能的问题。内容围绕两种主流限制方式展开:一是路由不可见,借助Vue Router的meta字段存储权限列表,并通过全局前置守卫beforeEach完成校验与重定向;二是元素不可见,通过自定义指令v-acl在元素插入时判断权限并隐藏无权操作的按钮或链接,避免无效点击引发接口报错。资源包为1个PDF文件,大小约55KB,内容紧凑,适合作为权限控制入门与速查参考。目前已有778人学习下载,说明该主题在后台管理系统开发中具有普遍需求。读者可从中获得可直接套用的路由守卫与指令代码模板、权限判断函数的实现思路,以及侧边栏导航过滤等扩展方向,便于按项目实际需求定制更复杂的权限方案。

1. 权限控制不是"加个 v-if":从菜单到按钮的完整闭环

很多团队第一次做后台系统时,权限控制就是登录后拿一个role字段,然后在模板里到处写v-if="role === '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-for="route in routes" :key="route.path"> <!-- 有子菜单且子项多于一个 --> <el-sub-menu v-if="route.children && route.children.length > 1" :index="resolvePath(route.path)" > <template #title> <span>{{ route.meta.title }}</span> </template> <sidebar-item :routes="route.children" :base-path="route.path" /> </el-sub-menu> <!-- 单个子项,直接渲染成菜单项 --> <el-menu-item v-else :index="resolvePath(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, '/') } </script>

resolvePath里那个replace(/\/+/g, '/')是防手滑的——父子路径拼接时很容易出现//双斜杠,菜单高亮就会失效。这种小坑不踩一次根本想不到。

4. 按钮级权限:自定义指令比 v-if 更省心

路由管住了"能不能进页面",但页面里的"新增/删除/导出"按钮还得单独控制。用v-if当然能实现,但每个按钮都写一遍hasPermission('xxx')太啰嗦,而且权限码散落在模板里,改起来痛苦。自定义指令是更优雅的方案。

4.1 实现 v-permission 指令

// directives/permission.js import { usePermissionStore } from '@/stores/permission' /** * v-permission 指令 * 用法一:v-permission="'order: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 class="toolbar"> <el-button v-permission="'order:add'" type="primary">新增订单</el-button> <el-button v-permission="['order:edit', 'order:audit']">编辑</el-button> <el-button v-permission="'order:delete'" type="danger">删除</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-permission="PERM.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-if="canAudit" type="primary">审核</el-button> <el-button v-if="hasPerm('order:export:excel')">导出</el-button> </template>

hasPermWith返回的是computed,所以当rowStatus变化时按钮会自动更新,这是指令做不到的。代价是模板里要写v-if,但换来的是动态响应能力,值得。

再补一个验证技巧:权限功能做完后,别只用管理员账号点一遍就完事。准备三个账号——全权限管理员、部分权限操作员、无权限访客,分别测这四件事:直接输入无权限 URL 能否被拦、刷新后菜单是否还在、按钮是否按预期显隐、退出再登录是否串权限。这四步跑通,基本就稳了。我自己吃过亏,曾经只测了管理员,上线后操作员登录发现菜单全空,原因是过滤函数里roles判断写反了,some写成了every。这种低级错误,多账号测一遍就能当场抓出来。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询