vue3框架仓库:vue-next-admin: 🎉🎉🔥基于vue3.x 、Typescript、vite、Element plus等,适配手机、平板、pc 的后台开源免费模板库(vue2.x请切换vue-prev-admin分支) - Gitee.comhttps://gitee.com/lyt-top/vue-next-admin/tree/master/vue3框架安装流程:
1. 克隆项目 git clone https://gitee.com/lyt-top/vue-next-admin.git
2. 进入项目 cd vue-next-admin
3. 安装依赖 npm install
4. 运行项目 npm run dev
什么是动态路由?为什么要用?
很多新手写的 Vue 项目,所有页面路由都是写死在代码里的(固定/静态路由),所有用户登录后看到的菜单、页面权限都一模一样。
动态路由核心作用:
管理员、普通员工登录,看到的侧边栏菜单、可访问的页面、按钮权限完全不一样。后端返回什么菜单,前端就渲染什么路由和菜单,不用改代码、不用打包重启项目。
一动态路由的实现原理
1. 权限获取:登录成功后获取后端返回的用户菜单数据
2. 路由过滤 / 数据转换:读取返回的扁平菜单,递归格式化生成树形路由;将“后端接口返回的菜单数据”与“前端本地预设的路由表”进行比对,筛选出当前用户拥有访问权限的路由配置.
3. 路由注册:将处理完成的路由对象动态挂载到路由实例,实现路由按需加载;侧边栏根据格式化后的菜单自动渲染。
二vue3动态路由完整实现步骤
步骤一:Vite 跨域代理配置
位置:vite.config.ts
作用:给其加上代理配置,在本地开发环境中,不用全局修改所有接口地址,就可请求完整接口地址。前端本地启动项目时,所有 /pc 开头的接口请求,自动转发到后端地址 https://lhsc.xucm.cn,解决跨域报错问题。(根据接口内容配置代理内容,可配置多个)
server: { host: '192.168.1.105', //监听的 IP 地址; 同一局域网内:其他手机、电脑,可以通过 `http://你电脑IP:端口` 访问你的前端项目 port: env.VITE_PORT as unknown as number, open: JSON.parse(env.VITE_OPEN), hmr: true, proxy: { '/pc': { //匹配接口 target: 'https://lhsc.xucm.cn', //接口地址 changeOrigin: true, //修改请求头Origin源 }, }, },步骤二:开启动态路由模式
位置:src/stores/themeConfig.ts
核心配置:isRequestRoutes: true (134行左右)
( false:前端写死所有路由,所有人权限一致(静态路由)
true:后端控制动态路由,登录后根据后端返回数据动态生成页面和菜单)
// 是否开启后端控制路由(本项目使用登录接口返回的菜单数据动态生成路由与侧边栏) isRequestRoutes: true,步骤三:Axios 请求拦截器(统一处理接口)
位置:src/utils/request.ts
1. 请求拦截(发请求之前)
自动从本地缓存读取 token,塞进请求头,识别当前登录用户。
// 请求拦截器:每次发送接口请求之前自动执行 service.interceptors.request.use( (config) => { if (Local.get('token')) { // 从本地存储读取token,判断用户是否已经登录 // 在请求头headers中挂载Authorization,后端依靠该token识别登录身份,完成鉴权 config.headers!['Authorization'] = `${Local.get('token')}`; } // 返回处理后的请求配置,正式发起网络请求 return config; }, (error) => { // 请求发起前出现异常,将错误向外抛出,交由业务页面catch捕获 return Promise.reject(error); } );2. 响应拦截(拿到后端返回数据后)
自动判断接口是否请求成功(code 0 为失败/1 为成功)
识别 401/4001 状态码(token过期、被踢下线),自动清缓存、跳回登录页
统一处理网络超时、断网、404、500 报错,并进行提示
// 响应拦截器 service.interceptors.response.use( (response) => { // 1. 获取接口返回的真实数据实体 const res = response.data; // 2. 业务状态码拦截校验 (使用 res.code !== undefined 替代 res.code,避免 code 为 0 时被错误识别为 false 导致漏拦截。) if (res.code !== undefined && res.code !== 0 && res.code !== 1) { // 业务成功 code:0(模板默认)、1(本后端登录成功),其余全部视为接口业务异常 // 3. 处理鉴权失败的特殊状态码(401 / 4001 通常代表 token 过期或被顶号) if (res.code === 401 || res.code === 4001) { Session.clear(); // 清除浏览器全部临时缓存(如 token, userInfo, menuList 等) window.location.href = '/'; // 强制重定向至登录页 // 弹出提示框,告知用户登录已失效 ElMessageBox.alert('你已被登出,请重新登录', '提示', { confirmButtonText: '确定', type: 'warning' }).then(() => {}).catch(() => {}); } // 4. 将业务错误抛出,中断当前的 Promise 链 return Promise.reject(res); // 拦截器本身不是错误对象,这里应当抛出接口返回的 res(或 new Error),以便在具体页面 API 调用处的 catch 中获取真实的报错信息。 } // 5. 状态正常,直接将数据返回给页面调用 return res; }, (error) => { // HTTP 协议层面的网络错误拦截(如 404, 500, 请求超时, 断网等) if (error.message.includes('timeout')) { ElMessage.error('网络超时,请稍后重试'); } else if (error.message === 'Network Error') { ElMessage.error('网络连接错误,请检查您的网络环境'); } else { // 增加安全校验 error.response 是否存在,避免取 statusText 时出现 undefined 报错 if (error.response && error.response.statusText) { ElMessage.error(error.response.statusText); // 提示服务器返回的标准错误文本(如 "Not Found", "Internal Server Error") } else { ElMessage.error('接口路径找不到或服务器无响应'); } } return Promise.reject(error); // 继续抛出网络错误,使得前端页面的 finally 或 catch 能够正确执行关闭 loading 等操作 } );步骤四:登录页面核心逻辑修改
1. 登录接口封装
位置:src/api/login/index.ts
单独封装登录请求,向后端提交账号密码,获取登录结果。
请求地址:/pc/user/login,请求方式:post,传参:用户名、密码。
export function useLoginApi() { return { signIn: (data: object) => { return request({ url: '/pc/user/login', //登录接口路径(根据接口路径修改) method: 'post', data, }); }, }; }2. 登录页面表单绑定并修改登录逻辑
位置:src//views/login/component/account.vue
页面绑定了 3 个数据:账号、密码,默认填充测试账号 admin/123456。
v-model实现双向绑定,绑定账号,密码输入框
<!-- 账号输入框 --> <el-input v-model="state.ruleForm.userName"></el-input> <!-- 密码输入框 --> <el-input v-model="state.ruleForm.password"></el-input>点击登录,开启加载状态请求期间锁死按钮、防止重复提交、告诉用户正在加载。
state.loading.signIn = true;点击登录,取出绑定的值传给登录接口,并调用后端登录接口,提交账号密码
前端把页面输入的用户名、密码传给后端,等待后端返回结果。
const res = await loginApi.signIn({ username: state.ruleForm.userName, //账号 password: state.ruleForm.password, //密码 });判断登录是否成功,登陆成功本地持久化存储登录核心数据
后端返回code=1/200:登录成功,提示并存储接口返回的数据
其他 code:登录失败,弹出错误提示,并终止流程
if (res.code === 1 || res.code === 200) { // 统一存储登录接口返回的完整数据,初始化路由,看看这个人有没有菜单权限 Local.set('userInfo', res.data);登录成功页面跳转 + 友好提示,并开启页面加载动画,结束 loading 状态
首次进入首页会加载大量组件,开启加载遮罩,防止白屏卡顿;最后关闭登录按钮加载状态,无论成功失败都重置状态。
const onSignIn = async () => { state.loading.signIn = true; // 开启登录按钮的 loading 加载状态,防止用户重复点击 try { const res = await loginApi.signIn({ // 1、调用登录接口(传递表单绑定的账号和密码) username: state.ruleForm.userName, password: state.ruleForm.password, }); // 2、【判断登录状态】:如果 code 是 1 或 200,说明请求成功 if (res.code === 1 || res.code === 200) { Local.set('userInfo', res.data); // 统一存储登录接口返回的完整数据,初始化路由,看看这个人有没有菜单权限 let isNoPower: boolean | undefined = false; if (!themeConfig.value.isRequestRoutes) { isNoPower = await initFrontEndControlRoutes(); } else { isNoPower = await initBackEndControlRoutes(); } // 成功后的提示与跳转逻辑 if (isNoPower) { ElMessage.warning('抱歉,您没有登录权限'); //// 就算账号密码对了,但是没有分配任何页面权限,依然不让进 } else { ElMessage({ // 账号密码正确,且有权限,弹出成功提示 type: 'success', message: res.msg || '登录成功!', // 提示接口返回的 msg 内容 duration: 1500, // 提示框停留 1.5 秒 onClose: () => { if (route.query?.redirect) { // 【等待提示消失后才执行这里的跳转代码】 router.push({ path: <string>route.query?.redirect, query: Object.keys(<string>route.query?.params).length > 0 ? JSON.parse(<string>route.query?.params) : '', }); } else { router.push('/');// 正常登录,跳转到首页 } NextLoading.start(); // 开启全屏加载动画,防止跳转瞬间白屏卡顿 } }); } } else { // 3、【登录失败】:如果 code 不是 1 也不是 200,进行错误提示 ElMessage.error(res.msg || '账号或密码错误,登录失败'); } } catch (error) { // 【异常兜底】:比如断网了,或者服务器出bug了 console.error('登录请求异常:', error); } finally { state.loading.signIn = false; // 无论上面是成功还是失败,最后都要把按钮的 loading 状态关掉 } };步骤五:动态路由效果修改
1. 定义本地路由表
位置:src/router/route.ts
import { RouteRecordRaw } from 'vue-router'; // 扩展 RouteMeta 接口 declare module 'vue-router' { interface RouteMeta { title?: string; isLink?: string; isHide?: boolean; isKeepAlive?: boolean; isAffix?: boolean; isIframe?: boolean; roles?: string[]; icon?: string; } } /** * 定义动态路由 */ export const dynamicRoutes: Array<RouteRecordRaw> = [ { path: '/', name: '/', component: () => import('/@/layout/index.vue'), redirect: '/home', meta: { isKeepAlive: true, }, children: [ // 首页 { path: '/home', name: 'home', component: () => import('/@/views/home/index.vue'), meta: { title: '首页', icon: 'iconfont icon-barcode-qr', isKeepAlive: true, }, }, ], }, ]; /** * 定义404、401界面 */ export const notFoundAndNoPower = [ { path: '/:path(.*)*', name: 'notFound', component: () => import('/@/views/error/404.vue'), meta: { title: 'message.staticRoutes.notFound', isHide: true, }, }, { path: '/401', name: 'noPower', component: () => import('/@/views/error/401.vue'), meta: { title: 'message.staticRoutes.noPower', isHide: true, }, }, ]; /** * 定义静态路由(默认路由) */ export const staticRoutes: Array<RouteRecordRaw> = [ { path: '/login', name: 'login', component: () => import('/@/views/login/index.vue'), meta: { title: '登录', }, }, ];2. 登录后获取菜单数据并注册路由
1.读取后端原始菜单数据
从Local.userInfo.menu读取登录时缓存的后端菜单数组,做容错判断,为空默认返回空数组,避免代码报错崩溃。
const menuList = Local.get('userInfo')2.权限判定(无菜单拦截)
判断后端返回菜单数组长度:
长度为 0 → 账号密码正确,但该用户没有任何页面权限,返回无权限标识
有菜单数据 → 继续解析路由
if (res.data.length <= 0) return Promise.resolve(true);3.后端菜单 → 前端标准路由格式转换(核心)
执行backEndComponent递归转换方法,做三件核心大事:
自动排序:根据后端传的 order 序号,自动从小到大排序,菜单顺序由后端控制
组件自动绑定:通过 Vite 的
import.meta.glob扫描src/views所有页面,根据菜单 name 自动匹配xxx/index.vue页面,找不到页面自动展示占位页递归多级菜单:自动遍历子 children,无限层级解析,支持一级、二级、三级无限嵌套菜单
最终把后端纯文本菜单数据,转为 Vue-Router 可以识别的标准路由对象。
export async function setAddRoute() { await setFilterRouteEnd().forEach((route: RouteRecordRaw) => { router.addRoute(route); }); } export function setFilterRouteEnd() { let filterRouteEnd: any = formatTwoStageRoutes(formatFlatteningRoutes(dynamicRoutes)); // notFoundAndNoPower 防止 404、401 不在 layout 布局中,不设置的话,404、401 界面将全屏显示 // 关联问题 No match found for location with path 'xxx' filterRouteEnd[0].children = [...filterRouteEnd[0].children, ...notFoundAndNoPower]; return filterRouteEnd; }4.批量注册路由到Vue项目
通过router.addRoute循环遍历所有解析好的路由,动态挂载到全局路由实例。
此时:路由已经真正生效,页面可以正常访问。
export function backEndComponent(routes: any) { if (!routes) return []; return [...routes] .sort((a, b) => (a.meta?.order ?? 0) - (b.meta?.order ?? 0)) .map((item: any) => { const route: any = { path: item.path, name: item.name, // 组件按菜单 name 约定解析,未找到对应页面时使用占位页 component: resolveViewComponent(item.name), meta: { // 后端字段 locale 为菜单标题 title: item.meta?.locale || item.name, icon: item.meta?.icon || '', // 后端字段 hideInMenu 控制是否在菜单中隐藏 isHide: !!item.meta?.hideInMenu, isKeepAlive: true, }, }; if (item.children && item.children.length > 0) { route.children = backEndComponent(item.children); } return route; });5.存入Pinia全局状态,渲染页面
最后把解析好的路由存入 Pinia:
嵌套路由数据 → 渲染左侧侧边栏菜单
扁平化一维路由 → 渲染顶部标签页、菜单搜索
export async function setFilterMenuAndCacheTagsViewRoutes() { const storesRoutesList = useRoutesList(pinia); storesRoutesList.setRoutesList(dynamicRoutes[0].children as any); setCacheTagsViewRoutes(); }
修改后的完整src/router/backEnd.ts 内容
import { RouteRecordRaw } from 'vue-router'; import pinia from '/@/stores/index'; import { useUserInfo } from '/@/stores/userInfo'; import { useRequestOldRoutes } from '/@/stores/requestOldRoutes'; import { Local } from '/@/utils/storage'; import { NextLoading } from '/@/utils/loading'; import { dynamicRoutes, notFoundAndNoPower } from '/@/router/route'; import { formatTwoStageRoutes, formatFlatteningRoutes, router } from '/@/router/index'; import { useRoutesList } from '/@/stores/routesList'; import { useTagsViewRoutes } from '/@/stores/tagsViewRoutes'; // 后端控制路由 /** - 获取目录下的 .vue、.tsx 全部文件,用于动态组件解析 - @method import.meta.glob - @link 参考:[https://cn.vitejs.dev/guide/features.html#json](https://cn.vitejs.dev/guide/features.html#json) */ const viewsModules: any = import.meta.glob('../views/**/*.{vue,tsx}'); /** - 按菜单 name 约定解析页面组件:/src/views/<menu.name>/index.vue - @description 对应页面文件存在则使用真实页面,否则回退到占位页面(页面建设中) - @param name 后端菜单的 name 字段 - @returns 返回组件函数 */ function resolveViewComponent(name: string) { const key = `../views/${name}/index.vue`; if (viewsModules[key]) return viewsModules[key]; return () => import('/@/views/placeholder/index.vue'); } /** - 后端控制路由:初始化方法,防止刷新时路由丢失 - @method NextLoading 界面 loading 动画开始执行 - @method useUserInfo().setUserInfos() 触发初始化用户信息 pinia - @method useRequestOldRoutes().setRequestOldRoutes() 存储接口原始路由(未处理component),根据需求选择使用 - @method setAddRoute 添加动态路由 - @method setFilterMenuAndCacheTagsViewRoutes 设置路由到 pinia routesList 中(已处理成多级嵌套路由)及缓存多级嵌套数组处理后的一维数组 */ export async function initBackEndControlRoutes() { // 界面 loading 动画开始执行 if (window.nextLoading === undefined) NextLoading.start(); // 无 token 停止执行下一步 if (!Local.get('userInfo')?.token) return false; // 触发初始化用户信息 pinia // [https://gitee.com/lyt-top/vue-next-admin/issues/I5F1HP](https://gitee.com/lyt-top/vue-next-admin/issues/I5F1HP) await useUserInfo().setUserInfos(); // 获取路由菜单数据(登录接口返回,已由 account.vue 存入 localStorage) const res = getBackEndControlRoutes(); // 无登录权限时,添加判断 // [https://gitee.com/lyt-top/vue-next-admin/issues/I64HVO](https://gitee.com/lyt-top/vue-next-admin/issues/I64HVO) if (res.data.length <= 0) return Promise.resolve(true); // 存储接口原始路由(未处理component),根据需求选择使用 useRequestOldRoutes().setRequestOldRoutes(JSON.parse(JSON.stringify(res.data))); // 处理路由(component),替换 dynamicRoutes(/@/router/route)第一个顶级 children 的路由 dynamicRoutes[0].children = await backEndComponent(res.data); // 登录后默认首页:将 / 重定向到后端菜单第一个可见菜单(如 /index) const firstMenu = (dynamicRoutes[0].children || []).find((v: any) => v.path && !v.meta?.isHide); if (firstMenu) dynamicRoutes[0].redirect = firstMenu.path; // 添加动态路由 await setAddRoute(); // 设置路由到 pinia routesList 中(已处理成多级嵌套路由)及缓存多级嵌套数组处理后的一维数组 setFilterMenuAndCacheTagsViewRoutes(); } /** - 设置路由到 pinia routesList 中(已处理成多级嵌套路由)及缓存多级嵌套数组处理后的一维数组 - @description 用于左侧菜单、横向菜单的显示 - @description 用于 tagsView、菜单搜索中:未过滤隐藏的(isHide) */ export async function setFilterMenuAndCacheTagsViewRoutes() { const storesRoutesList = useRoutesList(pinia); storesRoutesList.setRoutesList(dynamicRoutes[0].children as any); setCacheTagsViewRoutes(); } /** - 缓存多级嵌套数组处理后的一维数组 - @description 用于 tagsView、菜单搜索中:未过滤隐藏的(isHide) */ export function setCacheTagsViewRoutes() { const storesTagsView = useTagsViewRoutes(pinia); storesTagsView.setTagsViewRoutes(formatTwoStageRoutes(formatFlatteningRoutes(dynamicRoutes))[0].children); } /** - 处理路由格式及添加捕获所有路由或 404 Not found 路由 - @description 替换 dynamicRoutes(/@/router/route)第一个顶级 children 的路由 - @returns 返回替换后的路由数组 */ export function setFilterRouteEnd() { let filterRouteEnd: any = formatTwoStageRoutes(formatFlatteningRoutes(dynamicRoutes)); // notFoundAndNoPower 防止 404、401 不在 layout 布局中,不设置的话,404、401 界面将全屏显示 // 关联问题 No match found for location with path 'xxx' filterRouteEnd[0].children = [...filterRouteEnd[0].children, ...notFoundAndNoPower]; return filterRouteEnd; } /** - 添加动态路由 - @method router.addRoute - @description 此处循环为 dynamicRoutes(/@/router/route)第一个顶级 children 的路由一维数组,非多级嵌套 - @link 参考:[https://next.router.vuejs.org/zh/api/#addroute](https://next.router.vuejs.org/zh/api/#addroute) */ export async function setAddRoute() { await setFilterRouteEnd().forEach((route: RouteRecordRaw) => { router.addRoute(route); }); } /** - 获取后端路由菜单数据 - @description 数据来源:登录接口返回的 menu 字段,登录成功时已由 account.vue 存入 localStorage(key: menuList) - @returns 返回后端路由菜单数据 */ export function getBackEndControlRoutes() { // 菜单数据统一从 Local.userInfo 中获取(登录时已整体存储) const menuList = Local.get('userInfo')?.menu; return { data: Array.isArray(menuList) ? menuList : [] }; } /** - 重新获取后端路由菜单数据 - @description 用于菜单管理界面刷新菜单(未进行测试) - @description 路径:/src/views/system/menu/component/addMenu.vue */ export async function setBackEndControlRefreshRoutes() { await getBackEndControlRoutes(); } /** - 后端路由 component 转换(兼容登录接口返回的 menu 结构) - @param routes 后端返回的路由表数组(字段:path/name/meta.locale/meta.icon/meta.order/meta.hideInMenu/children) - @returns 返回处理成 vue-router 路由记录后的数组(按 meta.order 升序排序) */ export function backEndComponent(routes: any) { if (!routes) return []; return [...routes] .sort((a, b) => (a.meta?.order ?? 0) - (b.meta?.order ?? 0)) .map((item: any) => { const route: any = { path: item.path, name: item.name, // 组件按菜单 name 约定解析,未找到对应页面时使用占位页 component: resolveViewComponent(item.name), meta: { // 后端字段 locale 为菜单标题 title: item.meta?.locale || item.name, icon: item.meta?.icon || '', // 后端字段 hideInMenu 控制是否在菜单中隐藏 isHide: !!item.meta?.hideInMenu, isKeepAlive: true, }, }; if (item.children && item.children.length > 0) { route.children = backEndComponent(item.children); } return route; }); }完整动态路由效果代码关注并私信