按钮级权限用指令写:Vue3 v-permission 从封装到落地
导读:管理后台的权限,路由级好做,v-if 一判就行。真正烦的是按钮级——“这个岗位只能主管导出,专员只能看不能导”,如果每个按钮都写
v-if="hasPermission('job:export')",代码里全是判断,换权限规则还得一个个找。qkl-boot-admin 的做法是封装一个 v-permission 指令,权限判断收敛到一处。
Vue3 里做按钮权限,常见的土办法是全局$hasPerm方法 + v-if。能用,但有两个毛病:模板里判断逻辑散落、指令无法复用 DOM 层面逻辑(比如没权限直接移除按钮,而不是渲染一个 disabled 的假按钮)。自定义指令v-permission可以解决得更干净——没权限时直接把元素从 DOM 里移除,连点击事件都不用防。
第一步:登录后把权限码存下来
权限数据哪来?登录接口返回用户角色和权限码列表,存到 store,页面刷新时再从接口拉一次(不能只存内存,刷新就没了):
// store/modules/user.jsimport{defineStore}from'pinia';import{getPerms}from'@/api/user';exportconstuseUserStore=defineStore('user',{state:()=>({token:'',permCodes:[]// 权限码数组,如 ['job:view', 'job:export']}),actions:{asyncfetchPerms(){constres=awaitgetPerms();// GET /api/user/permsthis.permCodes=res.data.permCodes||[];returnthis.permCodes;},hasPerm(code){// 支持通配:'job:*' 匹配所有 job 权限if(this.permCodes.includes('*'))returntrue;if(this.permCodes.includes(code))returntrue;// 父级通配:有 'job:*' 时 'job:export' 也算有权限constprefix=code.split(':')[0];returnthis.permCodes.includes(`${prefix}:*`);}}});权限码的命名规则要提前定好:模块:操作,比如job:view(查看岗位)、job:export(导出岗位)、settle:audit(审核结算)。规则不统一,后面做通配就乱了——比如有人写viewJob、有人写job_view,前缀解析直接失效。
第二步:封装 v-permission 指令
在directives/permission.js里定义指令,登录后动态注册(没登录不能用,避免白屏):
// directives/permission.jsimport{useUserStore}from'@/store/modules/user';exportfunctionsetupPermissionDirective(app){app.directive('permission',{mounted(el,binding){conststore=useUserStore();constcode=binding.value;// 用法:v-permission="'job:export'"if(!code)return;// 数组形式:满足任一即可(v-permission="['job:export','job:view']")constcodes=Array.isArray(code)?code:[code];constallowed=codes.some(c=>store.hasPerm(c));if(!allowed){// 没权限:整个元素移除,连 disabled 都不用el.parentNode&&el.parentNode.removeChild(el);}}});}main.js 里注册:
// main.jsimport{createApp}from'vue';importAppfrom'./App.vue';import{setupPermissionDirective}from'@/directives/permission';constapp=createApp(App);setupPermissionDirective(app);app.mount('#app');模板里用法:
移除元素和 disabled 的区别:移除是"眼不见心不烦",彻底没有入口;disabled 是"看得见点不动",体验上更友好但保留信息泄露风险(元素还在 DOM 里)。敏感操作我倾向移除,普通操作可以用 disabled。qkl-boot-admin 的做法是:删除、导出、审核这类有后果的操作一律移除,只读类按钮用 disabled。
第三步:后端必须二次校验
前端指令只是"用户体验层"——按钮藏了,但接口还在。权限校验的真正防线在后端。前端藏按钮、后端不校验,等于门锁装在门框上没锁芯。qkl-boot 后端用注解做接口级权限:
// 导出接口:必须有 job:export 权限@PreAuthorize("hasAuthority('job:export')")@PostMapping("/job/export")publicRexportJob(@RequestBodyExportQueryquery){// 导出逻辑...returnR.ok(fileUrl);}// 删除接口:必须有 job:delete@PreAuthorize("hasAuthority('job:delete')")@DeleteMapping("/job/{id}")publicRdeleteJob(@PathVariableLongid){jobService.delete(id);returnR.ok();}前端指令 + 后端注解双保险,按钮级权限才算闭环。前端判断用 store 里的 permCodes,后端判断用 SecurityContext 里的权限——两边数据源可能不一致,但权限码必须一致,所以权限码命名规范是前后端共享的契约,最好写进接口文档。
踩坑实录:换了个角色,按钮还在,权限没生效
现象:测试账号从"专员"切到"主管",登录后岗位列表的"导出"按钮还在,点一下直接 403(后端挡住了),但按钮没藏住,很尴尬。
排查过程:F12 看 Network,发现切换角色后前端只调了登录接口,getPerms根本没发请求——store 里的 permCodes 还是旧角色的。再看代码,问题在登录流程:登录成功后直接跳首页,权限拉取放在了App.vue的onMounted里,只在应用首次挂载时跑一次,切换账号是同一个 SPA 会话,不重新挂载,权限码自然是旧的。
定位思路:这是"权限数据刷新时机"问题。权限码必须跟着用户身份走,登录/切换账号时强制刷新。不是指令写错了,是数据没更新,指令拿到的还是旧权限。
最终解决:把权限拉取挪到登录成功的 action 里,和 token 一起写入,切换账号时 store 里的 permCodes 先清空再重新拉:
asynclogin(credentials){constres=awaitloginApi(credentials);this.token=res.data.token;// 关键:登录成功立刻拉权限,覆盖旧账号的权限this.permCodes=[];awaitthis.fetchPerms();// 路由跳转returnres;},asyncswitchAccount(newToken){this.token=newToken;this.permCodes=[];// 先清空,防止旧权限残留awaitthis.fetchPerms();}同时给指令加一层兜底:如果 store 里 permCodes 还没加载(空数组),先不急着移除,等权限拉取完成再判断——否则首屏会闪一下按钮再消失:
// 首屏兜底:权限未加载时显示 loading,加载完成后指令再判断if(!store.permLoaded){awaitstore.fetchPerms();// 指令挂载时如果还没权限数据,主动拉一次}改完测试:专员登录看不到导出,主管登录秒出导出按钮,切换账号后权限即时刷新,无残留。
可以直接抄的结论
- 权限码命名统一
模块:操作,前后端共享契约,写进接口文档; - 指令里没权限直接
removeChild移除元素,敏感操作不显示、只读操作用 disabled; - 权限码必须随登录/切换账号刷新,登录 action 里清空再拉,别放在 App onMounted;
- 指令挂载时检查权限数据是否已加载,没加载先 fetch 再判断,防首屏闪烁;
- 前端指令只是体验层,后端
@PreAuthorize才是真正防线,双保险缺一不可。
按钮级权限这事,封装指令只是第一步,权限数据刷新时机才是大多数人翻车的地方。指令 + store 刷新 + 后端注解三件套配齐,权限管理才算真正落地。
项目源码:https://gitee.com/gzqkl/qkl-boot