vue3 vite pinia实现动态路由,菜单权限,按钮权限

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
}
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=隐藏)。

七、核心流程梳理

  1. 登录验证:用户登录成功后,后端返回Token,前端存储Token与基础用户信息。

  2. 路由守卫触发:登录后重定向至首页,触发路由前置守卫,开始权限校验。

  3. 权限数据拉取:守卫校验Token有效且无动态路由数据,调用接口获取菜单和按钮权限。

  4. 动态路由挂载:将后端菜单数据格式化为路由格式,批量挂载至首页路由下。

  5. 页面与组件渲染:路由挂载完成后放行至目标页面,侧边栏根据原始菜单数据渲染,按钮通过v-perms指令校验权限后显示/隐藏。

  6. 退出登录清理:清除Token、用户信息,重置Pinia状态与动态路由,重定向至登录页。

八、注意事项

  • 路由挂载后需通过next({ ...to, replace: true })重定向,避免路由匹配异常。

  • 退出登录时必须调用resetRouter()清除动态路由,防止权限泄露。

  • 组件路径需与后端返回的component字段一致,避免组件加载失败。

  • 按钮权限标识需前后端统一,建议通过后端配置中心管理,便于维护。

九、上述代码及说明仅供参考

相关推荐
翱翔的苍鹰2 小时前
智谱(Zhipu)大模型的流式使用 response.iter_lines() 逐行解析 SSE 流
服务器·前端·数据库
未来之窗软件服务2 小时前
仙盟创梦IDE-集成开发测试:自动解析驱动的多线路自动化测试
前端·测试自动化·仙盟创梦ide·东方仙盟
天天睡大觉2 小时前
python命名规则(PEP8编码规则)
开发语言·前端·python
2501_944521592 小时前
Flutter for OpenHarmony 微动漫App实战:底部导航实现
android·开发语言·前端·javascript·redis·flutter·ecmascript
奔跑的web.2 小时前
npm install发生了什么?
前端·npm·node.js
zhengxianyi5152 小时前
npmjs切换淘宝镜像
前端·npm·npm安装源
一个处女座的程序猿O(∩_∩)O2 小时前
Next.js 文件系统路由深度解析:从原理到实践
开发语言·javascript·ecmascript
运筹vivo@2 小时前
BUUCTF: [SUCTF 2019]EasySQL
前端·web安全·php
holeer2 小时前
14步入门Vue|cn.vuejs.org教程学习笔记
前端·javascript·vue.js·笔记·前端框架·教程·入门