资讯动态

Vue + Element 后台权限管理系统:从动态路由到按钮级权限的完整实现

发布时间:2026/9/16 11:57:25 来源:尧图企业网站定制
简介基于 Vue 与 Element 实现的后台权限管理系统面向需要为管理系统引入前端权限控制的开发者主要解决动态菜单、路由守卫、按钮级权限校验等常见问题适合已有 Vue 基础的中级前端用于项目参考。压缩包共 104 个文件约 598KB核心源码以 45 个 JS 文件与 32 个 Vue 文件为主JS 文件承载路由配置、状态管理和权限校验逻辑Vue 文件构成登录、主页及各类管理页面其余 JSON、HTML、CSS 等文件补充了项目配置、页面入口及基础样式结构清晰便于定位。项目完整展示了从用户登录获取令牌、动态生成可访问路由、通过自定义指令控制按钮显隐的整套处理流程同时包含菜单按角色过滤的示例能够帮助读者快速理解前端权限管理的关键实现。目前已有 465 人学习适合需要落地权限功能或准备二次开发的工程师借鉴。1. 一个后台项目进入维护期后改动最集中的地方就是权限一个后台项目从开发期进入维护期改动最频繁的往往不是业务页面而是权限边界哪个菜单只让管理员看哪个按钮只让运营点哪个接口必须在请求头里带校验信息。基于 Vue Element 的权限管理系统实际要解决的只有三件事登录态的 token 怎么存不同角色登录后路由表怎么动态装配以及按钮级别的操作权限怎么收敛。这套方案不需要商业脚手架vue-router 的addRoute、Element 的el-menu递归渲染、自定义指令v-permission三件套就能覆盖绝大多数后台管理系统的权限诉求。本文从数据库表结构讲到路由守卫再到按钮级显隐最后给到一个多角色切换场景里极易踩中的坑和处理方法。2. 权限数据模型与登录态先把 RBAC 的表和返回结构定死2.1 五张核心表与一条连表查询业界做权限管理系统最稳妥的数据模型还是 RBAC基于角色的访问控制。用户的权限不直接挂在用户表上而是通过角色中转。一个用户可以有多个角色一个角色挂多个菜单和按钮权限。落到 MySQL 上常见设计是五张表表名关键字段职责说明sys_userid, username, password, status用户基础信息status 用于软启停用sys_roleid, role_name, role_key, sort角色定义role_key 如 admin / operatorsys_menuid, parent_id, title, path, component, perms, type菜单表同时承载目录、页面、按钮三类节点sys_user_roleuser_id, role_id用户与角色多对多关联sys_role_menurole_id, menu_id角色与菜单/按钮多对多关联其中sys_menu.type字段特别关键一般约定0代表目录1代表可访问的页面2代表按钮权限点。按钮节点没有component它的可复用字段是perms对应后端接口权限标识比如user:add。目录和页面节点则用parent_id形成树形结构。前端登录后拿到的权限列表不要自己拼 SQL让后端一次性查好返回。后端在登录接口里做的事可以概括为根据user_id查出所有角色再根据role_ids查出sys_menu里status1的节点按树形结构组装后返回。这一步做完前端只是拿到结果不需要再做二次过滤。2.2 登录接口的返回结构与 token 存放前后端分离的项目里登录接口返回的数据结构建议统一成这样{ code: 200, data: { token: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..., userInfo: { userId: 1024, username: admin, nickname: 管理员 }, roles: [admin], permissions: [user:list, user:add, role:list, dashboard:view] } }token由后端签发前端存入localStorage或sessionStorage。这里要确认一个边界业务系统一般不需要前端处理refresh_token刷新让 axios 响应拦截器在遇到401时清理本地登录态并跳回登录页即可刷新逻辑放在后端网关统一处理更省事。roles和permissions建议在刷新页面后重新拉取不持久化到 localStorage避免权限变更后旧权限码残留这一点在最后一章会展开讲。登录态在 Vue 端的内存管理Vuex和Pinia都能胜任。新项目建议直接用 Pinia写法更轻且 Pinia 的 store 在组件外用起来不需要额外传参路由守卫里调用更顺手。store 里只需要维护token、roles、permissions、dynamicRoutes四个字段token负责 axios 请求头roles和permissions提供给路由守卫与指令判断dynamicRoutes记录已追加的路由用于登出时清理。2.3 菜单由后端返回还是前端硬编码这是一个经常被讨论的选型问题。前端把菜单和路由写死在代码里开发时确实快但权限变更要重新走发版流程。后端动态返回菜单运维和运营可以在管理后台直接调整角色权限刷新页面就生效这也是若依等知名开源项目中常用的做法。我的建议是采用折中方案静态基础路由放前端比如登录页、404 页、首页仪表盘这部分所有账号可见业务模块路由由后端按角色返回前端只维护一份组件路径映射表。这么做的好处是路由表即使后续膨胀到几百个节点前端包体积不会跟着膨胀因为异步组件配合import.meta.glob按需加载用户没权限的路由组件不会被下载首屏速度也更可控。3. 路由级权限动态路由与路由守卫的实现3.1 把路由拆成 constantRoutes 与 asyncRoutes动态路由的核心是把前端路由表按是否依赖权限拆成两部分。constantRoutes是所有人可见的包含登录页、首页、404以及一些不需要权限的公开页。asyncRoutes则是需要角色权限的模块路由它们在登录后根据后端返回的菜单数据动态生成。这里常见的做法是后端菜单接口返回的路径、组件名与前端路由配置保持一一对应。前端维护一个组件映射关系把后端返回的component字符串转换成真正的组件对象// src/router/index.js import { createRouter, createWebHistory } from vue-router const modules import.meta.glob(../views/**/*.vue) export function loadView(componentPath) { // 后端返回的路径如 system/user/index const matched modules[../views/${componentPath}.vue] if (!matched) { throw new Error(组件路径未找到: ${componentPath}) } return matched }这里用import.meta.glob把views目录下的所有.vue文件打包成映射表后端返回system/user/index就能对应到src/views/system/user/index.vue。Vite 构建时会把这个映射表里的组件全部打成异步 chunk用户没权限的页面不会在登录后立即加载这也是动态路由相比静态全量路由的一个核心优势。3.2 用 router.addRoute 按角色动态追加路由登录流程中拿到后端菜单数据后要做两件事第一把菜单数据存进 Pinia供el-menu渲染侧边栏第二把菜单数据转换成路由对象通过router.addRoute追加进路由表。// src/store/modules/permission.js import { defineStore } from pinia const modules import.meta.glob(../views/**/*.vue) function buildRoutes(menuTree) { return menuTree.filter(item item.type ! 2).map(item { const route { path: item.path, name: item.routeName || item.path.replace(/\//g, _), meta: { title: item.title, icon: item.icon, permissions: item.perms || [] } } if (item.type 1) { // type1 是页面需要组件 route.component modules[../views/${item.component}.vue] } else { // type0 是目录用 Layout 作为壳 route.component Layout } if (item.children item.children.length) { route.children buildRoutes(item.children) } return route }) } export const usePermissionStore defineStore(permission, { state: () ({ menus: [], routes: [] }), actions: { setMenus(menuTree) { this.menus menuTree this.routes buildRoutes(menuTree) this.routes.forEach(route router.addRoute(route)) } } })3.3 路由守卫 beforeEach 的五个分支判断router.beforeEach是动态路由的调度中心。这段守卫逻辑只要分支判断清晰权限系统就成功了一半router.beforeEach(async (to) { const userStore useUserStore() const permissionStore usePermissionStore() // 白名单页面直接放行 if (whiteList.includes(to.path)) return true // 没有 token踢回登录页并带上回跳地址 if (!userStore.token) { return /login?redirect${encodeURIComponent(to.fullPath)} } // 已有 token 但权限信息为空说明是刷新导致的重新初始化 if (userStore.token !userStore.roles.length) { // 拉取用户信息和权限码 const userInfo await userStore.fetchUserInfo() // 拿菜单树生成动态路由 const menus await userStore.fetchMenus() permissionStore.setMenus(menus) // 重新进入目标页面触发一次完整的路由解析 return { ...to, replace: true } } return true })这个守卫里最容易写错的一点是addRoute虽然有动态路由进来但因为异步挂载需要时间有时候页面一级路由已经匹配到了 Layout但子路由还没挂完刷新后会出现白屏。解决方法是守卫里return { ...to, replace: true }重新进入目标路由让 vue-router 重新解析一次路由表。另一个高频问题是没有 token 时就直接跳登录页但用户手动输入一个权限页面地址时没有提示他为什么被拦。可以在登录页带上redirect参数登录成功后用router.replace(redirect || /)跳回原目标体验会比一刀切跳首页好得多。3.4 404 路由必须在动态路由之后追加一个非常隐蔽的坑如果constantRoutes里把/:pathMatch(.*)*404 兜底路由写在了最前面那么动态addRoute进去的路由会全部被 404 抢先匹配页面永远渲染为未找到。所以 404 兜底路由必须放在动态路由之后追加。常见的做法是提供一个setupNotFoundRoute方法在addRoute全部执行完毕后调用export function setupNotFoundRoute() { router.addRoute({ path: /:pathMatch(.*)*, name: NotFound, component: () import(../views/error/404.vue) }) }在permissionStore.setMenus的最后调用这个方法保证 404 永远在最后匹配。刷新页面时也一样因为守卫会重新走fetchMenus - addRoute - setupNotFoundRoute的链路顺序不会乱。4. 菜单与按钮权限el-menu 渲染与 v-permission 指令4.1 用递归组件渲染多级菜单侧边栏菜单的数据结构就是后端返回的菜单树。Element 的el-menu通过el-sub-menu支持多级嵌套而递归组件是渲染任意层级树形数据最自然的做法。定义一个SidebarItem.vuetemplate template v-ifmenu.children menu.children.length el-sub-menu :indexmenu.path template #title el-iconcomponent :ismenu.meta.icon //el-icon span{{ menu.meta.title }}/span /template sidebar-item v-forchild in menu.children :keychild.path :menuchild / /el-sub-menu /template el-menu-item v-else :indexmenu.path el-iconcomponent :ismenu.meta.icon //el-icon template #title{{ menu.meta.title }}/template /el-menu-item /template script setup defineProps({ menu: { type: Object, required: true } }) /script递归组件里自身就是一个SidebarItem当遇到有子节点的菜单时用el-sub-menu包一层继续递归没有子节点时渲染为el-menu-item。菜单数据里type0的目录节点通常它本身没有可跳转的路由只负责展开收起所以index用path即可点击子菜单时才真正触发路由跳转。4.2 default-active 与路由高亮联动菜单渲染出来后要解决两个联动问题刷新页面后当前菜单高亮、点击菜单跳转路由。el-menu的default-active绑定当前路由路径并通过监听$route变化保持同步template el-menu :default-activeactiveMenu :routertrue :collapseisCollapse sidebar-item v-foritem in permissionStore.menus :keyitem.path :menuitem / /el-menu /template script setup import { computed } from vue import { useRoute } from vue-router const route useRoute() const activeMenu computed(() route.path) // :routertrue 让 el-menu-item 的 index 自动作为路由地址跳转 /scriptel-menu自带router属性开启后点击el-menu-item会自动把index作为路径传给vue-router跳转不需要手动写select事件。如果项目里的菜单路径和路由 path 不完全一致再在select事件里router.push(index)即可。这里值得注意的一点是菜单组件里的图标。Element Plus 的图标是组件形式动态渲染时用component :ismenu.meta.icon /需要先全局注册或者把图标组件映射表引入。建议在菜单数据里直接存图标组件名渲染处用一个icons对象做映射避免后端传字符串但前端找不到组件而白屏。4.3 用 v-permission 指令收敛按钮权限路由级权限控制了页面的可达性但页面内的操作按钮才是权限管理的最后一公里。比如用户管理页面普通角色只能看列表管理员才能点击新增和删除按钮。最常见的做法是自定义一个权限指令// src/directives/permission.js import { useUserStore } from /store/modules/user export const permission { mounted(el, binding) { const { value } binding if (!value) return const userStore useUserStore() const permissions userStore.permissions const hasPermission Array.isArray(value) ? value.some(code permissions.includes(code)) : permissions.includes(value) if (!hasPermission) { el.parentNode el.parentNode.removeChild(el) } } }用法是el-button v-permission[user:add]新增用户/el-button。指令接收一个权限码数组只要其中一个命中当前用户的权限集合按钮就保留否则直接移除 DOM。但这个指令有一个副作用如果用v-permission移除的按钮后续权限集合发生变化DOM 不会自动回来。所以在权限码稳定不变的后台管理系统里指令方案是干净的如果权限码可能在单次会话内动态更新更稳妥的做法是配合v-if判断el-button v-ifhasPerm(user:add)新增用户/el-buttonhasPerm是一个从 Pinia 的permissions派生出来的计算属性方法。它比指令更可控因为它不是移除 DOM而是控制渲染权限码更新时视图会响应式刷新。两者的取舍可以这么记v-permission适合写起来简单、权限不变的项目hasPerm适合需要动态响应权限变化、或者按钮刚被移除又要恢复的场景。5. 权限码命名规范与登出时动态路由的彻底清理5.1 用模块 冒号 动作约束权限码权限码是权限系统的接口协议。它一端连接后端接口的PreAuthorize注解或拦截器配置另一端连接前端的按钮显隐两边必须严格一致。权限码一定要有固定格式按模块:动作组织比如user:add、user:delete、role:assign。禁止出现button1、btn_abc这类没有语义的命名。后端做数据字典、前端做代码提示时按模块前缀过滤一眼就能定位。5.2 登出时重置动态路由一个高频踩坑的边界很多项目上线后暴露一个严重问题A 账号有系统管理菜单登出后换 B 账号登录B 竟然还能直接输入 系统管理 下的页面地址并访问。原因就是登出时只清了localStorage和 Pinia但vue-router里动态addRoute的路由一直没有被移除。路由表是内存级的不清掉就一直在。Vue Router 4 的addRoute方法会返回一个移除函数专门用于撤销这条动态路由。在动态路由创建阶段就把这些移除函数收集起来// src/store/modules/permission.js const removeRouteFns [] function setupDynamicRoutes(routes) { routes.forEach(route { const remove router.addRoute(route) removeRouteFns.push(remove) }) } export function resetDynamicRoutes() { while (removeRouteFns.length) { const remove removeRouteFns.shift() remove() } }登出操作里调用resetDynamicRoutes()然后清空 Pinia 中保存的roles和permissions再调用localStorage.clear()最后跳转登录页。注意顺序先重置路由再清理用户态避免路由跳转过程中再次触发守卫去拉取已清空的用户信息。业务登录页触发登出只需要丢一个logoutaction让 store 内部处理这些细节。如果你的项目是 Vue Router 3addRoute不返回移除函数这时候常见的兜底方案是用一个计数器给动态路由的 name 加后缀然后router.$router.options.routes配合matcher重置这种跳坑操作就不展开了。新项目直接上 Vue Router 4 是最省心的做法。5.3 双账号切换的验证清单代码写完之后用两个角色账号做一次对照验证能覆盖动态路由清理的大部分缺陷。打开浏览器 DevTools 的 Application 面板清空 Local Storage 后先登录管理员账号手动访问几个只有管理员才有的路由确认菜单正常然后登出不刷新页面直接登录一个普通账号检查三件事第一侧边栏菜单是否只剩普通角色能看到的项第二手动输入管理员专属路由地址是否被重定向到 404 或首页第三在 vue-router 的路由历史里检查旧路由是否还残留在当前路由表。如果发现残留优先检查removeRouteFns是否在登出时被正确执行以及 Pinia 的routes数组是否被清空。验证通过后这套基于 Vue Element 的权限方案才算闭环。权限码约束了按钮按钮约束了操作路由约束了页面登出清理堵住了账号切换的漏洞三层各司其职后续新增业务模块只需要在后端菜单表里加一条记录前端按规范在views目录下建组件权限系统本身不再需要改动。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价