简介:这份资源面向正在开发后台管理系统的Vue前端开发者,聚焦权限控制这一核心场景,帮助解决不同角色用户只能访问被授权功能的问题。内容围绕两种主流限制方式展开:一是路由不可见,借助Vue Router的meta字段存储权限列表,并通过全局前置守卫beforeEach完成校验与重定向;二是元素不可见,通过自定义指令v-acl在元素插入时判断权限并隐藏无权操作的按钮或链接,避免无效请求带来的糟糕交互。资源包共1个文件,为PDF格式,大小约55KB,结构紧凑,适合快速查阅与对照实践。目前已有778人学习下载,说明该主题在Vue权限控制领域具备一定关注度。读者可从中获得可直接参考的代码示例与实现思路,理解路由级与元素级权限校验的配合方式,并在此基础上按需扩展,适配更复杂的权限管理场景。
1. 权限控制不是玄学:从菜单消失到按钮变灰的完整链路
很多前端开发者第一次接到“给系统加权限”的需求时,脑子里浮现的画面是后端返回一个角色字段,前端用v-if判断一下。真动手才发现,菜单要动态生成、路由要拦截、按钮要按角色隐藏、刷新页面后权限状态还得恢复——四个问题环环相扣,任何一个没处理好,用户要么看到不该看的页面,要么刷新后权限全丢。Vue 项目实现简单的权限控制管理功能,核心就三件事:路由级控制决定“能不能进这个页面”,菜单级控制决定“侧边栏显示什么”,按钮级控制决定“页面里哪些操作可用”。这三层从粗到细,层层递进,缺一层都会留下安全缺口。适合已经用 Vue 做过一两个项目、但对权限体系还没有系统认知的前端开发者,也适合正在做后台管理系统、需要快速落地一套可维护权限方案的工程师。
2. 路由级权限:动态路由表怎么拆、怎么挂、怎么守
路由级权限是整个权限体系的骨架。用户登录后,前端拿到角色或权限标识,据此决定哪些路由可以注册、哪些直接拦截。这一步做不好,后面菜单和按钮的权限都无从谈起。
2.1 静态路由与动态路由的拆分逻辑
常见做法是把路由分成两块:静态路由(constantRoutes)和动态路由(asyncRoutes)。静态路由是所有角色都能访问的页面,比如登录页、404 页、首页重定向;动态路由则按权限标识做过滤,只有匹配上的才注册到 router 实例里。
拆分时有一个容易翻车的点:不要把动态路由直接写死在router/index.js里然后靠beforeEach去判断。这样做的问题是,路由表本身是完整的,用户手动输入 URL 仍然可能匹配到未授权的路由组件。正确做法是:动态路由单独定义,登录后根据权限过滤,再用router.addRoute()逐条挂载。
// router/index.js import { createRouter, createWebHistory } from 'vue-router' // 静态路由:所有角色可访问 const constantRoutes = [ { path: '/login', component: () => import('@/views/Login.vue') }, { path: '/404', component: () => import('@/views/NotFound.vue') }, { path: '/', redirect: '/dashboard' } ] // 动态路由:按权限标识过滤后挂载 const asyncRoutes = [ { path: '/dashboard', component: () => import('@/layout/index.vue'), children: [ { path: '', name: 'Dashboard', component: () => import('@/views/Dashboard.vue'), meta: { title: '工作台', permission: 'dashboard:view' } } ] }, { path: '/system', component: () => import('@/layout/index.vue'), meta: { title: '系统管理', permission: 'system:view' }, children: [ { path: 'user', name: 'UserManage', component: () => import('@/views/system/User.vue'), meta: { title: '用户管理', permission: 'system:user:list' } }, { path: 'role', name: 'RoleManage', component: () => import('@/views/system/Role.vue'), meta: { title: '角色管理', permission: 'system:role:list' } } ] } ] const router = createRouter({ history: createWebHistory(), routes: constantRoutes }) export { constantRoutes, asyncRoutes } export default router这段代码的关键在于meta.permission字段。每个动态路由节点都带一个权限标识,过滤时递归检查这个字段。父路由的permission和子路由的permission是独立的——父路由控制菜单分组是否显示,子路由控制具体页面是否可访问。如果父路由没有permission字段,默认视为所有有权限访问其子路由的角色都能看到这个分组。
2.2 权限过滤函数与 addRoute 的挂载时机
过滤函数要做两件事:递归遍历asyncRoutes,检查每个节点的meta.permission是否在用户权限列表里;如果父节点被过滤掉,子节点也一并丢弃。挂载时机放在全局前置守卫里,登录后第一次进入受保护路由时执行。
// permission.js —— 全局路由守卫 import router, { asyncRoutes, constantRoutes } from './router' import { useUserStore } from '@/stores/user' // 递归过滤动态路由 function filterAsyncRoutes(routes, permissions) { const result = [] routes.forEach(route => { const tmp = { ...route } const required = tmp.meta?.permission // 没有权限标识的节点默认放行 if (!required || permissions.includes(required)) { if (tmp.children) { tmp.children = filterAsyncRoutes(tmp.children, permissions) } result.push(tmp) } }) return result } router.beforeEach(async (to, from, next) => { const userStore = useUserStore() if (to.path === '/login') { next() return } // 未登录,跳转登录页 if (!userStore.token) { next('/login') return } // 已登录但未加载权限路由 if (!userStore.routesLoaded) { try { // 从后端拉取当前用户的权限标识列表 const permissions = await userStore.fetchPermissions() const accessibleRoutes = filterAsyncRoutes(asyncRoutes, permissions) // 逐条挂载,避免重复注册 accessibleRoutes.forEach(route => { router.addRoute(route) }) // 兜底 404 必须最后挂载 router.addRoute({ path: '/:pathMatch(.*)*', redirect: '/404' }) userStore.routesLoaded = true // replace: true 避免在历史记录里留下重定向痕迹 next({ ...to, replace: true }) } catch (err) { userStore.resetToken() next('/login') } return } next() })filterAsyncRoutes的递归逻辑是:先浅拷贝当前节点,检查meta.permission是否存在且在当前用户权限列表里。如果通过,再递归处理children。注意permissions.includes(required)这一句——权限标识的命名建议用模块:资源:操作三段式,比如system:user:list,这样后端返回的权限列表和前端路由的meta.permission能一一对应,排查问题时也容易定位。
next({ ...to, replace: true })这一行是血泪经验。动态路由挂载后,当前导航目标to在挂载前是不存在的,直接next()会白屏。用replace: true重新触发一次导航,让 router 用新的路由表去匹配。如果不加replace,用户点浏览器后退会回到一个不存在的路由,体验很怪。
2.3 刷新页面后权限路由丢失怎么恢复
动态路由是运行时挂载的,刷新页面后 router 实例重置,动态路由全部丢失。如果不在守卫里重新挂载,用户刷新后会被重定向到 404 或登录页。
上面代码里用userStore.routesLoaded标记是否已加载。刷新后这个标记是初始值false,守卫会重新走一遍拉权限、过滤、挂载的流程。这里有一个细节:fetchPermissions应该从后端拉取权限列表,而不是从 localStorage 读。localStorage 里的权限数据可能被篡改,而且角色变更后不会自动更新。每次刷新重新拉取,虽然多一次请求,但保证了权限的实时性。
如果项目对首屏性能敏感,可以在fetchPermissions里加一层缓存:用 token 的哈希值作为 key,存在 sessionStorage 里,token 不变则复用。但角色变更后必须清掉这个缓存,否则会出现“管理员被降权后刷新页面仍然看到管理菜单”的问题。
3. 菜单级权限:侧边栏怎么根据路由表自动生成
路由挂载好了,侧边栏菜单不能写死。菜单项应该从已挂载的路由表里推导出来,这样路由和菜单天然一致,不会出现“路由有但菜单没有”或“菜单有但路由没挂”的错位。
3.1 从路由表推导菜单树
推导逻辑:遍历router.getRoutes()或直接遍历过滤后的accessibleRoutes,提取有meta.title的节点,构造成菜单树。父节点作为分组,子节点作为可点击项。
// composables/useMenu.js import { computed } from 'vue' import { useUserStore } from '@/stores/user' export function useMenu() { const userStore = useUserStore() const menuTree = computed(() => { const routes = userStore.accessibleRoutes || [] return buildMenu(routes) }) function buildMenu(routes, basePath = '') { const menus = [] routes.forEach(route => { // 跳过没有 title 的路由(如重定向、404) if (!route.meta?.title) return const fullPath = resolvePath(basePath, route.path) const menu = { title: route.meta.title, icon: route.meta.icon || '', path: fullPath, children: [] } if (route.children && route.children.length) { menu.children = buildMenu(route.children, fullPath) // 子菜单全部被过滤掉时,父菜单也不显示 if (menu.children.length === 0) return } menus.push(menu) }) return menus } function resolvePath(base, path) { if (path.startsWith('/')) return path return `${base}/${path}`.replace(/\/+/g, '/') } return { menuTree } }buildMenu里有两个判断值得注意。第一,if (!route.meta?.title) return会跳过没有标题的路由,比如纯重定向节点。第二,if (menu.children.length === 0) return处理的是“父菜单下所有子菜单都被权限过滤掉”的情况——这时候父菜单也不应该显示,否则用户点进去看到空页面。
resolvePath处理相对路径拼接。Vue Router 的子路由path如果以/开头就是绝对路径,否则是相对父路由的路径。菜单的path必须是完整路径,否则点击菜单跳转时会匹配不到路由。
3.2 菜单渲染与高亮联动
菜单组件用递归组件渲染,高亮状态根据当前路由$route.path匹配。这里有一个常见坑:如果菜单项的path和实际路由的path因为斜杠或参数不一致,高亮会失效。
<!-- components/SideMenu.vue --> <template> <el-menu :default-active="activeMenu" :collapse="isCollapse" router > <template v-for="menu in menuTree" :key="menu.path"> <!-- 有子菜单 --> <el-sub-menu v-if="menu.children.length" :index="menu.path"> <template #title> <span>{{ menu.title }}</span> </template> <el-menu-item v-for="child in menu.children" :key="child.path" :index="child.path" > {{ child.title }} </el-menu-item> </el-sub-menu> <!-- 无子菜单 --> <el-menu-item v-else :index="menu.path"> {{ menu.title }} </el-menu-item> </template> </el-menu> </template> <script setup> import { computed } from 'vue' import { useRoute } from 'vue-router' import { useMenu } from '@/composables/useMenu' const route = useRoute() const { menuTree } = useMenu() // 优先用 meta.activeMenu,支持详情页高亮列表页菜单 const activeMenu = computed(() => { return route.meta?.activeMenu || route.path }) </script>activeMenu的计算逻辑里,route.meta.activeMenu是一个实用技巧。比如用户管理列表页路径是/system/user,详情页路径是/system/user/123,详情页的菜单高亮应该仍然落在“用户管理”上。在详情页路由的meta里写activeMenu: '/system/user',菜单高亮就能正确联动。
el-menu的router属性开启后,点击菜单项会自动调用router.push,不需要手动绑定点击事件。但要注意index必须是完整路径,和路由的path完全一致,否则跳转失败。
4. 按钮级权限:指令、函数、组件三种粒度怎么选
按钮级权限是最后一道防线。路由级权限挡住了页面,但页面里的“删除”“导出”“审批”这些操作按钮,需要更细粒度的控制。常见做法有三种:自定义指令v-permission、权限判断函数hasPermission()、权限包裹组件<PermissionWrapper>。三种方式各有适用场景,选错了要么代码冗余,要么维护困难。
4.1 v-permission 指令的实现与边界
自定义指令适合“元素级”的权限控制——按钮、链接、图标,有权限就显示,没权限就移除。
// directives/permission.js import { useUserStore } from '@/stores/user' export const permission = { mounted(el, binding) { const { value } = binding const userStore = useUserStore() const permissions = userStore.permissions || [] if (value && value.length) { // 支持数组:满足任意一个即可显示 const hasAuth = value.some(p => permissions.includes(p)) if (!hasAuth) { // 从 DOM 中移除,而不是 display:none el.parentNode && el.parentNode.removeChild(el) } } } } // main.js 注册 // app.directive('permission', permission)指令的value支持字符串或数组。传数组时用some判断,满足任意一个权限就显示。这里用removeChild而不是display: none,原因是display: none的元素仍然在 DOM 里,用户可以通过开发者工具改样式让它显示出来。虽然前端权限本质上防不住有心人,但移除 DOM 至少提高了操作门槛。
指令的边界在于:它只处理“挂载时”的权限判断。如果用户权限在页面运行期间动态变化(比如切换角色),指令不会重新执行。这种场景需要配合v-if和响应式权限数据,或者用key强制重新渲染。
4.2 权限判断函数在逻辑层的使用
有些场景不适合用指令,比如表格列的动态显示、根据权限决定跳转目标、在setup里做条件渲染。这时候用函数更灵活。
// composables/usePermission.js import { useUserStore } from '@/stores/user' export function usePermission() { const userStore = useUserStore() // 单个权限判断 function hasPermission(perm) { if (!perm) return true const permissions = userStore.permissions || [] return permissions.includes(perm) } // 任意一个满足 function hasAnyPermission(perms) { if (!perms || !perms.length) return true return perms.some(p => hasPermission(p)) } // 全部满足 function hasAllPermissions(perms) { if (!perms || !perms.length) return true return perms.every(p => hasPermission(p)) } return { hasPermission, hasAnyPermission, hasAllPermissions } }在模板里用v-if="hasPermission('system:user:delete')",在逻辑里用if (hasAllPermissions(['a', 'b']))。函数方式的优势是可以在任何地方调用,包括路由守卫、axios 拦截器、甚至工具函数里。但缺点是模板里写太多v-if会让可读性下降,这时候可以考虑用权限包裹组件。
4.3 权限包裹组件的适用场景
当多个按钮共享同一组权限判断,或者需要在权限不足时显示占位内容(比如“无权限操作”的提示),用组件更合适。
<!-- components/PermissionWrapper.vue --> <template> <slot v-if="visible" /> <slot v-else name="fallback" /> </template> <script setup> import { computed } from 'vue' import { usePermission } from '@/composables/usePermission' const props = defineProps({ perms: { type: [String, Array], default: '' }, mode: { type: String, default: 'any' } // any | all }) const { hasAnyPermission, hasAllPermissions } = usePermission() const visible = computed(() => { const list = Array.isArray(props.perms) ? props.perms : [props.perms] if (props.mode === 'all') return hasAllPermissions(list) return hasAnyPermission(list) }) </script>使用方式:<PermissionWrapper perms="system:user:delete"><el-button>删除</el-button></PermissionWrapper>。需要显示占位时,用fallback插槽。组件的优势是语义清晰,模板里一眼能看出权限边界;缺点是嵌套层级变深,简单场景下不如指令直接。
三种方式的选择建议:单个按钮用指令,逻辑判断用函数,一组按钮或需要占位用组件。不要为了统一而全部用同一种,那样反而增加维护成本。
5. 避坑与排查:权限控制里最容易翻车的五个地方
5.1 刷新后 404:动态路由挂载顺序错了
现象:登录后正常,刷新页面直接跳到 404,或者白屏。
原因:动态路由在守卫里挂载后,next()没有重新触发导航,或者 404 兜底路由在动态路由之前挂载了,导致所有未匹配路径先被 404 捕获。
解决:挂载完动态路由后,用next({ ...to, replace: true })重新导航;404 兜底路由必须在所有动态路由挂载完之后再addRoute,且只挂一次。
5.2 菜单高亮失效:path 不一致
现象:点击菜单跳转正常,但菜单项不高亮,或者高亮停留在上一个菜单。
原因:菜单项的index和路由的path不一致,常见于子路由用了相对路径、或者路由有params导致$route.path和菜单index不匹配。
解决:菜单index统一用完整绝对路径;详情页用meta.activeMenu指定高亮目标;在resolvePath里统一处理斜杠拼接。
5.3 权限变更后不生效:缓存没清
现象:管理员在后台改了某个角色的权限,用户刷新页面后仍然看到旧的菜单和按钮。
原因:权限列表缓存在 localStorage 或 sessionStorage 里,刷新时直接读缓存,没有重新拉取。
解决:权限列表每次刷新重新拉取,或者用 token 哈希做缓存 key,token 不变才复用。角色变更后强制清缓存并重新登录。
5.4 按钮权限指令不生效:v-if 和 v-permission 冲突
现象:同时用了v-if和v-permission,按钮该隐藏的时候没隐藏。
原因:v-if的优先级高于自定义指令,当v-if为 false 时元素根本不渲染,指令的mounted不会执行;当v-if为 true 但权限不足时,指令执行移除,但 Vue 的虚拟 DOM 可能在下一次更新时重新创建元素。
解决:不要同时用v-if和v-permission控制同一个元素。用v-permission就不要再加v-if;需要条件渲染时,用hasPermission()函数配合v-if。
5.5 动态路由重复挂载:addRoute 没有去重
现象:多次登录/登出后,路由表里出现重复路由,控制台警告 “Duplicate route name”。
原因:router.addRoute每次调用都会添加,没有检查是否已存在。登出时没有重置路由表。
解决:登出时调用router.removeRoute移除动态路由,或者用router.addRoute返回的移除函数统一清理。更简单的做法是登出后直接location.reload(),让 router 实例重置。
6. 进阶技巧:用路由 meta 做权限继承与按钮级联
前面讲的权限控制是“扁平”的——每个路由、每个按钮独立判断。实际项目里,权限往往有层级关系:能访问“系统管理”的人,通常也能访问“用户管理”和“角色管理”;能“编辑用户”的人,通常也能“查看用户”。如果每个地方都写完整的权限标识,维护起来很痛苦。
一个实用技巧是利用路由的meta做权限继承。在父路由的meta里定义permission,子路由如果没有自己的permission,就继承父级的。过滤函数里加一行判断:
function filterAsyncRoutes(routes, permissions, parentPerm = '') { const result = [] routes.forEach(route => { const tmp = { ...route } const required = tmp.meta?.permission || parentPerm if (!required || permissions.includes(required)) { if (tmp.children) { tmp.children = filterAsyncRoutes(tmp.children, permissions, required) } result.push(tmp) } }) return result }这样/system下的所有子路由,只要父级permission: 'system:view'通过,子路由默认也通过。子路由需要更细粒度控制时,再单独写meta.permission覆盖。
按钮级联的思路类似:后端返回的权限列表里,如果包含system:user:edit,前端在判断system:user:view时自动视为通过。实现方式是在fetchPermissions之后做一次权限展开:
function expandPermissions(perms) { const expanded = new Set(perms) perms.forEach(p => { const parts = p.split(':') // system:user:edit -> system:user:view, system:view while (parts.length > 1) { parts.pop() expanded.add([...parts, 'view'].join(':')) expanded.add(parts.join(':')) } }) return [...expanded] }这个展开逻辑是:system:user:edit会派生出system:user:view、system:user、system:view、system。这样前端只需要判断最细粒度的权限,粗粒度的权限自动满足。后端返回的权限列表可以只给最细的,减少配置量。
验证权限展开是否正确,可以在控制台打印userStore.permissions,对比后端返回的原始列表和展开后的列表。如果发现某个按钮该显示却没显示,先检查权限标识是否拼写一致,再检查展开逻辑是否覆盖到了。
我自己的习惯是:权限标识统一用小写加冒号分隔,后端返回的列表和前端meta.permission用同一套命名规范,任何一边改动都要同步更新。权限问题排查起来最耗时的不是逻辑复杂,而是命名不一致导致的“看起来有权限但就是不过”。每次加新权限,先在控制台确认permissions数组里有这个标识,再去看过滤逻辑。希望帮到你。
本文还有配套的精品资源,点击获取