
在后台管理系统里动态路由、菜单权限和按钮权限这组功能可以说是几乎所有中后台项目的刚需。我基于 vue3 vite pinia 实现过一套比较完整的权限体系这里把设计思路、核心代码和踩坑经历都整理出来。无论你是刚做完基础后台准备接入权限还是已经写了一版但总被“刷新白屏”或“权限码无效”困扰这篇内容都值得花几分钟看完。这套方案最终实现了三个目标前端登录后根据用户角色动态生成可访问的路由表侧边菜单从路由表自动派生页面内按钮通过指令或函数级判断控制显隐。1. 整体方案设计与思路拆解做权限最怕一开始就陷入“写代码”的兴奋感里。我见过不少项目先把路由表写死然后登录后拿一个角色字段硬过滤最后菜单是显了又隐、隐了又显刷新页面还直接崩掉。在一套中后台系统里权限数据量通常不会太大。角色一般就那么几个路由也就在几十条量级按钮权限码更是一眼就能数完。所以在设计选型上我直接放弃了一些重量级方案没有引入额外的状态管理插件也没有搞复杂的transition动画缓冲就靠 pinia 作为核心数据源把路由、菜单、权限信息统一收敛到一个 store 里。方案的核心思路用一句话概括路由表由权限数据动态生成菜单由路由表派生按钮权限由全局指令统一控制。具体拆开看有几个关键决策。1.1 为什么用 pinia 而不是 localStorage很多项目图省事把用户信息和权限码直接塞进 localStorage。但你认真想一下 localStorage 的几个特性不能响应式、需要手动序列化、多标签页不同步。一旦用户在另一个页面里更新了权限当前页面是拿不到变化的。pinia 的属性天然是 reactive 的在组件里用storeToRefs取出来的数据路由变化时可以自动联动组件状态。我最终的做法是请求回来的数据先进 store再从 store 同步到 sessionStorage 作持久化。这样既有内存的响应式又能兜底刷新后重新拉取前的过渡期。1.2 动态路由还是静态路由加拦截纯静态路由 全局前置守卫也能实现页面访问控制。页面能不能进可以在router.beforeEach里做判断。但这种做法有两个硬伤第一路由表对所有人可见。前端把路由写死在代码里相当于把菜单结构提前暴露了。就算你不在侧边栏显示用户手动输入某个 URL 还是可能直达。第二菜单生成缺乏数据基础。静态路由本身带不了完整的角色、权限码信息让每个菜单项和路由产生映射关系越往后越难维护。所以我在项目里选择先定义一个基础的公共路由包含登录页、404页、首页重定向然后在登录成功后先拼接出用户的路由表再调用router.addRoute把它动态注册进去。这是目前行业里比较主流、也是被验证得最扎实的做法。1.3 菜单、路由、按钮之间的关系模型用一张表来梳理这层关系数据维度存储位置主要作用生成方式完整路由表前端静态定义系统所有可能访问的页面信息手动维护用户路由表pinia sessionStorage控制当前用户能访问的页面登录后动态拼接菜单结构由用户路由表派生侧边栏渲染的数据源前端递归转换按钮权限码pinia 中单独维护页面内操作级权限控制后端接口返回这个模型的核心就是把“系统有什么”和“用户能干什么”彻底分离。系统能访问哪些路由写在前端常量里用户能看哪些菜单、能点哪些按钮完全由接口返回的用户信息决定。2. 核心实现动态路由与菜单权限2.1 路由表拆分公共路由与动态路由先把路由模块从文件组织上拆开。我一般建一个router目录里面分constantRoutes和asyncRoutes两部分。// router/routes.js // 所有角色都能访问的路由 export const constantRoutes [ { path: /login, name: Login, component: () import(/views/login/index.vue), meta: { hidden: true } }, { path: /404, name: NotFound, component: () import(/views/error/404.vue), meta: { hidden: true } }, { path: /, name: Home, component: () import(/layout/index.vue), redirect: /dashboard, children: [ { path: dashboard, name: Dashboard, component: () import(/views/dashboard/index.vue), meta: { title: 首页, icon: home } } ] } ] // 需要权限判断的路由 export const asyncRoutes [ { path: /system, name: System, component: () import(/layout/index.vue), meta: { title: 系统管理, icon: setting, roles: [admin] }, children: [ { path: user, name: UserManage, component: () import(/views/system/user/index.vue), meta: { title: 用户管理, icon: user, roles: [admin] } }, { path: role, name: RoleManage, component: () import(/views/system/role/index.vue), meta: { title: 角色管理, icon: role, roles: [admin] } } ] }, { path: /order, name: Order, component: () import(/layout/index.vue), meta: { title: 订单管理, icon: order, roles: [admin, operator] }, children: [ { path: list, name: OrderList, component: () import(/views/order/list/index.vue), meta: { title: 订单列表, icon: list, roles: [admin, operator] } }, { path: detail, name: OrderDetail, component: () import(/views/order/detail/index.vue), meta: { title: 订单详情, icon: detail, roles: [admin, operator] } } ] } ]这里要注意一个细节component使用懒加载的箭头函数形式同时路径必须对应实际的views目录结构。动态路由在注册时是根据文件名去解析组件的一旦写错就是一整个页面白屏。2.2 用户状态管理pinia 中的权限数据接下来定义用户 store把用户信息、角色、权限码、动态路由全部集中放在这里。// store/modules/user.js import { defineStore } from pinia import { login, getUserInfo } from /api/user import { constantRoutes, asyncRoutes } from /router/routes function filterAsyncRoutes(routes, roles) { const res [] routes.forEach(route { const tmp { ...route } if (hasPermission(roles, tmp)) { if (tmp.children) { tmp.children filterAsyncRoutes(tmp.children, roles) } res.push(tmp) } }) return res } function hasPermission(roles, route) { if (route.meta route.meta.roles) { return roles.some(role route.meta.roles.includes(role)) } return true } export const useUserStore defineStore(user, { state: () ({ token: , name: , avatar: , roles: [], buttons: [], asyncRoutes: [], loaded: false }), actions: { // 登录 async login(loginForm) { const { token } await login(loginForm) this.token token sessionStorage.setItem(token, token) }, // 获取用户信息 async fetchUserInfo() { const res await getUserInfo(this.token) this.name res.name this.avatar res.avatar this.roles res.roles this.buttons res.buttons this.loaded true }, // 生成动态路由 generateRoutes() { // 这里根据当前用户角色过滤出可访问路由 const accessedRoutes filterAsyncRoutes(asyncRoutes, this.roles) this.asyncRoutes accessedRoutes return accessedRoutes }, // 退出登录重置状态 resetState() { this.token this.name this.roles [] this.buttons [] this.asyncRoutes [] this.loaded false sessionStorage.removeItem(token) } } })筛选逻辑里最容易被忽略的点是roles字段的匹配方式。很多人喜欢简单判断route.meta.roles.includes(user.role)但一个用户如果同时兼任多种角色some和includes的写法差异就会直接导致权限判定错误。2.3 路由守卫里的完整闭环有了 store 和路由表接下来是路由守卫的核心处理。这是整个方案里最容易出 bug 的地方我直接贴出可运行的版本。// router/index.js import { createRouter, createWebHistory } from vue-router import { constantRoutes } from ./routes import { useUserStore } from /store/modules/user const router createRouter({ history: createWebHistory(), routes: constantRoutes }) // 免登录白名单 const whiteList [/login] router.beforeEach(async (to, from, next) { const userStore useUserStore() const hasToken sessionStorage.getItem(token) if (hasToken) { if (to.path /login) { next({ path: / }) } else { // 关键防止刷新后再次加载路由 if (userStore.loaded) { next() } else { try { // 拉取用户信息 await userStore.fetchUserInfo() // 生成动态路由 const accessRoutes userStore.generateRoutes() accessRoutes.forEach(route router.addRoute(route)) // 以防万一动态添加404兜底 router.addRoute({ path: /:pathMatch(.*)*, redirect: /404, meta: { hidden: true } }) // 重新进入一次当前路由保证动态路由渲染完毕 next({ ...to, replace: true }) } catch (error) { // 拉取用户信息失败说明 token 失效强制重新登录 userStore.resetState() next(/login?redirect${to.path}) } } } } else { if (whiteList.includes(to.path)) { next() } else { next(/login?redirect${to.path}) } } })这个守卫里最重要的魔法其实在这两行accessRoutes.forEach(route router.addRoute(route)) next({ ...to, replace: true })动态路由加载后当前跳转目标其实还没注册直接next()会匹配到 404。所以必须重新触发一次导航让新注册的路由生效。这是我第一次实现动态路由时踩过最大的坑回想起来都是泪。2.4 菜单组件递归渲染与自定义指令有了路由表菜单组件就简单了但要注意它必须支持无限层级递归并且要过滤掉meta.hidden的隐藏路由。!-- layout/components/SidebarItem.vue -- template template v-if!item.meta || !item.meta.hidden el-sub-menu v-ifitem.children item.children.length :indexitem.path template #title el-icon v-ifitem.meta item.meta.icon component :isitem.meta.icon / /el-icon span{{ item.meta item.meta.title }}/span /template SidebarItem v-forchild in item.children :keychild.path :itemchild :base-pathitem.path / /el-sub-menu el-menu-item v-else :indexresolvePath(item.path) el-icon v-ifitem.meta item.meta.icon component :isitem.meta.icon / /el-icon template #title{{ item.meta item.meta.title }}/template /el-menu-item /template /template菜单的核心依据不是单独维护的菜单数据而是上面的动态路由表。路由里有多少可访问的节点菜单就有多少项天然保证一致性不需要再费心同步。3. 按钮权限的实现与细节路由和菜单权限解决了页面可见性问题但用户进入页面后能干什么这就是按钮权限的工作范围。3.1 按钮权限的两种主流实现按钮权限一般有两条路一是用 Vue 的v-if指令二是封装自定义指令v-permission。v-if方案直观但它的致命缺点是逻辑散落在每个组件里权限一变就要改代码。比如下面这样template el-button v-ifuserStore.buttons.includes(user:add)新增/el-button el-button v-ifuserStore.buttons.includes(user:delete)删除/el-button /template按钮一多模板里全是判断条件后边看到这种代码就头大。所以我明确选择指令方案把权限校验集中放到一个指令里模板只写v-permission既清爽又统一。3.2 自定义权限指令的完整封装在项目src/directives/permission.js里定义指令// directives/permission.js import { useUserStore } from /store/modules/user export const permission { mounted(el, binding) { const userStore useUserStore() const { value } binding // 权限码可能是字符串或数组 const requiredPermissions Array.isArray(value) ? value : [value] const hasPermission userStore.buttons.some(btn requiredPermissions.includes(btn) ) if (!hasPermission) { el.parentNode el.parentNode.removeChild(el) } } }然后在入口文件注册指令// main.js import { permission } from /directives/permission app.directive(permission, permission)这样在组件里使用的时候只需写上权限码template el-button v-permissionuser:add typeprimary新增用户/el-button el-button v-permission[user:edit, user:delete]批量操作/el-button /template我用下来的经验是当某个操作需要多个权限码中的任意一个时传数组非常方便。很多项目这里实现成“全部满足才显示”反而导致真实业务需求没法覆盖。3.3 权限码的约定与管理按钮权限经常出问题并不是指令写错了而是权限码本身命名混乱。我列一下我在项目中落地时固定的规则权限码格式统一为模块:操作例如user:add、order:export。后端返回的按钮权限列表必须和前端定义的权限码完全一致一个字符都不能差。所有权限码集中到一个常量文件里维护避免页面直接写字符串。// constants/permission.js export const PERMISSION { USER_ADD: user:add, USER_EDIT: user:edit, USER_DELETE: user:delete, ORDER_EXPORT: order:export, ORDER_IMPORT: order:import }使用的时候引入常量而不是写死字符串这样将来权限码调整时全局搜索替换就行也方便做代码审查。3.4 函数式按钮权限配合禁用态删除按钮可以用指令直接移除但有些按钮不能直接消失比如“提交”按钮在无权限时需要置灰并给提示。这时候指令就不够用了需要配一个函数判断。// utils/permission.js import { useUserStore } from /store/modules/user export function hasPermission(permissionCode) { const userStore useUserStore() return userStore.buttons.includes(permissionCode) }在模板中这样使用el-button :disabled!hasPermission(order:submit) clickhandleSubmit 提交订单 /el-button指令方案和函数方案一起用基本可以覆盖所有按钮级的权限场景。指令解决“能不能看到”函数解决“能不能操作”各司其职。4. 常见问题与排查技巧实录这个权限模块改过很多版我把实际项目中采到的高频问题都整理出来了每一个都是真金白银的教训。4.1 刷新后白屏路由守卫死循环这是动态路由实现后最常见的首坑。现象登录后一切正常按 F5 一刷新页面全白控制台报错找不到路由甚至有时浏览器一直转圈。原因刷新后整个 Vue 应用重新初始化动态路由虽然被 persist 到 sessionStorage但router实例里的路由注册信息已经清空。此时导航到/system/user路由匹配不到任何记录自然白屏。解决在路由守卫里加已加载标识刷新后重新走一遍fetchUserInfo generateRoutes addRoute流程。核心就是我在上面守卫代码里写的userStore.loaded判断。还有一个隐藏细节如果你是用sessionStorage直接持久化了用户信息千万不要用“从 storage 里恢复数据然后跳过重新拉取”的方式因为权限可能已过期。刷新时宁可多请求一次接口也不要因为图省事造成权限漏洞。4.2 动态路由注册前就跳转导致404现象登录成功后跳转首页正常但直接访问一个二级菜单 URL 就 404。原因注册动态路由的addRoute是同步代码但它之后next()的时机可能早于路由匹配。解决路由守卫里必须用next({ ...to, replace: true })而不是直接next()确保动态路由已生效后重新进入目标路由。排查看这里如果addRoute之后还是 404大概率是路由路径写错了。检查动态路由的path是否以/开头以及children中是不是漏了接父级路径。嵌套路由的路径子级不要写/开头否则会变成绝对路径。4.3 权限码明明有按钮却显示不出来现象后台配置好了权限码接口也返回了但按钮就是不出现。排查步骤第一步先在fetchUserInfo请求后打日志看res.buttons里到底是什么格式。经常有后端接口返回的是[{ name: user:add }]这种对象数组你includes直接匹配字符串自然匹配不上。第二步检查权限码是否有多余空格。很多接口数据经过 JSON 解析后两端的空格看起来一样但实际不一致。建议前端在fetchUserInfo里做一层数据清洗。this.buttons res.buttons.map(item item.trim())第三步确认指令绑定值是数组时逻辑是否正确。比如v-permission[user:add, user:edit]一定要想清楚用的是some还是every。4.4 菜单闪烁加载时先出全部菜单再消失现象登录后侧边栏菜单全部闪现一下然后才刷新成当前用户该看到的菜单。原因布局组件渲染时使用了constantRoutes渲染菜单动态路由还没生成用户信息返回后动态菜单才顶上来。解决菜单渲染前加v-ifuserStore.loaded判断未加载完成时压根不渲染侧边栏。template div v-ifuserStore.loaded classsidebar SidebarItem v-foritem in userStore.asyncRoutes :keyitem.path :itemitem / /div /template4.5 404页被动态路由误拦截现象登录后访问任意未定义 URL404 页面一闪而过随后被重定向到首页。原因动态路由最后添加的通配匹配路由/:pathMatch(.*)*和router.addRoute的注入顺序有关。这个通配路由应该放在所有动态路由注册之后一旦注册顺序不对通配路由会先匹配到所有 URL。解决把通配路由放在generateRoutes的返回值里跟随用户动态路由一起 add保证它是最后一个注册的路由。4.6 退出登录后路由没清理干净现象admin 登录 - 退出 - visitor 登录visitor 竟然还能看到 admin 的页面。原因router.addRoute是全局注册退出登录时如果不主动移除路由一直存在。解决方式一在退出登录时重置整个 router换一个思路点击退出时直接location.href /login做整页强制刷新一劳永逸。如果不想整页刷新可以在退出时获取所有已注册的动态路由名然后手动移除// 在 resetState 里收集 const removeRoutes [] accessRoutes.forEach(route { removeRoutes.push(route.name) }) removeRoutes.forEach(name { if (router.hasRoute(name)) { router.removeRoute(name) } })不过实测下来最省心的还是整页刷新方案。后台系统对刷新开销并不敏感安全性却提升了不少。5. 权限模块的边界问题与设计思考写到这儿再把几个容易被忽略的边界点展开说一下。5.1 后端接口鉴权是底线前端只是体验优化前端路由和按钮权限本质上是“体验层”控制它不能让用户看到不该看的页面、点不该点的按钮。但这绝不意味着后端可以不做接口校验。我一直在团队里强调这句话页面能跳转、按钮能点击不代表请求会成功。真正的地基是后端每个接口都要有权限校验前端权限只是把体验做得更顺畅。前端权限本质上是优化体验真正的地基是后端判断前端只是把交互收起来。设计权限系统时如果后端还没做赶紧回头推动后端把shiro、spring security之类的鉴权框架配上。5.2 动态路由和菜单的先后顺序我梳理一下整个流程确保你离手写代码时不会乱用户在登录页输入账号密码拿到 token。前端把 token 存到 pinia 和 sessionStorage。进入路由守卫判断 token 存在但用户信息未拉取。调用fetchUserInfo()拿到用户基本信息。调用generateRoutes()根据角色筛选出可访问路由表。addRoute动态注册路由同时next({ ...to, replace: true })重新进入页面。菜单组件从路由表派生渲染出侧边栏。按钮级别的操作通过指令或函数判断显隐。这套流程就是权限模块的主干道。所有的坑、所以的修改都是在这个主干道某个环节出了问题。5.3 如何处理角色和权限码同时存在权限问题有一部分系统既有角色、也有权限码菜单按角色过滤按钮按权限码判断这很常见。但要注意防止角色和权限码互相冲突。举例来说一个用户挂了“运营”角色但按钮权限码里没有给他order:export这就导致他看得到订单导出按钮点了之后被后端拒绝。这种问题表面上难以排查其实根源在于后端返回的数据口径不统一。我的建议是菜单和按钮都统一基于权限码做判断因为后端权限体系最细的粒度永远是权限码角色只是一种权限码的组合。前端传过来的角色其实是冗余信息真正要用的只有一个buttons列表。基于这套统一的权限码机制整个权限体系会更自洽。6. 一些可以做得更细的收尾小技巧最后再补充三个我实际项目中用过、颇有效果的小细节。第一点是页面标题联动。动态路由里每个meta.title都可以在afterEach守卫中设置成浏览器标签页的标题。用户权限不同、看到的页面不同标题也随之变化效果整齐。第二个是操作日志记录。当按钮权限被触发时比如用户点击了某个受限按钮可以在指令里统一上报全局日志不管是“越权点击”还是正常点击都记录下来方便后续审计。第三个是可以做的更核心的一点就是动态路由的守卫顺序。一定要把动态路由生成放在路由守卫里做而不是放在登录页登录成功后做。这样刷新时不管用户从哪个 URL 直接进入均有兜底机会不会因为登录成功时没生成路由而直接漏掉。7. 我的个人体会写权限这套东西最容易犯的错误是一开始就想把菜单、按钮、路由全都做到位。其实先跑通路由和菜单权限有一点可用度后再去补按钮权限这种递进式开发要稳妥得多。被 404 白屏折磨过几次之后我现在的习惯是在路由守卫里把加载状态打满日志每一步都确认 token、用户信息、动态路由、进页面这四步各自都执行到位。权限系统就是典型的“差一个步骤就全盘崩掉”的功能调试的时候耐心区分“数据问题”和“时序问题”才能迅速定位到根因。最后再提醒一句前端权限再复杂也只是权限链路里的一环。架构设计时一定要同步让后端把接口鉴权做扎实。别再让“前端隐藏了按钮就等于权限安全”这种认知在团队里继续蔓延了。