☰
Vue3中后台框架核心链路:登录Token、动态路由与按钮权限详解
2026/10/14 13:50:11 网站建设 项目流程

简介:若依Vue3前端页面框架是一套基于Vue3的开源企业级后台管理前端解决方案,面向需要快速搭建管理系统界面与业务逻辑的开发者。框架以Composition API与Proxy响应式系统为核心,集成Element UI for Vue3组件库,并封装了路由管理、状态管理、Ajax及WebSocket请求等能力,使前后端分离项目的基础架构能够在统一规范下快速落地。

压缩包共含299个文件、约3MB,以Vue页面组件、JavaScript逻辑脚本与SVG图标为主,同时涵盖SCSS样式、环境配置及构建启动脚本等,覆盖按钮、表格、表单、导航栏等常用后台界面元素,目录结构清晰,便于按模块检索与二次开发。

目前已有371人浏览学习该资源。借助这套框架可获得完整的前端项目骨架,包括组件复用方式、数据交互封装思路、路由与状态管理示例,以及代码压缩、资源懒加载等性能优化策略,适合Vue3初学者研究整体工程结构,也适合有经验的开发团队直接基于项目模板扩展业务功能。

1. 为什么收到这份 Vue3 中后台框架资源后,先从“登录链路”开始读代码

做 Vue3 后台管理系统的人,迟早会碰到一个问题:项目越往后越像在造轮子。权限、路由、菜单、字典、代码生成,这些通用能力每个项目都要来一遍。而这份资源对应的方案,是一套已经把这些基础能力全部固化的 Vue3 前端页面框架,解压就能跑,跑起来能看到完整的登录验证码、动态菜单、按钮级权限和多级页面布局。它适合两类人:一类是想快速交付公司后台或毕业设计的全栈开发者,另一类是刚接触中后台项目、想看懂真实工程里路由和权限是怎么串起来的新手。我建议你别急着改页面,先把登录这条链路读通,后面所有模块都挂在这条链路上。

2. 把 src 目录当作布线图纸:七个核心模块与启动流程

拿到这套框架的源码包,第一件事不是npm run dev,而是先弄清楚 src 下每个目录在整条业务链路里扮演什么角色。很多人在这一步跳太快,导致后面动态路由加不进去、权限指令不生效时,连该去哪个文件排查都不知道。

2.1 核心目录角色:哪几个文件是二次开发的起点

这套框架的 src 目录结构很典型,我把它按“请求链路、页面链路、权限链路”三个维度拆开看。

目录或文件核心职责二次开发时先看什么
src/api按业务模块拆分的接口定义登录、用户、字典等模块的请求函数
src/views页面组件,按业务模块划分子目录列表页、表单页、详情页的模板结构
src/layout后台布局骨架:侧边栏、导航栏、标签页AppMain.vue 里的 router-view 与 keep-alive
src/router静态路由表与动态路由生成逻辑index.js 里 constantRoutes 的写法
src/storePinia 状态:用户、权限、字典、标签页modules/user.js 里 token 与权限的存取
src/directive自定义指令:hasPermi、hasRole 等permission/hasPermi.js 的实现
src/utils请求封装、token 存储、通用工具request.js 与 auth.js 的约定

我一般会先打开 src/utils/request.js,看它的请求拦截器和响应拦截器。因为这套框架的 axios 封装决定了所有接口的鉴权方式、错误提示方式和 401 处理方式,后续接入任何新模块都会经过这一层。再看 src/store/modules/user.js,因为登录态、用户信息、权限码的读写都集中在这里。

2.2 本地启动与多环境配置:env 文件里最容易写错的是 BASE_API

依赖安装和环境变量配置是整个框架跑起来的前置条件。常见的安装命令如下:

# 首次安装依赖,建议用 npm,部分机器用 pnpm 会因 peer 依赖解析策略差异报错 npm install # 本地开发服务,默认端口在 vite.config.js 里配置,常见是 80 或 8080 npm run dev # 生产环境构建,产物输出到 dist 目录 npm run build:prod

如果 npm install 过程中卡在某个原生依赖,常见原因是 Node 版本与 Vite 不匹配。这套框架对 Node 版本有要求,实践下来 Node 16 到 18 之间比较稳,Node 20 以上个别版本会在安装 esbuild 时失败,降级到 16 或 18 即可。

环境变量文件是很多人翻车的地方。开发环境用的是.env.development,生产环境用的是.env.production,核心内容如下:

# .env.development NODE_ENV = development VITE_APP_BASE_API = '/dev-api' VITE_APP_APP_TITLE = '后台管理系统'

这里的VITE_APP_BASE_API不是后端真实地址,而是一个代理标识。Vite 开发服务器会拦截以/dev-api开头的请求,转发到真正的后端服务:

// vite.config.js 中的 server.proxy 配置片段 server: { host: '0.0.0.0', port: 80, proxy: { '/dev-api': { target: 'http://localhost:8080', // 后端服务真实地址 changeOrigin: true, pathRewrite: { '^/dev-api': '' } } } }

参数说明:target是后端接口服务地址;changeOrigin设置为 true 表示把请求头里的 Host 改成 target 域名,避免后端做域名校验时拒绝;pathRewrite把/dev-api前缀去掉后转发。很多新手图省事,直接把VITE_APP_BASE_API写成http://localhost:8080,结果浏览器直接跨域,请求发不出去。正确做法是永远写代理标识,由 Vite 转发,开发环境就不会遇到跨域问题。

2.3 从登录接口到 Token 落地:一条完整请求链

登录逻辑是整个框架权限体系的入口,理解它之后,动态路由和按钮权限才能顺理成章接上。登录页通常会先请求验证码接口,拿到一张图片和一个 uuid:

// 验证码接口,src/api/login.js import request from '@/utils/request' // 获取验证码 export function getCodeImg() { return request({ url: '/captchaImage', method: 'get' }) } // 登录提交 export function login(username, password, code, uuid) { const data = { username, password, code, uuid // 验证码的唯一标识,后端用它比对用户输入的验证码 } return request({ url: '/login', method: 'post', data: data }) }

登录页拿到验证码图片后,用户填写用户名、密码、验证码,提交时把 uuid 一起带过去。这个 uuid 的作用是让后端知道你要校验的是哪张验证码图片,防止验证码被并发请求覆盖。

登录成功后,框架会把 token 存进 localStorage,同时写入 Pinia:

// src/store/modules/user.js 中的 login action import { login, getInfo } from '@/api/login' import { getToken, setToken, removeToken } from '@/utils/auth' export const useUserStore = defineStore('user', { state: () => ({ token: getToken(), name: '', avatar: '', roles: [], permissions: [] }), actions: { // 登录 login(userInfo) { return new Promise((resolve, reject) => { login(userInfo.username.trim(), userInfo.password, userInfo.code, userInfo.uuid) .then(res => { setToken(res.token) this.token = res.token resolve() }) .catch(error => { reject(error) }) }) } } })

setToken内部就是localStorage.setItem('token', value)。之所以不只用 Pinia,是因为刷新页面后 Pinia 内存会被清空,localStorage 里的 token 能让刷新后仍然保持登录态。

每次发请求时,axios 请求拦截器会把 token 放进请求头:

// src/utils/request.js 请求拦截器片段 service.interceptors.request.use(config => { if (getToken()) { config.headers['Authorization'] = 'Bearer ' + getToken() } return config })

Header 里的Authorization: Bearer <token>是后端识别用户身份的凭证。后端返回 401 时,响应拦截器会清掉本地 token 并跳回登录页。这条链路看起来简单,但它是后面所有权限判断的地基:没有 token 就拉不到用户信息,没有用户信息就生不成菜单,没有菜单就没有按钮权限。所以我把“先读登录链路”放在第一步。

3. 动态路由:菜单不是写死的,而是后端返回、前端挂载

中后台项目最核心的体验就是菜单和路由联动。这套框架没有在前端硬编码业务菜单,而是把菜单数据放在数据库的菜单表里,由后端按当前用户角色过滤后返回,前端再把菜单数据翻译成 Vue Router 能识别的路由表。这个机制是二次开发必读的一环。

3.1 菜单表的字段如何映射成路由记录

菜单表里每条记录通常包含这些关键字段:菜单名称、路由地址、组件路径、权限字符、菜单类型、图标、排序。后端返回的菜单树里,真正影响前端路由的是path、component、name、meta这四个属性。

前端拿到菜单树后,通过递归把它转换成路由对象:

// 菜单树转路由表的逻辑,通常在 src/store/modules/permission.js 中 function buildRoutes(menuList) { const result = [] menuList.forEach(menu => { const route = { path: menu.path, // 菜单里配置的路由地址 name: menu.routeName || menu.path.replace(/^\//, ''), // 路由名称,keep-alive 缓存依赖它 component: loadView(menu.component), // 组件路径,如 system/user/index meta: { title: menu.menuName, // 菜单标题 icon: menu.icon, // 图标名称 keepAlive: menu.keepAlive // 是否需要页面缓存 } } if (menu.children && menu.children.length > 0) { route.children = buildRoutes(menu.children) } result.push(route) }) return result }

这里的loadView是围绕 Vite 的静态分析特性设计的。如果写成() => import('@/views/' + menu.component),Vite 无法在构建时静态分析动态变量,生产环境会报模块找不到。这套框架常见做法是用import.meta.glob一次性读入所有页面组件:

// 使用 import.meta.glob 提前收集所有页面组件 const viewModules = import.meta.glob('../views/**/*.vue') // 根据后端返回的组件路径取对应组件 function loadView(viewPath) { return viewModules[`../views/${viewPath}.vue`] }

参数说明:import.meta.glob是 Vite 提供的批量导入语法,它会在构建时把../views下所有.vue文件收集进一个映射表,键是文件路径,值是组件加载函数。改造后的菜单表里,组件路径要写成system/user/index这种不含.vue后缀的格式,框架会自动拼接成../views/system/user/index.vue去映射表里找。

3.2 路由守卫里 addRoute 的时序,错一步就白屏

动态路由不会在项目启动时就全部注册,而是等用户登录后,拿到菜单树再调用router.addRoute逐个注册。整个流程发生在全局路由守卫里:

// src/permission.js 路由守卫核心逻辑 router.beforeEach(async (to, from, next) => { if (getToken()) { if (to.path === '/login') { next({ path: '/' }) } else if (useUserStore().roles.length === 0) { // 尚未拉取用户信息和动态路由,需要走初始化流程 await useUserStore().getInfo() // 获取 roles 和 permissions const accessRoutes = await usePermissionStore().generateRoutes() // 生成动态路由 accessRoutes.forEach(route => { router.addRoute(route) // 把动态路由注册进当前 router 实例 }) next({ ...to, replace: true }) // 重新进入目标路由,避免首次匹配 404 } else { next() } } else { if (to.path === '/login') { next() } else { next(`/login?redirect=${to.path}`) // 未登录跳登录页,并记住来源路径 } } })

这段代码的时序很关键。getInfo()必须发生在generateRoutes()之前,因为菜单树是根据当前用户的角色过滤出来的,没有用户角色信息,后端不知道该返回哪份菜单。而next({ ...to, replace: true })这个写法是防白屏的核心:第一次进入某个页面时,动态路由尚未注册,路由匹配可能落到 404,第二次带着完整路径重新进入,路由表里已经存在对应记录,才能正确渲染。

我建议你在调试时,在generateRoutes()前后各打印一次路由表,看一眼router.getRoutes()的长度变化。这个习惯能快速定位 90% 的动态路由问题。

3.3 刷新后路由丢失的正确补救方式

动态路由面临的最大考验是浏览器刷新。刷新后 Pinia 内存清空、路由表重置,但 localStorage 里的 token 还在。路由守卫会认为用户仍处于登录态,于是进入roles.length === 0的分支,重新拉取用户信息、重新生成路由并注册。所以这套框架天然自带刷新恢复能力,前提是你没有改坏守卫逻辑。

如果你发现刷新后白屏,优先检查两件事:第一,getInfo()接口是否正常返回 roles 和 permissions;第二,generateRoutes()是否因为后端返回的 component 路径对不上views目录结构,导致loadView返回 undefined。后者最常见的表现是:菜单能显示,但点击菜单页面空白,控制台报 no component 相关警告。

有一种错误补救方式是刷新后把路由表存进 sessionStorage,试图跳过重新请求。我不推荐这么做,因为路由对象里有组件引用,序列化再恢复后组件引用会丢失,而且代码更新后用户仍可能拿到旧路由。让路由每次刷新都重新走一遍生成链路,反而是最干净、最不容易出问题的方式。

4. 按钮级权限:v-hasPermi 指令帮你把按钮藏住,但藏不住请求

动态路由解决的是“用户能看到哪些页面”的问题,而按钮权限解决的是“用户能在页面上操作哪些功能”的问题。这套框架里,按钮级权限通过一个自定义指令v-hasPermi实现,用法是在按钮上写v-hasPermi="['system:user:add']",没有权限的用户连按钮都看不到。

4.1 权限码怎么从数据库一路走到前端 Pinia

按钮权限的源头是数据库菜单表里的“权限字符”字段,比如system:user:add、system:user:edit。管理员在菜单管理里给每个按钮配置权限字符后,后端在用户登录时,会根据该用户关联的角色收集所有权限字符,随getInfo()接口一起返回。

// src/api/login.js 中的 getInfo 返回数据结构 { user: { userId: '1', nickName: '管理员' }, roles: ['admin'], permissions: ['*:*:*', 'system:user:list', 'system:user:add'] }

前端拿到 permissions 数组后,存放在useUserStore的 state 里。v-hasPermi指令运行时,就是从 Pinia 里读取这个数组做判断。注意*:*:*是管理员通配符,拥有它时所有权限判断都直接放行,这是后端默认给的逻辑,前端指令里也要配合处理。

4.2 v-hasPermi 指令实现:为什么直接移除 DOM

指令的核心实现并不复杂,关键在选择“以什么方式隐藏按钮”:

// src/directive/permission/hasPermi.js import { useUserStore } from '@/store/modules/user' export default { mounted(el, binding) { const { value } = binding const allPermission = '*:*:*' const permissions = useUserStore().permissions // 指令用法要求必须传数组,例如 v-hasPermi="['system:user:add']" if (Array.isArray(value) && value.length > 0) { const hasPermission = permissions.some(permission => { return allPermission === permission || value.includes(permission) }) // 没有权限时直接移除该 DOM 节点 if (!hasPermission) { el.parentNode && el.parentNode.removeChild(el) } } else { throw new Error('请设置操作权限标签值') } } }

逻辑说明:value是指令传入的权限数组,比如某个按钮可能同时允许“新增”和“导入”两种权限,可以传['system:user:add', 'system:user:import'],只要当前用户拥有其中一个就显示。permissions.some()是遍历当前用户的权限列表,只要有任意一项匹配就返回 true。

这里故意用removeChild直接从 DOM 树中移除按钮,而不是用v-show或 CSSdisplay: none。原因有三:第一,用户无法通过浏览器开发者工具临时改样式来看到按钮;第二,被移除的按钮不会参与页面布局,界面更干净;第三,真正决定能否调用接口的是后端接口权限,前端移除按钮只是交互层面的第一道过滤,不是安全措施。

4.3 后端二次校验是底线:前端控制只是交互遮丑

这里必须说清楚一个边界:v-hasPermi只控制按钮显隐,它不能代替后端鉴权。如果用户绕过页面直接向接口发请求,比如通过浏览器控制台直接调POST /system/user/add,后端必须有能力拒绝。因此这套框架的前后端是配合的,前端拿权限码控制交互,后端在接口上做权限注解校验。

前端能做的是在响应拦截器里统一处理 403 状态码:

// src/utils/request.js 响应拦截器片段 service.interceptors.response.use( response => response.data, error => { const { status } = error.response if (status === 401) { // token 失效,清空本地登录态,跳转登录页 removeToken() location.href = '/login' } else if (status === 403) { // 后端拒绝了当前操作 ElMessage.error('没有访问该资源的权限') } return Promise.reject(error) } )

这个处理的意义在于:即使某个按钮因为权限判断失误显示出来了,用户点击后请求被后端拒绝,前端也能给出明确提示,而不是页面无响应或报一堆堆栈错误。所以你在二次开发时,新增了个菜单也配了权限码,却发现按钮“时有时无”,不要只盯着指令看,先确认后端接口有没有做同样的权限校验。

4.4 指令不生效的排查顺序

我遇到过很多次“别人那边按钮正常,我这边死活不显示”的问题,总结出一个稳定的排查顺序,分享给你:

  1. 打开浏览器控制台 React DevTools 或 Vue DevTools,选中 Pinia 面板,找到useUserStore里的permissions数组,确认里面有没有你需要的权限码。如果连权限码都没有,问题不在指令,而在菜单配置或角色分配。
  2. 看菜单管理里按钮类型的菜单项,权限字符是否按模块:功能:操作的格式填写,比如system:user:add。填成中文或留空都会导致匹配失败。
  3. 如果权限码有,但按钮不显示,检查按钮上指令的写法。这里要求传数组,写成v-hasPermi="system:user:add"字符串形式不会生效,而且会在控制台抛指令错误。
  4. 最后确认指令是否在主入口文件里全局注册过。

还有一个常见误用是用v-hasPermi包裹一段非按钮元素,比如包一个整个卡片。这样做不会报错,但我建议只对按钮或需要隐藏的操作项使用,因为整块移除会改变页面布局,排查问题时容易混淆。

5. 避坑清单:二次开发最容易翻车的六个场景

前面几章讲了链路原理,这一章直接给结论。以下六个问题是我在实际改造这套框架时反复踩过的坑,按“现象 → 原因 → 解决”写清楚,你可以把它当成一份速查手册。

5.1 刷新后页面空白或落到 404

现象:登录后一切正常,按 F5 刷新,页面白屏,或者跳到一个大写的 404 页面。

原因:动态路由只存在于当前 router 实例里,刷新后路由表被清空。如果路由守卫里的“重新拉取用户信息并注册路由”流程没有正确触发,或者loadView找不到对应组件,路由匹配就会落到兜底的 404 记录上。

解决:确认刷新后网络面板里有getInfo请求发出,且返回正常。再确认generateRoutes()生成的路由数量与菜单配置一致。如果怀疑 404 是首次匹配问题,检查守卫最后是否用了next({ ...to, replace: true })重新进入目标路由。

5.2 菜单能打开,但标签页缓存不生效

现象:从列表页进入详情页,再切到其他菜单再切回来,列表页重新请求了接口,滚动位置和查询条件全部丢失。

原因:标签页缓存的匹配机制依赖路由 name 与组件内部 name 一致。路由表里name来自菜单配置,而页面组件内部没有定义 name,导致keep-alive的 include 列表匹配不上。

解决:在页面组件的<script setup>里加一行:

// 页面组件,例如 src/views/system/user/index.vue <script setup> defineOptions({ name: 'User' // 必须和路由表里该菜单的 routeName 完全一致 }) </script>

同时确认菜单配置里的路由 name 用的是大驼峰风格,如User,而不是user或user/index,否则 include 匹配仍然会失败。

5.3 代码生成出来的页面只有查询区,表格列全是空

现象:用代码生成器导入一张业务表,生成前端 zip 解压后放到 views 目录,页面能打开,但表格的列标题全部是空白或英文半角乱码。

原因:代码生成器的模板依赖数据库表结构和字段的 comment 注释。如果建表时字段没写COMMENT,生成出来的列标题就没有中文文本。

解决:把表和字段的 comment 补全,然后重新导入,生成时勾选覆盖已有文件。字段注释不只是给人看的,它是代码生成器产出的主要数据源。

5.4 全局样式覆盖 Element Plus 组件样式失效

现象:在src/assets/styles里写了一段样式想覆盖 el-button 的背景色,结果不生效,浏览器开发者工具里看到 Element Plus 自带的样式优先级更高。

原因:Element Plus 的样式变量与组件样式是从组件库内部注入的,普通全局样式如果没有提高选择器权重,就会被组件内部的样式规则压过。

解决:给覆盖样式加上:deep()或提高选择器层级。如果是在 scoped 样式中覆盖子组件样式,正确写法是:

<style scoped> :deep(.el-button--primary) { background-color: #409eff; } </style>

如果你想整体改变主题色,更推荐在样式入口覆盖 CSS 变量,比如:root { --el-color-primary: #1f4e5f; },这样所有 Element Plus 组件都会跟着变,不会出现单个按钮颜色不一致的问题。

5.5 菜单图标显示成方块或空白

现象:菜单管理里配置了图标名称,实际渲染出来是一个空的格子或一个小方块,控制台没有明显的报错。

原因:这套框架的菜单图标通常使用 SVG 雪碧图,图标文件必须放在src/assets/icons/svg目录下,文件名为图标名。你配置的图标名在目录里找不到对应文件时,就渲染成空白。

解决:把需要的 svg 格式图标文件放进上述目录,确保文件名的全小写与配置一致。新增图标后需要重新运行npm run dev,让雪碧图重新生成。

5.6 生产构建后路由懒加载 chunk 找不到

现象:本地npm run dev一切正常,npm run build:prod部署到服务器后,用户切路由时白屏,控制台报Loading chunk failed,刷新一下偶尔又能恢复。

原因:构建后的页面组件被打成带 hash 的 chunk 文件,旧 chunk 不存在了,但入口 HTML 或已加载的页面中仍引用旧 hash。常见于发布时只做了增量覆盖部署,原先的 chunk 文件被清掉。

解决:发布时整体替换 dist 目录,不要做旧文件保留式的增量发布。如果用了 Nginx,在配置里对前端路由加try_files $uri $uri/ /index.html;,确保刷新时回退到入口页。做完这些再遇到 chunk 报错,优先看服务器上的文件列表与请求的 hash 文件名是否对得上。

6. 把路由 name、组件 name 与 keepAlive 对齐,多标签页缓存才算真正接管

框架的多标签页功能是内部项目里使用频率最高的模块,但几乎每个人都会在缓存上栽一次。它表面上是一个可开关的配置,实际联动着路由、组件和标签页三个地方。

6.1 缓存开关:meta.keepAlive 与 include 列表

菜单配置里有一个“是否缓存”的开关,对应路由 meta 里的keepAlive字段。打开缓存后,路由记录会带meta: { keepAlive: true },而布局里的AppMain.vue会根据这个字段决定是否把页面放进keep-alive的 include 数组:

<!-- src/layout/components/AppMain.vue 简化的缓存联动逻辑 --> <router-view v-slot="{ Component, route }"> <keep-alive :include="cachedViews"> <component :is="Component" :key="route.path" /> </keep-alive> </router-view>

cachedViews来自标签页 store,路由的 meta.keepAlive 为 true 时,这个路由 name 会被加进缓存列表。这里最隐蔽的坑在于,keep-alive的 include 匹配的是组件注册名,不是路由 name。如果你在页面组件里没有通过defineOptions声明组件名,Vue 会退回用文件名作为 name,而文件名与路由 name 经常不一致,缓存就失效了。

6.2 验证缓存是否生效的方法

接手这套框架后,我验证缓存从不用眼睛看,而是习惯用两步操作:

第一,打开浏览器开发者工具的网络面板,清除日志后,从列表页切到详情页,再切回列表页。如果列表请求没有重新发出,说明缓存生效;如果重新发了一遍 list 请求,说明 name 没对齐。这是最直接的证据。

第二,再用 Vue DevTools 检查组件树,在 AppMain 下方会看到一个被缓存的组件实例,它的状态仍然是之前留下的查询条件和滚动位置。如果这里看不到对应组件实例,说明 include 列表里没有它。

我习惯把“路由 name、组件内 name、keepAlive 开关”列成一个三栏对照表,每新增一个页面就同步填一次,避免等到上线前统一排查时到处翻文件。

从那以后,每次接手这套框架的新页面,我都强制自己先做三件事:打开菜单配置确认路由 name,打开页面组件确认 defineOptions name,打开标签页面板确认缓存列表。这三步能在两分钟内判断一个页面该不该走缓存,几乎避免了我所有关于缓存的返工。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询