Vue3 动态路由菜单及权限案例

Vue\vue3_admin\src\router\index.ts

// import { createRouter, createWebHistory, createWebHashHistory } from 'vue-router'
import { createRouter, createWebHashHistory, createWebHistory } from 'vue-router'
import { constantRoutes } from './routes'

const router = createRouter({
  // 路由器的工作模式  `history`模式   `hash`模式
  history: createWebHistory(),
  // history: createWebHashHistory(import.meta.env.BASE_URL),

  // 路由配置
  // routes: allRoutes,
  routes: constantRoutes,

  // 滚动行为
  scrollBehavior() {
    return {
      left: 0,
      top: 0,
    }
  }
})

export default router

Vue\vue3_admin\src\router\routes.ts

// 对外暴露配置路由 常量路由
export const constantRoutes = [
    {   // 首页
        path: '/',
        component: () => import('@/layout/index.vue'),
        name: 'Layout',
        redirect: '/home',
        meta: {
            title: '',  // 菜单标题
            isShow: false,
            icon: '', // 菜单文字左侧的图标,支持element-plus全部图标
        },
        children: [
            {
                path: '/home',
                name: 'Home',
                component: () => import('@/views/home/index.vue'),
                meta: {
                    title: '首页',  // 菜单标题
                    isShow: true,   // 代表路由标题在菜单中是否显示,true 显示  false 不显示
                    icon: 'HomeFilled', // 菜单文字左侧的图标,支持element-plus全部图标
                }
            },
        ]
    },
    // {   // 登录后显示的首页
    //     path: '/home',
    //     name: 'home',
    //     component: () => import('@/layout/index.vue')
    // },
    {   // 登录页
        path: '/login',
        name: 'Login',
        component: () => import('@/views/login/index.vue'),
        meta: {
            title: '登录',  // 菜单标题
            isShow: false,   // 代表路由标题在菜单中是否显示,true 显示  false 不显示
            icon: 'Promotion', // 菜单文字左侧的图标,支持element-plus全部图标
        },
    },
    {   // 404
        path: '/404',
        name: '404',
        component: () => import('@/views/404/index.vue'),
        meta: {
            title: '404',  // 菜单标题
            isShow: false,   // 代表路由标题在菜单中是否显示,true 显示  false 不显示
            icon: 'Failed',
        }
    },
    {   // 数据大屏
        path: '/screen',
        name: 'Screen',
        component: () => import('@/views/screen/index.vue'),
        meta: {
            title: '数据大屏',  // 菜单标题
            isShow: true,   // 代表路由标题在菜单中是否显示,true 显示  false 不显示
            icon: 'DataLine', // 菜单文字左侧的图标,支持element-plus全部图标
        }
    },
]

// 异步路由
export const asyncRoutes = [
    {   // 权限管理
        path: '/acl',
        component: () => import('@/layout/index.vue'),
        name: 'Acl',
        redirect: '/acl/user',
        meta: {
            title: '权限管理',  // 菜单标题
            isShow: true,
            icon: 'Lock', // 菜单文字左侧的图标,支持element-plus全部图标
        },
        children: [
            {
                path: '/acl/user',
                name: 'AclUser',
                component: () => import('@/views/acl/user/index.vue'),
                meta: {
                    title: '用户管理',  // 菜单标题
                    isShow: true,   // 代表路由标题在菜单中是否显示,true 显示  false 不显示
                    icon: 'User', // 菜单文字左侧的图标,支持element-plus全部图标
                }
            },
            {
                path: '/acl/role',
                name: 'AclRole',
                component: () => import('@/views/acl/role/index.vue'),
                meta: {
                    title: '角色管理',  // 菜单标题
                    isShow: true,   // 代表路由标题在菜单中是否显示,true 显示  false 不显示
                    icon: 'UserFilled', // 菜单文字左侧的图标,支持element-plus全部图标
                }
            },
            {
                path: '/acl/permission',
                name: 'AclPermission',
                component: () => import('@/views/acl/permission/index.vue'),
                meta: {
                    title: '菜单管理',
                    isShow: true,
                    icon: 'Management',
                }
            },
        ]
    },
    {   // 商品管理
        path: '/product',
        component: () => import('@/layout/index.vue'),
        name: 'Product',
        redirect: '/product/trademark',
        meta: {
            title: '商品管理',
            isShow: true,
            icon: 'Shop',
        },
        children: [
            {   // 品牌管理
                path: '/product/trademark',
                name: 'ProductTrademark',
                component: () => import('@/views/product/trademark/index.vue'),
                meta: {
                    title: '品牌管理',
                    isShow: true,
                    icon: 'ShoppingCart',
                }
            },
            {   // 属性管理
                path: '/product/attr',
                name: 'ProductAttr',
                component: () => import('@/views/product/attr/index.vue'),
                meta: {
                    title: '属性管理',
                    isShow: true,
                    icon: 'Goods',
                }
            },
            {   // spu管理 标准化产品单元
                path: '/product/spu',
                name: 'ProductSpu',
                component: () => import('@/views/product/spu/index.vue'),
                meta: {
                    title: 'SPU管理',
                    isShow: true,
                    icon: 'Present',
                }
            },
            {   // sku管理 库存量单位
                path: '/product/sku',
                name: 'ProductSku',
                component: () => import('@/views/product/sku/index.vue'),
                meta: {
                    title: 'SKU管理',
                    isShow: true,
                    icon: 'ShoppingCartFull',
                }
            },
        ]
    },
]

// 任意路由
export const anyRoutes = [
    {   // 任意未存在页面转404
        path: '/:pathMatch(.*)*',
        redirect: '/404',
        name: 'Any',
        meta: {
            title: '任意路由',  // 菜单标题
            isShow: false,   // 代表路由标题在菜单中是否显示,true 显示  false 不显示
            icon: 'Checked',
        }
    }
]

// 所有路由
export const allRoutes = [
    {   // 首页
        path: '/',
        component: () => import('@/layout/index.vue'),
        name: 'Layout',
        redirect: '/home',
        meta: {
            title: '',  // 菜单标题
            isShow: false,
            icon: '', // 菜单文字左侧的图标,支持element-plus全部图标
        },
        children: [
            {
                path: '/home',
                name: 'Home',
                component: () => import('@/views/home/index.vue'),
                meta: {
                    title: '首页',  // 菜单标题
                    isShow: true,   // 代表路由标题在菜单中是否显示,true 显示  false 不显示
                    icon: 'HomeFilled', // 菜单文字左侧的图标,支持element-plus全部图标
                }
            },
        ]
    },
    // {   // 登录后显示的首页
    //     path: '/home',
    //     name: 'home',
    //     component: () => import('@/layout/index.vue')
    // },
    {   // 登录页
        path: '/login',
        name: 'Login',
        component: () => import('@/views/login/index.vue'),
        meta: {
            title: '登录',  // 菜单标题
            isShow: false,   // 代表路由标题在菜单中是否显示,true 显示  false 不显示
            icon: 'Promotion', // 菜单文字左侧的图标,支持element-plus全部图标
        },
    },
    {   // 404
        path: '/404',
        name: '404',
        component: () => import('@/views/404/index.vue'),
        meta: {
            title: '404',  // 菜单标题
            isShow: false,   // 代表路由标题在菜单中是否显示,true 显示  false 不显示
            icon: 'Failed',
        }
    },
    {   // 数据大屏
        path: '/screen',
        name: 'Screen',
        component: () => import('@/views/screen/index.vue'),
        meta: {
            title: '数据大屏',  // 菜单标题
            isShow: true,   // 代表路由标题在菜单中是否显示,true 显示  false 不显示
            icon: 'DataLine', // 菜单文字左侧的图标,支持element-plus全部图标
        }
    },
    {   // 权限管理
        path: '/acl',
        component: () => import('@/layout/index.vue'),
        name: 'Acl',
        redirect: '/acl/user',
        meta: {
            title: '权限管理',  // 菜单标题
            isShow: true,
            icon: 'Lock', // 菜单文字左侧的图标,支持element-plus全部图标
        },
        children: [
            {
                path: '/acl/user',
                name: 'AclUser',
                component: () => import('@/views/acl/user/index.vue'),
                meta: {
                    title: '用户管理',  // 菜单标题
                    isShow: true,   // 代表路由标题在菜单中是否显示,true 显示  false 不显示
                    icon: 'User', // 菜单文字左侧的图标,支持element-plus全部图标
                }
            },
            {
                path: '/acl/role',
                name: 'AclRole',
                component: () => import('@/views/acl/role/index.vue'),
                meta: {
                    title: '角色管理',  // 菜单标题
                    isShow: true,   // 代表路由标题在菜单中是否显示,true 显示  false 不显示
                    icon: 'UserFilled', // 菜单文字左侧的图标,支持element-plus全部图标
                }
            },
            {
                path: '/acl/permission',
                name: 'AclPermission',
                component: () => import('@/views/acl/permission/index.vue'),
                meta: {
                    title: '菜单管理',
                    isShow: true,
                    icon: 'Management',
                }
            },
        ]
    },
    {   // 商品管理
        path: '/product',
        component: () => import('@/layout/index.vue'),
        name: 'Product',
        redirect: '/product/trademark',
        meta: {
            title: '商品管理',
            isShow: true,
            icon: 'Shop',
        },
        children: [
            {   // 品牌管理
                path: '/product/trademark',
                name: 'ProductTrademark',
                component: () => import('@/views/product/trademark/index.vue'),
                meta: {
                    title: '品牌管理',
                    isShow: true,
                    icon: 'ShoppingCart',
                }
            },
            {   // 属性管理
                path: '/product/attr',
                name: 'ProductAttr',
                component: () => import('@/views/product/attr/index.vue'),
                meta: {
                    title: '属性管理',
                    isShow: true,
                    icon: 'Goods',
                }
            },
            {   // spu管理 标准化产品单元
                path: '/product/spu',
                name: 'ProductSpu',
                component: () => import('@/views/product/spu/index.vue'),
                meta: {
                    title: 'SPU管理',
                    isShow: true,
                    icon: 'Present',
                }
            },
            {   // sku管理 库存量单位
                path: '/product/sku',
                name: 'ProductSku',
                component: () => import('@/views/product/sku/index.vue'),
                meta: {
                    title: 'SKU管理',
                    isShow: true,
                    icon: 'ShoppingCartFull',
                }
            },
        ]
    },
    {   // 任意未存在页面转404
        path: '/:pathMatch(.*)*',
        redirect: '/404',
        name: 'Any',
        meta: {
            title: '任意路由',  // 菜单标题
            isShow: false,   // 代表路由标题在菜单中是否显示,true 显示  false 不显示
            icon: 'Checked',
        }
    },
]

Vue\vue3_admin\src\layout\menu\index.vue

<template>
  <!-- <h1>{{ menuList }}</h1> -->
  <template v-for="(item) in menuList" :key="item.path">
    <!-- 没有子路由 -->
    <template v-if="!item.children">
      <el-menu-item v-if="item.meta.isShow" :index="item.path" @click="goRoute"
        :class="{ fold: layOutSettingStore.fold ? true : false }">
        <el-icon>
          <component :is="item.meta.icon"></component>
        </el-icon>
        <template #title>
          <span>{{ item.meta.title }}</span>
        </template>
      </el-menu-item>
    </template>
    <!-- 有子路由且只有一个子路由 -->
    <template v-if="item.children && item.children.length == 1">
      <el-menu-item v-if="item.children[0].meta.isShow" :index="item.children[0].path" @click="goRoute"
        :class="{ fold: layOutSettingStore.fold ? true : false }">
        <el-icon>
          <component :is="item.children[0].meta.icon"></component>
        </el-icon>
        <template #title>
          <span>{{ item.children[0].meta.title }}</span>
        </template>
      </el-menu-item>
    </template>
    <!-- 有子路由且有多个子路由 -->
    <el-sub-menu v-if="item.children && item.children.length > 1" :index="item.path"
      :class="{ fold: layOutSettingStore.fold ? true : false }">
      <template #title>
        <el-icon>
          <component :is="item.meta.icon"></component>
        </el-icon>
        <span>{{ item.meta.title }}</span>
      </template>
      <LayoutMenu :menuList="item.children"></LayoutMenu>
    </el-sub-menu>
  </template>

</template>

<script lang="ts" setup name="LayoutMenu">
import useLayOutSettingStore from '@/stores/modules/setting'
import { useRouter } from 'vue-router';
// 获取父组件传递过来的全部路由数组
defineProps(['menuList']);
// 获取路由器对象
let $router = useRouter();
// 点击菜单的回调
const goRoute = (vc: any) => {
  // console.log(vc.index);
  // console.log($router);
  $router.push(vc.index)
}
// 获取layout配置仓库
let layOutSettingStore = useLayOutSettingStore()
</script>

<!-- 暴露一下自己 -->
<script lang="ts">
export default {
  name: 'LayoutMenu'
}
</script>

Vue\vue3_admin\src\stores\modules\user.ts

// 引入登录接口
import { reqLogin, reqLogout, reqUserInfo } from '@/api/user';

// 引入数据类型
import type { loginFormData, loginResponseData, responseData, tokenType } from '@/api/user/type';
import type { userState } from './types/types'

// 引入操作本地存储工具方法
import { SET_TOKEN, GET_TOKEN, REMOVE_TOKEN, GET_USERNAME, GET_AVATAR, SET_USERNAME, SET_AVATAR, REMOVE_USERINFO } from '@/utils/token';

// 引入大仓库
import { defineStore } from 'pinia';

// 引入路由(常量路由)
import { constantRoutes, asyncRoutes, anyRoutes } from '@/router/routes';

// 引入深拷贝方法
import cloneDeep from 'lodash/cloneDeep'

// 引入路由器
import router from '@/router';

// 用于过滤当前用户需要展示的异步路由
function filterAsyncRoute(asyncRoutes: any, routes: any) {
    return asyncRoutes.filter((item: any) => {
        if (routes.includes(item.name)) {
            if (item.children && item.children.length > 0) {
                // 切换账号时路由数据不匹配问题
                item.children = filterAsyncRoute(item.children, routes)
            }
            return true
        }
    })
}

// 创建用户相关的小仓库
const useUserStore = defineStore('User', {
    // 仓库存储数据的地方
    state: (): userState => {
        return {
            // token: localStorage.getItem("TOKEN") || '',   // 用户的唯一标识token
            token: GET_TOKEN() as string,
            menuRoutes: JSON.parse(localStorage.getItem('menuRoutes') as any),    // 仓库存储生成菜单需要的数组(路由)
            username: GET_USERNAME() as string,
            avatar: GET_AVATAR() as string,
            nickName: '',
            // userAsyncRoute: JSON.parse(localStorage.getItem('userAsyncRoute') as any)
            userAsyncRoute: [],
            userButtons: []
        }
    },

    // 异步|逻辑的地方
    actions: {
        // 用户登录的方法
        async userLogin(data: loginFormData) {
            // console.log(data);
            const request: loginResponseData = await reqLogin(data);

            // console.log('登录',request);

            // 登录请求成功 200 --> token
            if (request.code == 200) {
                // pinia仓库存储token
                // 由于pinia|vuex存储数据其实是利用js对象,非持久化存储
                // console.log(request.data);

                this.token = (request.data.token as string);
                this.username = (request.data.username as string);
                this.nickName = (request.data.nickName as string);
                this.avatar = (request.data.avatar as string);
                // 本地存储持久化
                // localStorage.setItem("TOKEN", (request.data.token as string))
                SET_TOKEN((request.data.token as string))
                SET_USERNAME((request.data.username as string))
                SET_AVATAR((request.data.avatar as string))

                return 'ok'   // 能保证当前async函数能返回一个成功的promise
            } else {
                // 登录请求失败 201 --> 登录失败错误的信息
                return Promise.reject(new Error(request.data.message))
            }
        },
        // 获取用户信息的方法
        async userInfo() {
            // 获取用户的信息进行存储在仓库中[用户头像,用户名]
            const result = await reqUserInfo();
            // console.log(result);
            if (result.code == 200) {
                this.username = (result.data.user_info.username as string);
                this.nickName = (result.data.user_info.nickName as string);
                this.avatar = (result.data.user_info.avatar as string);

                // 计算当前用户需要展示的异步路由,使用深拷贝
                // const userAsyncRoute = filterAsyncRoute(asyncRoutes, result.data.user_info.routes)
                const userAsyncRoute = filterAsyncRoute(cloneDeep(asyncRoutes), result.data.user_info.routes)
                // console.log(userAsyncRoute);
                // 存储用户动态路由
                localStorage.setItem('userAsyncRoute', JSON.stringify(userAsyncRoute));
                this.userAsyncRoute = userAsyncRoute
                // 菜单的路由数据整理
                this.menuRoutes = [...constantRoutes, ...userAsyncRoute, ...anyRoutes]
                localStorage.setItem('menuRoutes', JSON.stringify(this.menuRoutes));

                // 用户按钮权限
                this.userButtons = result.data.user_info.buttons as string[]
                localStorage.setItem('userButtons', JSON.stringify(this.userButtons));
                // 目前路由器管理的只有常量路由:用户计算完毕异步路由、任意路由动态追加
                // console.log(router.getRoutes());
                userAsyncRoute.forEach((route: any) => {
                    router.addRoute(route)
                });
                anyRoutes.forEach((route: any) => {
                    router.addRoute(route)
                });

                // console.log(router.getRoutes());

                // 持久化
                SET_USERNAME((result.data.user_info.username as string))
                SET_AVATAR((result.data.user_info.avatar as string))
                // console.log("用户名",this.username,"用户头像",this.avatar);

                return '获取用户信息成功!'
            } else {
                return Promise.reject('获取用户信息失败!')
            }
        },
        // 用户退出登录的方法
        async userLogout(token: tokenType) {
            const request: responseData = await reqLogout(token);
            if (request.code == 200) {
                // 清理本地token
                REMOVE_TOKEN();
                REMOVE_USERINFO();
                this.token = '';
                this.username = '';
                this.nickName = '';
                this.avatar = '';
                // 清空当前动态路由
                // localStorage.removeItem('menuRoutes');
                // console.log(this.userAsyncRoute);

                // this.userAsyncRoute.forEach((route: any) => {
                //     router.removeRoute(route)
                // });
                this.userAsyncRoute = []
                this.menuRoutes = [...constantRoutes, ...anyRoutes]
                localStorage.setItem('menuRoutes', JSON.stringify(this.menuRoutes));
                // this.menuRoutes = [];
                // console.log("当前Token:",GET_TOKEN());
                // console.log("用户登出,清理成功");
                // 刷新页面数据,防止数据残留
                window.location.reload();
                return '用户已登出'
            } else {
                // 登出失败错误的信息
                console.log("用户登出错误");
                // 清理本地token
                REMOVE_TOKEN();
                REMOVE_USERINFO();
                this.token = '';
                this.username = '';
                this.avatar = '';
                // 清空当前动态路由及按钮权限
                localStorage.removeItem('menuRoutes');
                localStorage.removeItem('userAsyncRoute');
                this.userAsyncRoute = []
                this.userButtons = []
                router.replace({ path: '/login' });
                return Promise.reject(new Error(request.data.message))
            }
        }
    },
    getters: {

    },
    // 开户持久化 默认存储到 sessionStorage
    persist: true,
})

// 对外暴露获取小仓库的方法
export default useUserStore

Vue\vue3_admin\src\directive\has.ts

// 自定义指令控制按钮权限
import pinia from '@/stores'
import useUserStore from '@/stores/modules/user';
const userStore = useUserStore(pinia)
export const isHasButton = (app: any) => {
  //获取对应的用户仓库
  //全局自定义指令:实现按钮的权限
  app.directive('has', {
    //代表使用这个全局自定义指令的DOM|组件挂载完毕的时候会执行一次
    mounted(el: any, options: any) {
      //自定义指令右侧的数值:如果在用户信息buttons数组当中没有
      //从DOM树上干掉或禁用
      if (userStore.userButtons.includes('btn.all')) {
        // el.parentNode.removeChild(el)
        // console.log(el);

        el.disabled = false
        // el.parentNode.style.opacity = 0.3

      } else if (userStore.userButtons.includes(options.value)) {
        // el.parentNode.removeChild(el)
        el.disabled = false
      } else {
        // el.parentNode.removeChild(el)
        // el.disabled = true
        el.disabled = true
        el.parentNode.style.opacity = 0.3
      }
    },
  })
}