ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Vben Admin权限体系全解:动态菜单与按钮权限控制实践

Vben Admin权限体系全解:动态菜单与按钮权限控制实践 拿Vben Admin做二次开发权限体系通常是第一个绕不开的硬骨头。很多朋友把项目跑起来后第一件事就是研究动态菜单怎么根据角色生成、按钮怎么按权限码隐藏、刷新之后权限状态会不会丢。这三个问题其实贯穿了Vben Admin的权限核心链路。今天我不打算只给你贴代码而是把从后端数据设计到前端路由注册、按钮控制的完整链路拆开讲清楚看完你可以在自己的项目里直接复刻这套方案。先说个真实感受Vben Admin这套权限设计初看会觉得文件路径分散——store里有权限模块、hooks里有权限判断函数、directives里有权限指令、路由模块里又有一堆动态路由配置。但当你把整条链路连起来看就会发现它的分工非常清晰。把这张地图刻在脑子里后面踩坑你也能自己定位问题。1. 权限体系的整体设计先分清数据、状态、路由三层关系1.1 权限数据从哪里来后端返回码的设计权限这件事前端能做的是展示控制真正的数据源头永远在后端。Vben Admin里最核心的权限数据是权限码数组简单说就是用户登录后后端把这个人能访问的所有资源标识一次性返回给前端。每个按钮、每个菜单背后都有一个权限码。以我生产项目里的接口为例登录成功后会请求用户信息接口返回的数据结构大概是这样的interface UserInfo { userId: string; userName: string; nickName: string; avatar: string; // 权限码数组比如 [dashboard:view, system:user:add, system:user:update] permissions: string[]; }这里有个非常关键的认知权限码尽量不要与后端接口地址强绑定。很多团队图省事直接用接口路径当权限码比如/api/system/user/add。能用但后患无穷。一旦后端调整了接口路径权限码就全乱了而且前端也没法在后端没改代码之前先行开发。我的经验是用语义化的资源标识module:resource:action这种三段式比如system:user:addsystem:role:export。它对前端渲染按钮、后端接口鉴权都是同一条规则对前端做v-auth判断也清晰。1.2 三层模型角色、权限码、路由的关系Vben Admin权限处理可以抽象成三层模型层级作用存储位置角色层描述用户是什么后端数据库权限码层描述用户能干什么Pinia 路由meta只读缓存路由层描述页面怎么显示动态注册的Vue Router路由不要试图用权限码直接去推导出所有菜单那样会让权限判断过度分散。正确做法是后端返回的菜单数据里每个菜单项和按钮已经绑定了权限码前端只需要拿着这些权限码去判断给不给他看。1.3 Vben Admin中实现这一模型的文件职责在Vben Admin里这套链路分布在几个核心文件中我先给你一张索引表文件/目录职责src/store/modules/user.ts用户状态保存token、用户信息、权限码数组src/store/modules/permission.ts路由状态保存动态路由、生成菜单数据src/router/routes/modules/静态路由与动态路由的meta配置src/router/helper/routeHelper.tsx把路由配置转换为菜单配置src/hooks/web/usePermission.ts权限判断逻辑菜单过滤、按钮权限判断src/directives/permission.tsv-auth指令实现也就是说数据进userStore路由判断进permissionStore渲染控制的执行动作在usePermission与v-auth里。理解了这张索引表你后面调东调西就不会找不到文件了。2. 动态菜单从后端菜单数据到Router动态路由的完整链路2.1 后端菜单数据结构怎么设计动态菜单的前提是前端能根据后端数据渲染出导航。我的做法是让后端返回一棵树形菜单结构每个节点带path、name、component、meta四个核心字段。这里的事故高发区在于component字段字符串与前端视图文件路径的对应关系后面还会细说。一个典型的数据结构如下[ { path: /dashboard, name: Dashboard, component: /dashboard/welcome/index, meta: { title: 工作台, icon: dashboard, orderNo: 1, affix: true } }, { path: /system, name: System, component: Layout, meta: { title: 系统管理, icon: setting }, children: [ { path: /system/user, name: SystemUser, component: /system/user/index, meta: { title: 用户管理, permissions: [system:user:list, system:user:update] } }, { path: /system/role, name: SystemRole, component: /system/role/index, meta: { title: 角色管理, permissions: [system:role:list] } } ] } ]注意component字段的写法。我在项目里约定顶级菜单的component固定是Layout它对应Vben Admin的布局组件二级及以下菜单component是相对src/views目录的路径字符串不带.vue后缀。这个约定前后端要统一写死不然后端多传了层级或漏传了后缀前端就会白屏。2.2 动态路由注册import.meta.glob的妙用Vite环境下Vben Admin处理动态组件靠的是import.meta.glob这是它的设计巧思。你不能在后端返回一个component字符串后就立即import(/src/views/ path)因为Vite打包时无法解析完全动态的路径。Vben Admin在src/router/helper/routeHelper.tsx中提前声明了全部视图模块const modules import.meta.glob(/src/views/**/*.vue);这样Vite在构建时就会把src/views下所有.vue文件都打包进产物并生成映射关系。运行时只要用匹配到的key去取模块// 以component路径为key去modules里找组件 let component modules[/src/views/${path}.vue]; // 如果找不到就渲染默认的异常页避免白屏 if (!component) { component modules[/src/views/error/404.vue]; }这里有一个特别容易踩的坑component字符串的路径大小写必须和实际文件系统完全一致。Windows开发环境下大小写不敏感可能跑得好好的但部署到Linux服务器上就找不到模块了。我团队里就出现过两次这种问题排查的姿势都是打开network看动态路由的加载发现404之后顺藤摸瓜找到大小写不一致。2.3 路由Meta设计菜单控制与权限过滤的桥接动态路由生成菜单靠的就是路由的meta字段。Vben Admin的meta里常用的几个字段我给你列一个实战对照meta字段作用我的建议title菜单名必填icon菜单图标选填没有就不显示图标orderNo排序号同一级菜单按orderNo升序hideMenu是否在菜单中隐藏详情页、编辑页通常设为trueaffix是否固定在标签页上工作台常用permissions该路由/模块需要的权限码结合按钮权限和菜单显示判断有一点需要特别提醒permissions数组在meta里定义的权限和菜单显示没有直接关系。菜单是否显示靠的是后端返回了这条路由数据而meta里的permissions主要是给usePermission里的hasPermission方法在需要的时候做二次判断用的。换句话说让不让他看到这个菜单是后端说了算他突然访问一个不在菜单里的URL时前端要不要拦截权限码才真正发威。2.4 常被忽略的菜单排序与缓存刷新动态菜单排序用的是meta的orderNo在transformRouteToMenu时会被解析成菜单的orderNo。如果你发现新加的菜单顺序不对基本就是两个原因一是后端返回的数据里没排序二是同级的orderNo写重复了。我的做法是后端直接返回排好序的菜单树前端transformRouteToMenu内部按orderNo做一次稳定排序。再说缓存刷新权限模块在Pinia里保存了动态路由但刷新页面后Pinia数据会清空这时必须在应用初始化时重新拉取用户信息和路由。Vben Admin在src/router/guard/index.ts的路由守卫里有isDynamicAddedRoute这个标志位来控制核心逻辑是刷新后第一次跳转如果还没生成动态路由就先调permissionStore.buildRoutesAction()再next({...to, replace: true})让路由重新走一遍。这个标志位很容易被忽略如果你自己改成登录一次以后再进系统但不重置这个标志就很容易出现刷新后跳到404的问题。3. 按钮级控制v-auth指令和权限判断函数的源码级拆解3.1 usePermission的核心hasPermission的实现Vben Admin里按钮控制最核心的函数是usePermission里的hasPermission。它的判断逻辑大概是这样export function usePermission() { const userStore useUserStore(); const permissionStore usePermissionStore(); // 前端是否有权限码 function hasPermission(value?: string | string[], def true): boolean { if (!value || value.length 0) { // 没有配置权限码默认有权限 return def; } const permissions userStore.getPermissions; // 管理员角色通常拥有全部权限 if (userStore.getIsAdmin) { return true; } if (Array.isArray(value)) { // 多个权限码满足一个即可 return value.some((perm) permissions.includes(perm)); } // 单个权限码 return permissions.includes(value); } return { hasPermission }; }这里有两个设计决定值得说说。第一def参数默认是true也就是说没有配置权限码的按钮默认可见。这个策略在项目初期非常舒服因为权限码还没梳理清楚时先不做控制等功能稳定了再把要隐藏的按钮加上权限码。千万不要反过来搞成默认隐藏否则很容易出现不知道哪个按钮不见了的排查地狱。第二管理员绕过了所有权限判断。这符合绝大多数系统的实际预期管理员看到所有按钮不需要权限码。这个逻辑就放在usePermission里不用每个按钮都做特殊处理。3.2 v-auth指令的工作机制光有判断函数还不够模板里写v-ifhasPermission(system:user:add)虽然能用但代码可读性和复用性都会下降。Vben Admin提供了一条v-auth指令让你能用指令式写法控制按钮template a-button v-authsystem:user:add typeprimary新增用户/a-button /template这条指令的实现位于src/directives/permission.tsexport const authDirective: Directive { mounted(el, binding) { const { hasPermission } usePermission(); if (!hasPermission(binding.value)) { // 无权限时把元素从DOM中移除 el.parentNode?.removeChild(el); } }, };我初次看这段代码时觉得太简单了担心会有问题实际用下来在这个场景确实是够用的。但有个细节指令的mounted阶段只能拿到初始值不响应权限码的异步变化。如果某个页面的权限码是进入页面后才异步加载的那初始判断时权限码为空按钮就会被误删。所以在真实项目里我建议在用户信息完全拉取完毕后再渲染页面内容。这也恰好是Vben Admin里路由守卫在进入页面前就已经拉取完用户信息的原因——把权限数据是否Ready作为页面渲染的前置条件。3.3 按钮权限的另外两种落地方式v-auth指令适合做整块隐藏但有些场景判断结果还要参与逻辑比如没权限时点击按钮弹个窗提示去申请权限。这时候必须用权限判断函数const { hasPermission } usePermission(); function handleExport() { if (!hasPermission(system:user:export)) { message.warning(没有导出权限请联系管理员分配); return; } // 执行导出 }还有一种更轻量的方式是封装组件把权限判断收敛到组件内部。比如封装一个PermissionButton组件传入权限码无权限时不仅隐藏按钮还可以渲染一个Tooltip说明无权访问的原因。这种方式项目里如果存在大量表单操作按钮维护起来会轻松很多。3.4 多权限码数组的使用规则hasPermission也支持传入数组比如v-auth[system:user:update, system:user:resetPwd]它的语义是满足其一即可。在某些场景你需要的可能是全部满足比如一个操作同时要求两个权限这个就需要你在业务代码里自己写了。我一般这样处理const hasAllPermissions (perms: string[]) perms.every((perm) hasPermission(perm));不要把这类个性化的判断硬塞进hasPermission里保持核心函数语义单一后续扩展才不会越搞越乱。4. 刷新与回退权限状态不丢、路由不404的持久化方案4.1 Pinia持久化的正确姿势Vben Admin默认的Pinia并不持久化刷新页面后store数据会被清空。权限码、动态路由这些数据一旦丢了整个菜单和路由都会变成空壳。我的方案是对用户信息和权限码做pinia-plugin-persistedstate持久化但这引出一个坑如果直接持久化整个userStore对象token和permissions确实能恢复但路由侧还需要依赖这些数据重建动态路由如果持久化了旧路由新旧权限变更后无法自动清理。比较稳妥的做法是Pinia只持久化轻量级数据token、用户名、权限码数组动态路由永远在刷新后按权限码重新生成。这样即使后端调整了菜单用户刷新页面后拉取到的也是最新权限数据。在persist配置里我只开启这几个字段persist: { paths: [token, userInfo, permissions], }permissionStore里的dynamicRoutes、menuList这些都不持久化因为它们每次都要从用户权限码和新的路由表里重新构建持久化只会引入过期状态的脏数据。4.2 刷新后动态路由重建的时序问题动态路由重建的时序把控不好最常见的表现就是刷新页面后白屏或跳404。根本原因是刷新后第一次路由导航发生时动态路由可能还没注册完成。Vben Admin的处理流程是路由守卫里遇到isDynamicAddedRoute false时先调buildRoutesAction构建路由并注册然后next({ ...to, replace: true })重新进入当前路由。这一步在Vue Router 4里有个细节router.addRoute添加路由后当前导航其实用的还是旧路由表所以需要replace来强制重新解析。如果你用router.beforeEach来判断动态路由是否添加务必保证next()不会被重复执行否则会出现死循环。Vben Admin里通过isDynamicAddedRouteRef.value true后立即next({ ...to, replace: true, force: true })来规避。4.3 动态路由清理权限变更后的正确重置方式用户被调整了角色或权限码之后前端路由需要重置。大部分项目只做登录/登出时的清理但有些管理系统需要同一账号在后台被改权限后前端在不重新登录的情况下切换可用菜单的场景这就必须做到动态路由的清理。Vben Admin里重置动态路由的通用手段是// 重置路由 export function resetRouter() { const basicRoutes constantRoutes; router.getRoutes().forEach((route) { const { name } route; if (name !basicRoutes.some((r) r.name name)) { router.removeRoute(name); } }); }这里判断name是否存在很重要因为addRoute添加的具名路由才能用removeRoute移除匿名路由remove不了这也是路由配置中name字段不能重复的底层原因。我在团队里经常强调动态路由的name必须全局唯一且父子路由name不能同名这不仅是Vue Router的规范更是后续能安全清理路由的基础。4.4 用户信息缓存与重新拉取的取舍权限码数组要不要缓存取决于系统对实时性的要求。如果允许管理员调整角色后用户下次刷新页面就能看到变化那就应该在应用加载时重新拉取用户信息而不是直接用本地缓存。我的实践是token持久化权限码尽量重新拉取。实现方式是在路由守卫的初始化逻辑里判断本地有token但Pinia中没有用户信息时调用getUserInfo接口拿到最新用户信息和权限码然后加载动态路由。这样做的好处是权限变更最多延迟一次刷新即可生效代价是每次刷新页面都会多一个用户信息请求。对内部管理系统来说这个成本完全可接受。5. 高频踩坑记录菜单丢失、闪白屏与权限码兼容5.1 后端返回菜单后前端没有渲染出任何菜单这个坑我几乎每次接手新项目都会遇到一遍。排查顺序建议如下首先打开浏览器Network确认用户信息接口是否正常返回了menus和permissions。如果接口正常接下里看控制台是否有Failed to resolve component之类的警告——这几乎一定是component路径匹配不到视图组件。最常见原因组件路径写在了src/views之外、大小写不一致、文件名带多了后缀。再往下看是路由守卫里buildRoutesAction是否真的执行了。你可以临时在permissionStore里打个日志看动态路由数组长度是否为0。如果菜单数据有但长度为0多半是后端返回的菜单数据结构里component字段缺失或为空导致transformRouteToMenu阶段就把这条路由过滤掉了。5.2 刷新后菜单正常直接访问URL却404这个场景和上一节的动态路由重建有关。如果刷新后从根路径进入系统会先走一次完整初始化动态路由注册完成菜单正常。但如果用户直接从/system/user这种深层URL进入比如收藏夹链接路由守卫第一次跳转时动态路由还不存在Vue Router会把它当404处理。Vben Admin的路由守卫里对这种情况已经有处理也就是前面提到过的next({ ...to, replace: true })但前提是守卫代码没被改动。我在不少二次开发项目里见过有人为了调样式把router/guard/index.ts注释得七零八落这个文件真的是一行都不能少。如果确认守卫代码没问题再检查constantRoutes里有没有配置/system/user的静态路由。如果有静态路由和动态路由路径重复也会导致动态路由注册时不生效因为同名路由会被后者覆盖或直接被忽略。排查方式是打开router.getRoutes()看最终注册了哪些路由很快就能定位。5.3 页面首次加载的白屏闪烁动态菜单首次加载时如果做不好过渡会出现先白屏后菜单出现的闪烁。Vben Admin里我处理这类问题有两个层次第一基础措施在路由守卫里保证动态路由构建完成后才next()这是程序层面的保障。第二体验优化应用根组件里加一个全局loading等动态路由ready后再渲染路由出口。很多团队在Vben Admin里引入v-loading或者自己包一层LoadingFrame组件效果都还不错template div v-if!routeReady classapp-loading a-spin sizelarge / /div router-view v-else / /template这个方案需要你在permissionStore里暴露一个routeReady状态动态路由构建完成把它置为true。它能同时覆盖首次登录、刷新页面、权限变更重建路由三类场景体验上会更稳。5.4 权限码兼容旧版roles与新版permissions的过渡Vben Admin 2.x早期版本用的是roles做菜单和按钮控制后来很多项目迁移到权限码时总会遇到老接口还在返回角色列表的情况。我的过渡方案是用户信息接口先兼容返回roles数组前端在拉取到用户信息后做一次归一化处理把角色对应的权限码映射逻辑放在前端store里处理// 把角色转换为权限码 function rolesToPermissionCodes(roles: string[]): string[] { const rolePermissionMap { admin: [*:*:*], editor: [dashboard:view, system:user:list], }; const codes new Setstring(); roles.forEach((role) { const list rolePermissionMap[role]; if (list) { list.forEach((code) codes.add(code)); } }); return Array.from(codes); }映射表放在前端并不能作为真正权限边界最终后端接口鉴权仍然要有。但这样做的好处是前端可以并行推进页面开发等后端把权限码结构理顺后把roles返回去掉也不会影响前端逻辑。这个前端映射做兼容、后端接口为最终准绳的思路能省掉很多跨团队联调的扯皮时间。5.5 隐藏菜单页面的权限守卫设计后台系统里几乎都有那种不在菜单显示、但用户需要能从别的页面跳转进入的页面比如详情页、编辑页。对于这种页面我的做法是在meta里设置hideMenu: true同时在该路由的meta中配置需要的权限码。访问时由hasPermission在前面拦截防止用户通过猜测URL直接进入。Vben Admin的路由meta中permissions在这里就能发挥关键作用。我习惯在全局路由守卫里对这类页面做统一判断if (to.meta.permissions) { const { hasPermission } usePermission(); if (!hasPermission(to.meta.permissions)) { return { name: NotFound }; } }注意这只是前端体验上的拦截真正防越权还是要靠后端接口鉴权。前端做这些的目的是让普通用户连入口都接触不到而不是把它当安全边界。6. 按业务场景定制动态菜单与按钮权限的扩展思路6.1 多标签页与权限菜单的联动Vben Admin自带多标签页Tabs但默认情况下标签页与菜单的联动有时候会有微妙的问题。比如用户关掉了所有标签页菜单的高亮状态可能会丢失。要做权限模块与标签页联动核心是保证当前路由的name能定位到菜单且权限码变化时清理不属于当前用户的标签页。我在项目中加了一个辅助函数在登出或权限变更时清空标签页缓存function clearTabsOnPermissionChange() { const tabStore useTabsStore(); const currentTabs tabStore.getTabs; const permissionCodeList useUserStore().getPermissions; const filtered currentTabs.filter((tab) { const code tab.meta?.permissions?.[0]; return !code || permissionCodeList.includes(code); }); tabStore.setTabs(filtered); }这样就算用户开了一堆标签页权限变更后也能立刻收敛到他有权限访问的页面不会出现点了历史标签页突然跳到404的尴尬。6.2 混合权限模式菜单级与操作级分开管理大型后台系统往往会遇到一个诉求角色A能看到系统管理菜单但看不到用户管理下面的新增用户按钮角色B只能看到用户管理菜单却看不到系统管理里的角色管理。这属于菜单级权限和操作级权限的分开管理。我推荐的做法是菜单级权限完全由后端菜单树驱动前端不额外做过滤按钮级权限使用权限码控制由meta.permissions或表单按钮上的v-auth定义。也就是说后端决定你能看到什么前端决定你在已看到的界面里能不能操作。这条边界一旦模糊就会出现前端把按钮藏了但接口仍然有权限、用户还是能通过控制台调接口访问的问题。6.3 单个页面内部不同模块的差异化权限有时候同一个页面里不同数据区块的可见性也不一样。比如用户管理页普通用户管理员和系统管理员看到的操作列按钮完全不同。这里我建议把权限判断工具收敛到一个自定义hook中比如export function usePagePermission(pageCode: string) { const { hasPermission } usePermission(); return { canView: (action: string) hasPermission(${pageCode}:${action}), }; }这样页面里写const { canView } usePagePermission(system:user); // 模板里 v-authcanView(update)虽然v-auth本身已经支持传字符串但把pageCode收敛到一个hook里可以避免每个模板都拼一遍长权限码手滑拼错一个字母还不好查。对于代码洁癖来说这种封装能让权限码的维护更集中。6.4 后端动态配置菜单权限的前端落地方案最后一个场景如果你的系统需要在运行时动态调整菜单权限而不需要改前端代码发布那么后端返回的菜单树里应该允许某个菜单项直接带一个visible布尔值或permissions数组。前端在生成菜单时如果发现该节点的permissions里有当前用户不具备的权限码就整棵子树隐藏即便后端返回了这条路由。我在Vben Admin的transformRouteToMenu处理中会做一道菜单过滤function filterMenusByPermission(menus, permissions) { return menus .filter((menu) { const needPerms menu.meta?.permissions ?? []; if (needPerms.length 0) return true; return needPerms.some((perm) permissions.includes(perm)); }) .map((menu) ({ ...menu, children: menu.children ? filterMenusByPermission(menu.children, permissions) : [], })); }这样做有一个额外的好处同一个前端包在不同租户或不同环境里可以通过后端配置控制菜单形状做到真正的配置即权限。但要注意性能问题菜单树深度过大或节点过多时这个递归过滤会有一定开销。一般后台管理系统菜单量级在几百个以内完全可以忽略不计。6.5 项目实践中的权限模块目录建议结合这几个扩展场景我最终在项目里的权限模块目录是这个样子供你参考src/features/permission/ ├── hooks/ │ ├── usePermission.ts # 核心判断函数 │ └── usePagePermission.ts # 页面级权限hook ├── directives/ │ └── permission.ts # v-auth指令 ├── components/ │ ├── PermissionButton.vue # 带权限按钮封装 │ └── PermissionTooltip.vue # 无权限原因提示 ├── store/ │ ├── user.ts # 用户权限码状态 │ └── permission.ts # 动态路由与菜单状态 └── utils/ ├── routeHelper.ts # 路由转菜单工具 └── filterMenu.ts # 菜单过滤工具这套结构把权限相关的代码收敛在一个模块里页面开发只需要引用hooks和指令不跟store直接耦合。权限变更是全系统影响最大的操作之一代码集中度越高排查问题越容易。根据我这几个项目的落地经验Vben Admin这套权限体系只要第一遍把它理解透了后面所有页面开发都只是往里填权限码的事。配置文件集中在permission.ts和usePermission.ts无论你是改按钮、加菜单还是做权限过滤都先回到这两个文件确认当前状态就不容易跑偏。如果你正在做类似改造建议先把后端权限码和菜单数据结构定好再动手改前端。前端永远只是表达层数据源头理顺了页面怎么控制都顺。
返回列表