vue3框架仓库: vue-next-admin: 🎉🎉🔥基于vue3.x 、Typescript、vite、Element plus等,适配手机、平板、pc 的后台开源免费模板库(vue2.x请切换vue-prev-admin分支) - Gitee.com
https://gitee.com/lyt-top/vue-next-admin/tree/master/vue3框架安装流程:
-
克隆项目 git clone https://gitee.com/lyt-top/vue-next-admin.git
-
进入项目 cd vue-next-admin
-
安装依赖 npm install
-
运行项目 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;
});
}
完整动态路由效果代码关注并私信