vue3 vite pinia实现动态路由,菜单权限,按钮权限
一、创建Vue3 Vite项目并安装依赖
初始化项目后,安装核心依赖,支撑路由、状态管理及权限控制功能:
bash
# 核心依赖(vue-router4 + pinia)
npm install vue-router pinia
# 辅助依赖(路由进度条,提升用户体验)
npm install nprogress
二、路由配置(router)
拆分静态路由与动态路由逻辑,静态路由存储无需权限校验的页面,动态路由由后端返回并动态挂载。
2.1 静态路由配置:router/specialroute.js
定义页面路径枚举、静态路由列表及首页核心路由容器,统一管理基础路由信息。
javascript
// 页面路径枚举,避免硬编码
export const PageEnum = {
LOGIN: '/login', // 登录页面
ERROR_403: '/403', // 无权限页面
INDEX: '/' // 首页根路径
}
import { PageEnum } from '@/enums/pageEnum'
import Layout from '@/views/home/home.vue' // 后台核心布局组件
export const INDEX_ROUTE_NAME = 'home' // 首页路由名称
// 静态路由(无需权限即可访问)
export const constantRoutes = [
// 404页面(匹配所有未定义路由)
{
path: '/:pathMatch(.*)*',
component: () => import('@/views/nopage/nopage.vue')
},
// 403无权限页面
{
path: PageEnum.ERROR_403,
component: () => import('@/views/nopage/nopage403.vue')
},
// 登录页面
{
path: PageEnum.LOGIN,
component: () => import('@/views/login/login.vue')
},
]
// 首页核心路由(承载动态路由的容器,后续动态路由挂载至其children下)
export const INDEX_ROUTE = {
path: PageEnum.INDEX,
component: Layout,
name: INDEX_ROUTE_NAME
}
2.1.1 menutype
javascript
/**
* 菜单类型
* 目录:dir
* 菜单:menu
* 按钮:btn
* 单页路由:spa
*/
const MENU_TYPE = {
DIR: 'dir',
MENU: 'menu',
BUTTON: 'btn',
SPA: 'spa',
};
const MENU_list = [
{
label: '目录',
value: MENU_TYPE.DIR,
},
{
label: '菜单',
value: MENU_TYPE.MENU,
},
{
label: '按钮',
value: MENU_TYPE.BUTTON,
},
{
label: '单页路由',
value: MENU_TYPE.SPA,
}
];
export {
MENU_TYPE,
MENU_list
}
2.2 路由核心逻辑:router/index.js
实现动态路由解析、组件懒加载、路由重置等功能,创建Vue Router实例并初始化静态路由。
javascript
import { createRouter, createWebHistory, RouterView } from 'vue-router'
import { MENU_TYPE } from '@/utils/menuconfig';
import { constantRoutes, INDEX_ROUTE_NAME } from './specialroute'
import { useMenuList } from '@/store';
// 判断是否为外部链接(http/https/mailto等)
const isExternal = (path) => {
return /^(https?:|mailto:|tel:)/.test(path)
}
// Vite专属语法:批量导入src/views下所有.vue组件(实现组件懒加载)
const modules = import.meta.glob('/src/views/**/*.vue')
// 获取所有可导入组件的路径标识(去除前缀和后缀,便于匹配)
export function getModulesKey() {
return Object.keys(modules).map((item) => item.replace('/src/views/', '').replace('.vue', ''))
}
// 过滤并处理异步路由(后端菜单数据 -> Vue Router可识别格式)
export function filterAsyncRoutes(routes, firstRoute = true) {
const routeRecord = createRouteRecord(routes, firstRoute)
return routeRecord
}
// 递归构建路由记录(核心:将后端菜单转为路由格式,支持多级子路由)
export function createRouteRecord(route, firstRoute) {
const routeRecord = []
route.forEach(menu => {
const generateRoute = (item) => {
const route = {
path: item.path,
name: item.name || '',
// 匹配对应组件,无组件则留空
component: item.component ? modules[`/src/views/${item.component}.vue`] : '',
meta: {
title: item.title || '', // 菜单名称
useType: item.useType, // 菜单类型(对应MENU_TYPE枚举)
requiresAuth: true, // 是否需要权限校验
keepAlive: item.keepAlive === 0, // 是否缓存组件
aisShow: item.aisShow === 0 // 是否在侧边栏显示
}
};
// 递归处理子菜单
if (item.children && item.children.length > 0) {
route.children = item.children.map(child => generateRoute(child));
}
return route;
};
routeRecord.push(generateRoute(menu));
});
return routeRecord
}
// 加载路由组件(容错处理:找不到组件时返回RouterView,避免页面报错)
export function loadRouteView(component) {
try {
const key = Object.keys(modules).find((key) => key.includes(`/${component}.vue`))
if (key) return modules[key]
throw Error(`找不到组件${component},请确保组件路径正确`)
} catch (error) {
console.error(error)
return RouterView
}
}
// 查找第一个有效路由(用于首页重定向,避免进入空白首页)
export function findFirstValidRoute(routes){
for (const route of routes) {
if (route.meta?.useType === MENU_TYPE.MENU && route.meta?.aisShow && !isExternal(route.path)) {
return route.name
}
if (route.children) {
const name = findFirstValidRoute(route.children)
if (name) return name
}
}
}
// 重置路由(退出登录时调用,清除动态挂载的路由,避免权限泄露)
export function resetRouter() {
router.removeRoute(INDEX_ROUTE_NAME)
const { menuList } = useMenuList()
menuList.forEach((route) => {
const name = route.name
if (name && router.hasRoute(name)) router.removeRoute(name)
})
}
// 创建Router实例,仅初始化静态路由
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL), // HTML5历史模式
routes: constantRoutes
})
export default router
三、状态管理(Pinia)
通过Pinia存储菜单列表、按钮权限列表,封装权限数据的获取、设置与重置方法,供全项目复用。
3.1 权限Store:store/menustore.js
javascript
import { defineStore } from 'pinia';
import { getRoutes } from '@/api/menu'; // 后端接口:获取菜单与按钮权限
import codeConfig from '@/utils/codeConfig'; // 接口状态码枚举(如成功码200)
import router, { filterAsyncRoutes } from '@/router'; // 路由工具方法
// 权限状态Store(命名空间:menu)
const useMenuList = defineStore('menu', {
state: () => ({
menuList: [], // 处理后的动态路由列表(用于路由挂载)
btnPermissionList: [], // 按钮权限列表(用于组件级权限控制)
sideMenuList: [] // 原始侧边菜单列表(用于渲染侧边栏)
}),
actions: {
// 设置菜单数据(格式化后端返回的原始菜单)
setMenuData(data) {
this.menuList = filterAsyncRoutes(data); // 转为可挂载的路由格式
this.sideMenuList = data; // 保留原始数据,用于侧边栏渲染
},
// 设置按钮权限列表
setBtnPermissionList(data) {
this.btnPermissionList = data;
},
// 异步获取路由与按钮权限数据(核心方法,登录后调用)
async getRouteList() {
const res = await getRoutes();
try {
// 接口请求成功(匹配成功状态码)
if (res.code === codeConfig.SUCCESS) {
this.setMenuData(res.data.menus); // 修复原代码笔误:menum -> menus
this.setBtnPermissionList(res.data.btns);
}
} catch (e) {
// 异常处理:清空权限数据,避免脏数据影响
console.error('获取权限数据失败:', e);
this.setMenuData([]);
this.setBtnPermissionList([]);
}
},
// 重置权限状态(退出登录时调用)
resetState() {
this.menuList = [];
this.btnPermissionList = [];
this.sideMenuList = [];
}
}
});
export { useMenuList };
四、动态路由与权限控制
通过路由前置守卫,在页面跳转前完成权限校验、动态路由挂载、无权限拦截等逻辑,实现页面级访问控制。
4.1 权限守卫:permission.js
javascript
import router, { findFirstValidRoute } from './router';
import NProgress from 'nprogress'; // 路由跳转进度条
import 'nprogress/nprogress.css'; // 进度条样式
import { useMenuList, useSSEClient } from '@/store'; // 权限Store与SSE状态
import { INDEX_ROUTE, INDEX_ROUTE_NAME } from './router/specialroute'; // 首页路由
import { PageEnum } from './enums/pageEnum'; // 页面路径枚举
import { MENU_TYPE } from '@/utils/menuconfig'; // 菜单类型枚举
import { getToken, clearUserInfo, getUserInfo } from '@/utils/tools'; // 本地存储工具
// 配置进度条(隐藏加载图标,优化动画效果)
NProgress.configure({ showSpinner: false, easing: 'ease', speed: 500 });
// 常量定义(简化后续代码)
const loginPath = PageEnum.LOGIN; // 登录页路径
const defaultPath = PageEnum.INDEX; // 首页路径
const whiteList = [loginPath, PageEnum.ERROR_403]; // 白名单(无需Token即可访问)
// 路由前置守卫(核心:权限校验与动态路由挂载)
router.beforeEach(async (to, from, next) => {
NProgress.start(); // 启动进度条
const userStore = useMenuList(); // 获取权限Store实例
const sseClientStore = useSSEClient(); // SSE状态(按需使用,可保留)
// 1. 访问白名单页面:直接放行
if (whiteList.includes(to.path)) {
next();
return;
}
// 2. 非白名单页面:校验Token(判断是否已登录)
if (getToken()) {
const userInfo = getUserInfo() || {}; // 获取本地存储的用户信息
const hasGetUserInfo = Object.keys(userInfo).length !== 0; // 是否有完整用户信息
// 2.1 已获取完整用户信息
if (hasGetUserInfo) {
// 2.1.1 已登录但访问登录页:重定向至首页
if (to.path === loginPath) {
userStore.resetState();
next({ path: defaultPath });
return;
}
// 2.1.2 动态路由已挂载:直接放行
if (userStore.menuList && userStore.menuList.length > 0) {
next();
return;
}
// 2.1.3 动态路由未挂载:拉取权限数据并挂载
try {
await userStore.getRouteList(); // 调用接口获取权限数据
const routes = userStore.menuList; // 处理后的动态路由
const routeName = findFirstValidRoute(routes); // 首个有效路由(首页重定向用)
// 无有效路由:无权限访问,重定向至403页面
if (!routeName) {
clearUserInfo();
userStore.resetState();
next(PageEnum.ERROR_403);
return;
}
// 配置首页重定向(避免进入空白首页)
INDEX_ROUTE.redirect = { name: routeName };
// 挂载首页路由(承载动态路由的容器)
if (!router.hasRoute(INDEX_ROUTE_NAME)) {
router.addRoute(INDEX_ROUTE);
}
// 批量挂载动态路由(根据菜单类型选择挂载方式)
routes.forEach((route) => {
if (route.meta?.useType !== MENU_TYPE.SPA) {
// 非SPA类型菜单:挂载至首页路由下(作为子路由)
router.addRoute('home', {
...route,
meta: { ...route.meta, isDynamic: true } // 标记为动态路由,便于重置
});
} else {
// SPA类型菜单:直接挂载(一级路由)
router.addRoute({
...route,
meta: { ...route.meta, isDynamic: true }
});
}
});
// 重定向至目标页面(replace清除历史记录,避免回退异常)
next({ ...to, replace: true });
} catch (err) {
// 挂载失败:清除用户信息,重定向至登录页
console.error('动态路由挂载失败:', err);
clearUserInfo();
userStore.resetState();
next({ path: loginPath, query: { redirect: to.fullPath } });
}
} else {
// 2.2 无完整用户信息:重置状态,重定向至登录页
userStore.resetState();
next({ path: loginPath, query: { redirect: to.fullPath } });
}
} else {
// 3. 无Token(未登录):重置状态,重定向至登录页
userStore.resetState();
next({ path: loginPath, query: { redirect: to.fullPath } });
}
});
// 路由后置守卫:结束进度条
router.afterEach(() => {
NProgress.done();
});
五、按钮权限(组件级控制)
通过Vue自定义指令,实现按钮、操作栏等组件的权限控制,无权限时自动隐藏对应元素,精准控制操作权限。
5.1 自定义权限指令:directives/perm.js
javascript
/**
* 自定义权限指令 v-perms
* 用途:组件级权限控制,无权限时移除DOM元素
* 用法:<el-button v-perms="['auth.menu/edit']">编辑</el-button>
* 说明:value为权限标识数组,与后端返回的btns列表匹配
*/
import { useMenuList } from '@/store';
export default {
// 指令挂载时执行权限校验
mounted: (el, binding) => {
const { value } = binding; // 指令传入的权限标识数组
const menuStore = useMenuList(); // 权限Store实例
const btnPermissions = menuStore.btnPermissionList; // 用户拥有的按钮权限
const allPermission = '*'; // 超级管理员权限(匹配所有操作)
// 校验指令格式:必须为非空数组
if (Array.isArray(value) && value.length > 0) {
// 校验是否拥有目标权限
const hasPermission = btnPermissions.some(key =>
key === allPermission || value.includes(key)
);
// 无权限:移除绑定指令的DOM元素
if (!hasPermission) {
el.parentNode && el.parentNode.removeChild(el);
}
} else {
// 格式错误:抛出异常提示
throw new Error('v-perms指令格式错误!正确用法:v-perms="[\'auth.menu/edit\']"');
}
}
};
六、后端数据格式说明

路由权限数据由后端从后台管理系统配置(菜单、按钮、角色关联),接口返回JSON格式数据,包含菜单路由信息和按钮权限标识。
6.1 数据示例
后端返回的菜单与按钮权限数据结构如下:
json
{
"code": 200,
"message": "success",
"data": {
"btns": [
"auth.menu/add", // 新增菜单权限
"auth.menu/edit", // 编辑菜单权限
"auth.menu/delete" // 删除菜单权限
],
"menus": [
{
"path": "/system",
"name": "System",
"component": "system/index",
"title": "系统管理",
"useType": 'dir',
"keepAlive": 1,
"aisShow": 0,
"children": [
{
"path": "/system/menu",
"name": "SystemMenu",
"component": "system/menu",
"title": "菜单管理",
"useType": 'menu',
"keepAlive": 1,
"aisShow": 0
}
]
}
]
}
}
6.2 字段说明
-
btns:数组类型,存储用户拥有的按钮权限标识,与前端v-perms指令传入值一一对应。
-
menus:数组类型,存储用户拥有的菜单数据,需包含路由必需的path、name、component字段,支持多级子菜单(children)。
-
useType:菜单类型标识,对应MENU_TYPE枚举,用于区分路由挂载方式。
-
keepAlive:组件缓存标识(0=缓存,1=不缓存)。
-
aisShow:侧边栏显示标识(0=显示,1=隐藏)。
七、核心流程梳理
-
登录验证:用户登录成功后,后端返回Token,前端存储Token与基础用户信息。
-
路由守卫触发:登录后重定向至首页,触发路由前置守卫,开始权限校验。
-
权限数据拉取:守卫校验Token有效且无动态路由数据,调用接口获取菜单和按钮权限。
-
动态路由挂载:将后端菜单数据格式化为路由格式,批量挂载至首页路由下。
-
页面与组件渲染:路由挂载完成后放行至目标页面,侧边栏根据原始菜单数据渲染,按钮通过v-perms指令校验权限后显示/隐藏。
-
退出登录清理:清除Token、用户信息,重置Pinia状态与动态路由,重定向至登录页。
八、注意事项
-
路由挂载后需通过
next({ ...to, replace: true })重定向,避免路由匹配异常。 -
退出登录时必须调用
resetRouter()清除动态路由,防止权限泄露。 -
组件路径需与后端返回的component字段一致,避免组件加载失败。
-
按钮权限标识需前后端统一,建议通过后端配置中心管理,便于维护。