摘要:在 B 端/SaaS 系统中,权限控制是核心命脉。传统的 Vue 项目常常面临“刷新页面路由丢失”、“按钮级权限依赖繁琐的 v-if”、“前端越权操作”等痛点。本文基于 Vue3 + Pinia + Vue Router 4,深度剖析一套“静态路由兜底 + 全局守卫拦截 + 自定义指令”相结合的企业级权限架构。我们将从底层源码出发,解决路由死循环、权限响应式更新、以及成员越权隔离等极端场景问题。
一、 引言:前端权限控制的“三座大山”
在开发企业级应用时,权限控制绝非简单的“登录后跳转”。它通常包含以下三个维度的复杂诉求:
- 路由级(页面级)权限:用户不能通过在浏览器输入 URL 的方式,访问他没有权限的页面。
- 按钮级(操作级)权限:同一个页面,管理员能看到“删除”按钮,普通员工必须看不到。
- 越权隔离操作:在成员管理模块,项目管理员不能把另一个项目管理员踢出群,组织级管理员不能越权去操作项目级成员。
在项目启动初期,我曾毫不犹豫地采用了业界非常流行的“全动态路由(router.addRoute)”方案。这看起来非常“高大上”,能根据后端菜单瞬间生成路由。
“由于 AITest 平台采用了多租户隔离设计,不同空间(Workspace)下的角色和权限各不相同。这就要求前端的鉴权不仅要细粒度到按钮,还要能支持跨层级越权隔离。这正是我放弃大厂开源模板,自研前端权限架构的根本原因。”
但当我真正将其放入高并发的 SaaS 环境中测试时,噩梦来了:
刷新白屏危机:Vite 打包后,后端返回的字符串路径极易导致动态导入失败,用户只要按一下 F5,页面就直接 404。
调试成本极高:路由表变成不可预测的动态状态,出现死循环重定向时极难排查。
架构选型的血泪史:从“动态”回归“静态”
经过对业界主流方案的调研与压测深思熟虑,我果断推翻了动态路由方案,敲定了 “静态骨架兜底 + 全局守卫拦截 + 后端 API 鉴权” 的黄金三角方案。今天,我就来复盘这套彻底解决白屏焦虑的企业级架构。
二、 架构设计:为什么放弃纯动态路由(addRoute)?
在 Vue Router 4 中,虽然 addRoute 可以根据后端菜单动态挂载路由,但在真实的 SaaS 平台开发中,这种做法存在致命缺陷:刷新白屏危机与Vite 打包死局(后端返回字符串组件极易导致动态导入失败)。
我们的最终选型:
在 router/index.ts 中,将系统的核心页面作为静态路由写死。真正的权限校验,全部交由全局路由守卫(Guards)和 Pinia 状态管理在运行时拦截。这保证了极致的加载速度,彻底规避了白屏风险。
三、 核心实现一:全局路由守卫与 401 拦截
守卫的核心职责是:决定用户“能不能进这个门”,以及“遇到异常时怎么优雅退出”。
// src/router/guards.tsimporttype{Router}from'vue-router'import{useUserStore}from'@/stores/user'import{usePermissionStore}from'@/stores/permission'importi18nfrom'@/locales'exportfunctionsetupRouterGuards(router:Router){router.beforeEach(asyncto=>{constuserStore=useUserStore()constpermissionStore=usePermissionStore()// 设置页面标题(支持国际化)consttitleKey=to.meta?.titleasstring|undefinedconstappName=i18n.global.t('login.title')document.title=titleKey?`${i18n.global.t(titleKey)}-${appName}`:appName// 1. 白名单放行if(to.meta?.requiresAuth===false)returntrue// 2. 未登录跳转登录页if(!userStore.token){return{path:'/login',query:{redirect:to.fullPath}}}// 3. 系统管理员直接放行if(userStore.userInfo?.is_superadmin)returntrue// 4. 核心:如果未加载,强制等待完成if(!permissionStore.loaded||permissionStore.permissions.length===0){try{// 加上 force = true,确保刚登录时一定要发请求拿最新数据awaitpermissionStore.fetchPermissions(true)}catch{// 异常处理:如果获取权限失败(比如 token 过期),清除 token 并跳回登录页userStore.clearToken()return{path:'/login'}}}// 5. 细粒度路由校验constrequiredPermission=to.meta?.permissionasstring|undefinedif(requiredPermission&&!permissionStore.hasPermission(requiredPermission)){return{path:'/403'}}returntrue})}踩坑实录一:被 401 状态码搞出的无限死循环
最初测试时,只要用户的 Token 过期,浏览器就直接卡死,提示 Maximum call stack size exceeded。
原因:Token 过期后,拉取权限接口返回 401。Axios 拦截器捕获 401 后清空了 Token 并跳转 /login。此时路由守卫再次触发,发现没 Token 又跳 /login,导致死循环。
解决:必须加上 try…catch,并在 catch 块里强制 userStore.clearToken(),彻底斩断路由重定向的循环。
四、 核心实现二:Pinia 状态管理与成员越权隔离
除了基础权限编码,这个 Store 里还封装了极其硬核的“成员操作判断逻辑(canOperateMember)”,将越权拦截提前到了前端。
// src/stores/permission.tsimport{defineStore}from'pinia'import{ref}from'vue'import{getMyPermissions}from'@/api/auth'import{useUserStore}from'@/stores/user'/** 角色权重(与后端保持同步) */exportconstROLE_WEIGHTS:Record<string,number>={system_admin:100,workspace_admin:80,executive:70,workspace_member:60,project_admin:50,project_manager:40,developer:30,tester:20,product_manager:20,normal_user:10,}exportconstusePermissionStore=defineStore('permission',()=>{constpermissions=ref<string[]>([])constloaded=ref(false)constloading=ref(false)// 从后端拉取当前用户权限(带并发锁)constfetchPermissions=async(force=false):Promise<string[]>=>{if(!force&&loaded.value)returnpermissions.valueif(loading.value)returnpermissions.value loading.value=truetry{constcodes=awaitgetMyPermissions()permissions.value=codes loaded.value=truereturncodes}catch(e){console.error('fetchPermissions failed:',e)return[]}finally{loading.value=false}}consthasPermission=(code:string):boolean=>permissions.value.includes(code)/** * 判断当前用户能否操作目标成员(极其硬核的权重与层级隔离) */constcanOperateMember=(scope:'workspace'|'project',scopeId:number,targetRoleCode:string,targetUserId:number,currentUserId:number,):boolean=>{constuserStore=useUserStore()// 1. 不能操作自己if(targetUserId===currentUserId)returnfalse// 2. 超管放行if(userStore.isSuperadmin)returntrue// 3. 非超管不能操作 system_adminif(targetRoleCode==='system_admin')returnfalse// 4. 跨层级隔离(只允许操作同层级成员)if(scope==='workspace'&&!['workspace_admin','executive','workspace_member'].includes(targetRoleCode))returnfalseif(scope==='project'&&!['project_admin','project_manager','developer','tester','product_manager','normal_user'].includes(targetRoleCode))returnfalse// 5. 权重比较:操作者权重必须严格大于目标constoperatorRole=scope==='workspace'?userStore.workspaceRolesMap[String(scopeId)]:userStore.projectRolesMap[String(scopeId)]constoperatorWeight=ROLE_WEIGHTS[operatorRole??'']??0consttargetWeight=ROLE_WEIGHTS[targetRoleCode]??0returnoperatorWeight>targetWeight}return{permissions,loaded,loading,fetchPermissions,hasPermission,canOperateMember}})五、 核心实现三:响应式按钮级权限指令
大部分教程里的 v-permission 指令,要么是直接 removeChild,要么无法响应权限变化。我们来看工业级怎么写:
// src/directives/permission.tsimporttype{App,Directive,DirectiveBinding}from'vue'import{watch,nextTick}from'vue'import{usePermissionStore}from'@/stores/permission'import{useUserStore}from'@/stores/user'constpermissionDirective:Directive<HTMLElement,string|string[]>={mounted(el,binding){constapplyPermission=()=>{constuserStore=useUserStore()constpermissionStore=usePermissionStore()constvalue=binding.valueconsthasPerm=userStore.userInfo?.is_superadmin||(Array.isArray(value)?value.some(c=>permissionStore.hasPermission(c)):permissionStore.hasPermission(value))// 核心优化:用 !important 防止外部样式覆盖el.style.setProperty('display',hasPerm?'':'none',hasPerm?'':'important')}applyPermission()// 惊艳一笔:监听权限变化,保证数据回来时能重新显示conststop=watch(()=>{constuserStore=useUserStore()constpermissionStore=usePermissionStore()return[userStore.userInfo?.is_superadmin,permissionStore.permissions.join(',')]asconst},()=>{nextTick(applyPermission)// 等 DOM 稳定后重新计算});(elasany)._stopWatch=stop},updated(el,binding){// 指令值变化时重新判断if(binding.value!==binding.oldValue){// ... (调用 applyPermission 逻辑)}},unmounted(el){// 清理监听,防止内存泄漏(elasany)._stopWatch?.()},}exportfunctionsetupPermissionDirective(app:App){app.directive('permission',permissionDirective)}踩坑实录二:removeChild 导致 Vue Diff 崩溃
最初我隐藏按钮用的是 el.parentNode?.removeChild(el),但这会导致 Vue 在更新虚拟 DOM 时找不到真实节点,从而报错。
解决:改用 el.style.setProperty(‘display’, ‘none’, ‘important’)。同时在组件卸载时,务必销毁内置的 watch,避免内存泄漏。
六、 总结
前端权限的本质是体验隔离。我们通过静态路由守卫保证了极速加载,通过强大的 Pinia 实现了复杂的越权判断,再通过响应式指令让 UI 呈现极其优雅。这套方案已在我们独立开发的全栈智测平台(AITest V1.0)中稳定运行,希望这篇基于真实源码的实战复盘,能帮你绕开权限控制的那些坑。
我是 Jason,独立开发者。专注全栈开发与架构设计,AI 智能落地。个人主页有我连载的 AITest 实战复盘,欢迎关注,随时交流。