1. 权限管理在后台系统里到底管什么
后台管理系统做久了,你会发现一个规律:功能越堆越多,最先扛不住的往往不是性能,而是权限。谁能看到哪个菜单、谁只能看不能改、谁连按钮都不该看见,这些问题如果一开始没设计好,后面每加一个页面都要打补丁,维护成本会指数级上升。AntDesignPro of Vue 这套中后台前端方案,本身提供了一套基于路由和角色的权限控制骨架,但很多人拿到手之后只是照着文档跑通了登录,真正落到业务里还是一团乱麻。
这篇文章围绕 AntDesignPro of Vue 的权限管理展开,把菜单权限、路由权限、按钮级权限、动态路由加载这几块拆开讲清楚。适合正在用这套框架做中后台的开发者,也适合想理解前端权限设计思路的人。我会从整体设计思路讲到具体实现,再到实际踩过的坑,尽量把每个“为什么这么做”都说明白,让你看完能直接对照自己的项目改。
先说结论:AntDesignPro of Vue 的权限体系核心就两条线,一条是路由级权限,决定用户能进哪些页面;另一条是操作级权限,决定用户在页面里能点哪些按钮。两条线都依赖一个东西——当前用户的权限标识集合。这个集合从哪来、长什么样、怎么用,是整个权限系统的地基。
2. 整体设计思路与方案选型
2.1 为什么权限要分两层来做
很多人一开始会把权限理解成“能不能登录”,登录成功就全放行。这在内部小工具里没问题,但一旦系统面向多个角色,比如管理员、运营、普通员工,就必须区分粒度。AntDesignPro of Vue 把权限拆成路由级和操作级,是有道理的。
路由级权限解决的是“页面能不能进”。如果用户没有某个页面的权限,理想情况下这个菜单根本不该出现在侧边栏,用户手动敲 URL 也应该被拦下来。操作级权限解决的是“进了页面能干什么”。同一个订单详情页,管理员能点“删除”,客服只能点“备注”,这两种角色的页面结构一样,但按钮的可见性不同。
把这两层分开的好处是职责清晰。路由权限在路由跳转时统一拦截,操作权限在组件内部按需判断。如果混在一起,每个页面都要写一遍“我能不能进”,代码会非常冗余。
2.2 权限标识的存储与流转
权限判断的本质是“比对”。用户身上带着一组权限标识,页面或按钮声明自己需要什么标识,两者匹配就放行。所以设计的第一步是确定权限标识的格式。
常见做法是用字符串,比如dashboard:view、order:delete、user:edit。这种冒号分隔的命名方式可读性好,也方便做前缀匹配。AntDesignPro of Vue 默认的角色判断用的是角色名,比如admin、user,但实际业务里更推荐用细粒度的权限点,因为角色是会变的,权限点相对稳定。
这些标识从哪来?一般是登录接口返回的。用户登录成功后,后端返回用户信息,里面包含permissions数组或者roles数组。前端拿到之后存到状态管理里,通常是 Vuex 的 store。存的位置很关键,因为路由守卫、菜单渲染、按钮判断都要读它,放在全局 store 里最方便。
注意:权限标识不要存在 localStorage 里做唯一来源。localStorage 可以被用户手动改,虽然前端权限本身防不住有心人,但至少要以内存中的 store 为准,localStorage 只做刷新后的恢复用。
2.3 静态路由与动态路由的取舍
AntDesignPro of Vue 默认给了一套静态路由表,所有页面都注册好,然后通过meta.permission字段来控制访问。这种方式简单直接,适合权限变化不频繁的系统。
另一种是动态路由,登录后根据用户权限从后端拉取路由配置,前端用router.addRoutes动态注册。这种方式适合权限差异极大的系统,比如不同租户看到的菜单完全不同。但它的复杂度也高,刷新页面时路由还没注册完就会出现白屏,需要额外处理。
我的建议是:中小型系统用静态路由加权限过滤就够了,别一上来就搞动态路由。动态路由带来的收益在多数项目里并不明显,反而容易在刷新、面包屑、标签页这些地方出问题。AntDesignPro of Vue 的静态路由方案配合meta.permission,已经能覆盖大部分场景。
3. 核心细节解析与实操要点
3.1 路由 meta 里的权限字段怎么配
在 AntDesignPro of Vue 里,路由配置通常在router.config.js或者按模块拆分的文件里。每条路由的meta对象可以挂权限信息。一个典型的配置长这样:
{ path: '/order', name: 'Order', meta: { title: '订单管理', permission: ['order:view'] }, component: () => import('@/views/order/Index') }这里的permission是个数组,意味着进入这个页面需要同时满足数组里的所有权限,还是满足任意一个?这取决于你守卫里的判断逻辑。我一般用“任意一个满足即可”,因为一个页面往往对应多个角色,写“同时满足”反而别扭。
菜单渲染的时候,会遍历路由表,检查每条路由的meta.permission和当前用户的权限集合是否有交集。没有交集的路由直接从菜单里过滤掉。这一步在src/layouts/BasicLayout.vue或者菜单生成逻辑里做。
提示:
meta里除了permission,还常放title、icon、hidden这些字段。hidden: true用来标记那些不该出现在菜单里但需要注册的路由,比如详情页、编辑页。
3.2 权限判断工具函数的封装
判断逻辑不要散落在各个组件里,一定要封装成工具函数。AntDesignPro of Vue 自带了一个checkPermission或者类似的指令,但实际项目里我建议自己再包一层,方便统一处理。
// src/utils/permission.js import store from '@/store' export function hasPermission(permission) { if (!permission) return true const userPermissions = store.getters.permissions || [] if (Array.isArray(permission)) { return permission.some(p => userPermissions.includes(p)) } return userPermissions.includes(permission) }这个函数是整个权限系统的枢纽。路由守卫用它,菜单过滤用它,按钮指令也用它。把它写好,后面所有地方都省心。
这里有个细节:permission参数可能是字符串也可能是数组,函数要能兼容。另外,如果传空,默认返回true,表示不需要权限,这样调用方不用每次都判断。
3.3 按钮级权限的三种实现方式
按钮级权限是权限管理里最琐碎的部分。一个页面可能有十几个按钮,每个按钮的权限要求都不一样。常见有三种做法。
第一种是v-if手动判断。在模板里写v-if="hasPermission('order:delete')"。这种方式最灵活,但每个按钮都要写一遍,代码里到处是权限判断,可读性差。
第二种是自定义指令。注册一个v-permission指令,用法是v-permission="'order:delete'"。指令内部调用hasPermission,没有权限就把元素从 DOM 里移除。这种方式比v-if干净,推荐使用。
// src/directives/permission.js import { hasPermission } from '@/utils/permission' export default { inserted(el, binding) { const { value } = binding if (!hasPermission(value)) { el.parentNode && el.parentNode.removeChild(el) } } }第三种是封装权限组件,比如<Permission :value="'order:delete'"><button>删除</button></Permission>。这种方式适合需要保留占位或者做更复杂逻辑的场景,但用起来比指令啰嗦。
我一般用指令为主,个别复杂场景用组件。指令的inserted钩子只在元素插入时执行一次,如果权限在运行时动态变化,需要配合update钩子重新判断,不过这种场景很少见。
注意:指令移除元素用的是
removeChild,如果元素本身有事件监听或者被其他逻辑引用,移除后可能报错。更稳妥的做法是设置el.style.display = 'none'或者用v-if配合计算属性。实测下来,直接移除在多数场景没问题,但涉及第三方组件时要注意。
4. 实操过程与核心环节实现
4.1 登录后权限数据的获取与存储
整个流程从登录开始。用户提交账号密码,后端验证通过后返回 token 和用户信息。用户信息里最关键的是权限标识数组。
// src/store/modules/user.js const actions = { async login({ commit }, userInfo) { const { data } = await login(userInfo) commit('SET_TOKEN', data.token) commit('SET_PERMISSIONS', data.permissions) commit('SET_ROLES', data.roles) return data } }拿到权限后存到 Vuex 的 state 里,同时通过 getter 暴露出去。这里有个坑:如果用户刷新页面,Vuex 里的数据会丢失,因为 Vuex 是内存存储。所以需要在应用初始化时,用 token 重新拉一次用户信息,把权限恢复回来。
这一步通常在路由守卫的beforeEach里做。判断有没有 token,有 token 但 store 里没有权限信息,就调接口拉取,拉取成功后再放行。如果拉取失败,说明 token 失效,跳回登录页。
router.beforeEach(async (to, from, next) => { const token = store.getters.token if (!token) { if (to.path === '/login') { next() } else { next('/login') } return } const permissions = store.getters.permissions if (permissions && permissions.length) { next() } else { try { await store.dispatch('getUserInfo') next() } catch (e) { await store.dispatch('logout') next('/login') } } })这段守卫逻辑是权限系统的入口,写的时候要特别注意死循环问题。比如getUserInfo失败后跳登录页,登录页本身又触发守卫,如果判断不当就会无限循环。所以登录页要单独放行。
4.2 菜单的动态过滤与渲染
菜单过滤发生在布局组件里。AntDesignPro of Vue 的侧边栏菜单是根据路由表生成的,所以过滤路由就等于过滤菜单。
// src/layouts/BasicLayout.vue computed: { menuData() { return filterMenuData(this.$router.options.routes) } } function filterMenuData(routes) { return routes.filter(route => { if (route.meta && route.meta.permission) { return hasPermission(route.meta.permission) } return true }).map(route => { if (route.children) { route.children = filterMenuData(route.children) } return route }) }这段递归过滤的逻辑要注意两点。一是过滤后要保留原始路由表,不能直接改this.$router.options.routes,否则下次过滤时数据已经被改过了。二是父级路由如果没有权限但子路由有权限,处理方式要明确。我一般让父级只要有任意子级可见就保留,否则整个父级隐藏。
菜单渲染还有个细节是图标和标题。meta.title用来显示菜单名,meta.icon用来显示图标。如果标题需要国际化,meta.title存的是 i18n 的 key,渲染时再翻译。
4.3 路由守卫中的权限拦截
菜单过滤只是让用户看不见,但用户手动输入 URL 还是能访问。所以路由守卫里必须再做一次拦截。
router.beforeEach((to, from, next) => { const permission = to.meta && to.meta.permission if (permission && !hasPermission(permission)) { next('/403') return } next() })这段逻辑要放在权限数据加载完成之后。如果权限还没拉回来就判断,会把有权限的用户也拦下来。所以守卫的执行顺序很重要:先确保权限数据就绪,再做权限判断。
403 页面是必须的,用来告诉用户“你没权限”。有些项目会直接跳回首页,但这样用户会困惑,不如给个明确的提示页。
提示:路由守卫里不要做太多异步操作,否则每次跳转都会卡顿。权限数据只在第一次跳转时拉取,后续从 store 读,这样性能最好。
4.4 按钮权限指令的注册与使用
指令注册在main.js里全局挂载:
import permission from '@/directives/permission' Vue.directive('permission', permission)使用的时候直接在按钮上写:
<a-button v-permission="'order:delete'" type="danger">删除</a-button>指令的inserted钩子在元素插入父节点时触发,这时候判断权限并决定是否移除。如果按钮是在v-if或者列表渲染里动态生成的,inserted也会正常触发。
有个场景要注意:如果按钮的权限依赖的数据是异步加载的,比如权限列表在按钮渲染后才回来,那inserted执行时权限还是空的,按钮会被误删。解决办法是把权限数据放在路由守卫里提前加载好,确保组件渲染时权限已经就绪。
5. 常见问题与排查技巧实录
5.1 刷新页面后菜单消失或权限失效
这是最常见的问题,根源是 Vuex 数据在刷新后丢失。表现是刷新页面后菜单变少,或者直接跳到 403。
排查思路:先看路由守卫里有没有在权限为空时重新拉取用户信息。如果没有,加上拉取逻辑。如果有,检查拉取接口是否正常返回,以及返回后有没有正确 commit 到 store。
还有一种情况是拉取是异步的,但守卫没有await,导致权限还没写入就执行了判断。这种问题在开发环境因为接口快不容易发现,生产环境接口慢就暴露了。
5.2 动态路由刷新后 404
如果用动态路由方案,刷新页面时路由还没注册,用户访问的路径匹配不到任何路由,就会 404。
解决办法是在路由守卫里判断,如果路由表里没有当前路径,先注册动态路由再next({ ...to, replace: true })重新导航。这个replace: true很关键,避免在历史记录里留下多余条目。
不过前面也说了,我一般不建议用动态路由,静态路由加权限过滤能避开这个坑。
5.3 按钮权限指令不生效
指令不生效通常有几个原因。一是指令没注册,检查main.js里有没有Vue.directive。二是权限标识写错了,和后端返回的对不上,这种要打印出来比对。三是指令的钩子用错了,inserted只在插入时执行,如果元素是后来才插入的,要用update或者componentUpdated。
还有一种隐蔽情况是按钮被包裹在v-if里,父级没渲染时指令不执行,父级渲染后指令才执行,这时候权限判断是正常的,但如果权限数据此时还没到,就会误判。
5.4 权限标识的设计混乱
很多项目后期权限难维护,根源是权限标识一开始没设计好。比如有的用角色名,有的用权限点,有的用中文,混在一起。
建议统一用模块:操作的格式,比如user:view、user:edit、user:delete。模块名和路由的 path 对应,操作名用动词。这样一看就知道这个权限是管什么的。
另外,权限标识要集中管理,不要散落在各个文件里。可以建一个src/constants/permission.js,把所有权限点定义成常量,用的时候引用常量而不是写字符串,避免拼写错误。
| 常见问题 | 排查方向 | 解决要点 |
|---|---|---|
| 刷新后菜单消失 | Vuex 数据丢失 | 守卫里重新拉取用户信息 |
| 动态路由 404 | 路由未注册 | 守卫里注册后 replace 重导航 |
| 按钮指令不生效 | 指令未注册或标识错误 | 检查注册和权限字符串 |
| 权限标识混乱 | 命名不统一 | 统一格式并集中管理 |
5.5 权限变更后的实时更新
用户权限在运行中被管理员修改了,前端怎么感知?一般有两种做法。一是轮询,定时拉取权限,简单但浪费请求。二是用长连接推送,复杂但实时。
多数系统不需要实时,用户重新登录或者刷新页面时更新就够了。如果确实需要实时,可以在关键操作前再校验一次权限,后端接口本身也要做权限校验,前端只是体验层。
注意:前端权限永远只是体验优化,真正的安全防线在后端。每个接口都要独立校验权限,不能依赖前端传过来的权限标识。这一点在安全评审时经常被提到。
6. 权限系统的扩展与维护建议
6.1 权限点与角色的解耦
早期项目容易把角色和权限点绑死,比如代码里写if (role === 'admin')。这样一旦角色调整,代码就要改。正确做法是角色只作为权限点的集合,代码里只判断权限点,不判断角色。
后端返回用户信息时,可以返回角色,也可以直接返回权限点。我倾向于直接返回权限点,前端不关心角色,只关心有没有某个权限。这样角色怎么变,前端都不用动。
6.2 权限配置的可视化
系统做大之后,权限配置会变成运维负担。这时候可以考虑做一个权限管理页面,让管理员在界面上勾选角色对应的权限点。这个页面的数据存到后端,前端登录时拉取。
这个页面本身也需要权限控制,只有超级管理员能进。实现上就是普通的增删改查,但要注意权限点的树形结构展示,以及保存时的批量处理。
6.3 权限日志与审计
对于敏感操作,比如删除、导出,除了权限控制,还应该记录操作日志。日志里包含谁在什么时间做了什么操作。这部分通常在后端做,前端只需要在调用接口时带上必要信息。
前端能做的辅助是,在按钮点击时先做一次权限校验,校验不通过就不发请求,减少无效请求。但这只是优化,不能替代后端校验。
6.4 多标签页与权限的配合
AntDesignPro of Vue 有多标签页功能,用户打开的页面会以标签形式保留。如果用户权限变了,已经打开的标签页怎么处理?一般是在权限变更后刷新页面,或者关闭所有标签重新加载。
实现上可以在权限更新后,清空标签页缓存,强制重新渲染。这部分逻辑在store/modules/tagsView.js里处理,清空visitedViews和cachedViews即可。
7. 我在实际项目里踩过的几个坑
第一个坑是权限标识用了中文。早期图省事,meta.permission直接写'订单查看',结果国际化的时候傻眼了,而且后端返回的权限点是英文,两边对不上。后来全部改成英文的模块:操作格式,才理顺。
第二个坑是路由守卫里做了太多事。一开始把用户信息拉取、菜单生成、权限判断全塞在beforeEach里,导致每次跳转都要跑一遍,页面切换明显卡顿。后来把能缓存的都缓存到 store,守卫里只做必要的判断,流畅多了。
第三个坑是按钮权限指令直接移除 DOM 导致第三方组件报错。有个表格的操作列用了第三方组件,指令移除按钮后组件内部还在引用这个节点,控制台一直报错。后来改成用v-if配合计算属性,虽然代码多写一点,但稳定。
第四个坑是忘了处理 403 页面本身的权限。403 页面如果也配了权限,用户没权限访问 403,就会无限重定向。所以 403、404、登录页这些基础页面一定要在守卫里放行,不参与权限判断。
这些坑说到底都是细节,但权限系统恰恰是细节决定成败的地方。设计的时候多想一步,后面就少改十行。AntDesignPro of Vue 给的骨架已经够用,关键是把权限标识设计好、把守卫逻辑写稳、把边界情况考虑全。剩下的就是根据业务慢慢调,没有一劳永逸的方案,只有不断迭代的实践。