【动态菜单栏】Vue3+Element-plus+Pinia优雅的实现动态菜单栏(递归)

如何使用Vue3+Element-plus+Pinia优雅的实现菜单栏的动态展示(递归)

在后台管理系统中常见的布局通常都存在侧边菜单栏,不管是侧边菜单蓝或者是顶部菜单栏,都会存在多级菜单的可能,真多此情况,利用Vue3+Element-plus+Pinia实现菜单栏的动态实现

环境准备:

  1. vue3: v3.3.4

  2. element-plus: v2.3.12,

  3. mockjs: v1.1.0,

  4. pinia: v2.1.6,

  5. vite: v4.4.5

  6. typescript: v5.0.2

  7. vite-plugin-mock: v2.9.6

  8. vue-router: v4.2.4

  9. axios: v1.5.0

创建项目

pnpm create vite@latest

选择typescipt语言

安装Element-plus、pinia、mockjs、vite-plugin-mock、vue-router

Element-plus配置

SH 复制代码
# 安装Element-plus
pnpm i element-plus -d
  • 在项目中的main.ts引入element-plus并使用

    TS 复制代码
    import { createApp } from 'vue'
    import App from '@/App.vue'
    //引入模板的全局的样式
    import '@/styles/index.scss'
    // 插件引入
    import ElementPlus from 'element-plus'
    // 样式引入
    import 'element-plus/dist/index.css'
    // 国际化配置
    // @ts-expect-error
    import zhCn from 'element-plus/dist/locale/zh-cn.mjs'
    
    const app = createApp(App)
    app.use(ElementPlus, {
      locale: zhCn, // element-plus国际化
    })
    app.mount('#app')
  • 根据语法<el-xxx>进行使用

Axios配置(axios的二次封装)

sh 复制代码
# 安装axios
pnpm i axios
  • src/utils下创建request.ts文件

    ts 复制代码
    import axios from 'axios'
    import { ElMessage } from 'element-plus'
    
    // 对axios进行二次封装
    const requests = axios.create({
      // 参考上一篇文章中的环境变量配置
      baseURL: import.meta.env.VITE_APP_BASE_API,
      timeout: 5000,
    })
    
    // 添加请求拦截器
    requests.interceptors.request.use(
      (config) => {
        // 成功之后做些什么
        return config
      },
      (error) => {
        // 对请求错误做些什么
        return Promise.reject(error)
      },
    )
    
    // 添加响应拦截器
    requests.interceptors.response.use(
      function (response) {
        // 2xx 范围内的状态码都会触发该函数。
        // 对响应数据做点什么
        //config配置对象,headers属性请求头,经常给服务器端携带公共参数
        //返回配置对象
        return response.data
      },
      function (error) {
        // 超出 2xx 范围的状态码都会触发该函数。
        // 对响应错误做点什么
        let message = ''
        const status = error.response.status
        switch (status) {
          case 401:
            message = 'token过期'
            break
          case 403:
            message = '无权访问'
            break
          case 404:
            message = '请求地址错误'
            break
          case 500:
            message = '服务器出现问题'
            break
          default:
            message = '无网络'
        }
        ElMessage({
          type: 'error',
          message: message,
        })
        return Promise.reject(error)
      },
    )
    
    export default requests

Mock配置

目的:

  1. 模拟数据: 可以使用 Mock.js 创建虚拟的 JSON 数据,包括字符串、数字、布尔值等,以模拟真实数据的结构和类型。
  2. 拦截请求: 可以拦截前端发出的 Ajax 请求,并根据预定义的规则返回模拟数据,而不是实际向后端发送请求。这对于前端开发人员来说很有用,因为他们可以独立于后端进行开发和测试。
  3. 快速开发: Mock.js 可以帮助前端开发人员快速创建原型和演示,无需依赖于后端数据,从而加快开发速度。
  4. 模拟异常情况: 可以模拟服务器返回的异常情况,如错误的 HTTP 状态码、超时等,以确保前端代码在面对这些情况时能够正常处理。
sh 复制代码
# mock安装
pnpm i mockjs vite-plugin-mock -d
  • 在项目根目录下创建mock文件夹,并创建user.ts

    ts 复制代码
    //createUserList:次函数执行会返回一个数组,数组里面包含两个用户信息
    function createUserList() {
      return [
        {
          // 用户id
          userId: 1,
          // 头像
          avatar:
            'https://wpimg.wallstcn.com/f778738c-e4f8-4870-b634-56703b4acafe.gif',
          // 用户名
          username: 'admin',
          // 密码
          password: '111111',
          // 描述
          desc: '平台管理员',
          // 角色
          roles: ['平台管理员'],
          buttons: ['cuser.detail'],
          // 路由
          routes: ['home'],
          // token
          token: 'Admin Token',
        },
        {
          userId: 2,
          avatar:
            'https://wpimg.wallstcn.com/f778738c-e4f8-4870-b634-56703b4acafe.gif',
          username: 'system',
          password: '111111',
          desc: '系统管理员',
          roles: ['系统管理员'],
          buttons: ['cuser.detail', 'cuser.user'],
          routes: ['home'],
          token: 'System Token',
        },
      ]
    }
    
    // 对外暴露一个数组:数组里面包含两个接口
    // 登录假的接口
    // 获取用户信息的假的接口
    export default [
      // 用户登录接口
      {
        url: '/api/user/login', //请求地址
        method: 'post', //请求方式
        response: ({ body }) => {
          // 获取请求体携带过来的用户名与密码
          const { username, password } = body
          // 调用获取用户信息函数,用于判断是否有此用户
          const checkUser = createUserList().find(
            (item) => item.username === username && item.password === password,
          )
          //没有用户返回失败信息
          if (!checkUser) {
            return { code: 201, data: { message: '账号或者密码不正确' } }
          }
          //如果有返回成功信息
          const { token } = checkUser
          return { code: 200, data: { token } }
        },
      },
      // 获取用户信息
      {
        url: '/api/user/info',
        method: 'get',
        response: (request: { headers: { token: any } }) => {
          //获取请求头携带token
          const token = request.headers.token
          //查看用户信息是否包含有次token用户
          const checkUser = createUserList().find((item) => item.token === token)
          //没有返回失败的信息
          if (!checkUser) {
            return { code: 201, data: { message: '获取用户信息失败' } }
          }
          //如果有返回成功信息
          return { code: 200, data: { checkUser } }
        },
      },
    ]
  • 已经存在配置类型

    TS 复制代码
    // 定义用户相关数据的ts类型
    // 用户登录接口携带参数的ts类型
    export interface loginFormData {
      username: string
      password: string
    }
    
    interface dataType {
      token?: string
      message?: string
    }
    
    // 登录接口返回的数据
    export interface loginResponseData {
      code: number
      data: dataType
    }
    
    interface userInfo {
      userId: number
      avatar: string
      username: string
      password: string
      desc: string
      roles: []
      buttons: []
      routes: []
      token: string
    }
    
    interface user {
      checkUser: userInfo
    }
    
    // 定义服务器返回的用户信息
    export interface userReponseData {
      code: number
      data: user
    }

Pinia配置

sh 复制代码
# 安装pinia
pnpm i pinia -d
  • 在目录:src/store/下创建index.ts文件,并进行一下配置

    ts 复制代码
    // src/store/index.ts
    // 创建仓库
    import { createPinia } from 'pinia'
    
    const pinia = createPinia()
    
    // 对外暴露
    export default pinia
  • type.ts文件存放仓库中所存在的字段

    TS 复制代码
    import type { RouteRecordRaw } from 'vue-router'
    // 小仓库的类型
    export interface UserState {
      token: string | null
      // 存放用户可访问的菜单的路由配置
      menuRoutes: RouteRecordRaw[]
      username: string
      avatar: string
    }
  • 新建文件夹modules,创建menu.tsuser.ts文件

    TS 复制代码
    /**
    * menu.ts:传入是否可折叠数据
    */
    
    // 从pinia从导入对应函数
    import { defineStore } from 'pinia'
    
    // 作为菜单栏折叠显示数据变化使用
    /**
    * ToggleCollapse:自定义仓库名称
    * useToggleCollapse:store实例
    */
    let useToggleCollapse = defineStore('ToggleCollapse', {
      state: () => {
        return {
          // 是否折叠
          isStoreCollapse: false,
        }
      },
    })
    
    export default useToggleCollapse
    ts 复制代码
    /**
    * user.ts:菜单信息
    */
    
    // 创建用户
    import { defineStore } from 'pinia'
    // 引入数类型
    import type { UserState } from './types/type'
    // 引入路由
    import { constantRoute } from '@/router/routes'
    
    // useStore 可以是 useUser、useCart 之类的任何东西
    // 第一个参数是应用程序中 store 的唯一 id
    const useUserStore = defineStore('User', {
      // 小仓库
      state: (): UserState => {
        return {
          // 仓库存储菜单的类型:[]
          // menuRoutes:用于存储用户可访问的菜单路由配置
          // constantRoute: 所有路由的配置信息
          menuRoutes: constantRoute,
        }
      },
        getters: {},
      },
    })
    
    export default useUserStore

vue-router配置

sh 复制代码
# 安装vue-router
pnpm i vue-router -d 
  • routes.ts

    TS 复制代码
    export const constantRoute = [
      // 登录
      {
        path: '/login',
        name: 'Login',
        component: () => import('@/views/login/login.vue'),
        // 用于展示惨菜单标题
        meta: {
          title: '登录',
          show: false, // 路由隐藏
          icon: 'Promotion',
        },
      },
      // 一级路由layout
      {
        path: '/',
        component: () => import('@/layout/index.vue'),
        name: 'layout',
        meta: {
          title: 'layout',
          show: true,
          icon: 'House',
        },
        redirect: '/home',
        children: [
          // 首页路由
          {
            path: '/home',
            component: () => import('@/views/home/home.vue'),
            name: 'Home',
            meta: {
              title: '首页',
              show: true,
              icon: 'House',
            },
          },
        ],
      },
      // 数据大屏
      {
        path: '/screen',
        component: () => import('@/views/screen/screen.vue'),
        name: 'Screen',
        meta: {
          show: true,
          title: '数据大屏',
          icon: 'Platform',
        },
      },
      // 权限管理
      {
        path: '/acl',
        name: 'Acl',
        component: () => import('@/layout/index.vue'),
        meta: {
          title: '权限管理',
          show: true,
          icon: 'Lock',
        },
        redirect: '/acl/user',
        children: [
          {
            path: '/acl/user',
            name: 'Acl',
            component: () => import('@/views/acl/user/user.vue'),
            meta: {
              title: '用户管理',
              show: true,
              icon: 'User',
            },
          },
          {
            path: '/acl/role',
            component: () => import('@/views/acl/role/role.vue'),
            name: 'Role',
            meta: {
              title: '角色管理',
              show: true,
              icon: 'UserFilled',
            },
          },
          {
            path: '/acl/permission',
            component: () => import('@/views/acl/permission/permission.vue'),
            name: 'Permission',
            meta: {
              title: '菜单管理',
              show: true,
              icon: 'Files',
            },
          },
        ],
      },
      // 商品管理
      {
        path: '/product',
        name: 'Product',
        component: () => import('@/layout/index.vue'),
        meta: {
          title: '商品管理',
          show: true,
          icon: 'Goods',
        },
        redirect: '/product/trademark',
        children: [
          {
            path: '/product/trademark',
            name: 'Trademark',
            component: () => import('@/views/product/trademark/trademark.vue'),
            meta: {
              title: '品牌管理',
              show: true,
              icon: 'Cherry',
            },
          },
          {
            path: '/product/attribute',
            name: 'Attribute',
            component: () => import('@/views/product/attribute/attribute.vue'),
            meta: {
              title: '属性管理',
              show: true,
              icon: 'ChromeFilled',
            },
          },
          {
            path: '/product/sku',
            component: () => import('@/views/product/sku/sku.vue'),
            name: 'Sku',
            meta: {
              title: 'SKU管理',
              show: true,
              icon: 'UserFilled',
            },
          },
          {
            path: '/product/spu',
            name: 'Spu',
            component: () => import('@/views/product/spu/spu.vue'),
            meta: {
              title: 'Spu管理',
              show: true,
              icon: 'Files',
            },
          },
        ],
      },
      // 404
      {
        path: '/404',
        name: '404',
        component: () => import('@/views/404/404.vue'),
        meta: {
          title: '404',
          show: false,
          icon: 'DocumentDelete',
        },
      },
      // 任意路由:所有都不匹配就匹配当前路由
      {
        path: '/:pathMatch(.*)*',
        redirect: '/404',
        name: 'Any',
        meta: {
          title: '任意',
          show: false,
          icon: 'DataLine',
        },
      },
    ]
  • router/index.ts

    TS 复制代码
    // 通过vue-router实现模板路由配置
    import { createRouter, createWebHashHistory } from 'vue-router'
    import { constantRoute } from './routes'
    
    // 创建路由器
    const router = createRouter({
      // 路由模式
      history: createWebHashHistory(),
      routes: constantRoute,
      // 滚动行为
      scrollBehavior(to, from, savedPosition) {
        // return 期望滚动到哪个的位置
      },
    })
    
    export default router

实现思路与代码:

  1. 将所有的一级和多级路由配置完成之后,把路由数组存入到pinia中的state实例当中
  2. 单独配置state中的menuRoutes存储配置进行类型设置
  3. 将数组constantRoute 存入到menuRoutes
  4. 父组件index.vue将数据传入到封装好的menu.vue中,父组件通过变量调用 useUserStore 函数,使用menuRoutes
  5. 子组件menu.vue通过defineProps(['menuList'])获取负组件中的数据
  6. 子组件对所有路由配置信息进行循环并且条件处理
    1. 路由 == 1
      1. 直接展示
    2. 子路由 && 子路由 == 1
      1. 展示完所有的一级路由再直接把一个子路由展示
    3. 子路由 && 子路由 > 1(递归)
      1. 展示完所有的一级路由,再将子路由作为对象数组继续传入到子组件继续循环(自己传给自己)

父组件:index.vue

html 复制代码
<template>
 <!-- 展示菜单 -->
      <el-scrollbar class="scrollbarHeight">
        <!-- 获取存储在仓库中的菜单数据,数据是从路由中获取 -->
        <el-menu
          background-color="#001529"
          text-color="white"
          active-text-color="aqua"
          :default-active="$route.path"
          :collapse="toggleCollapse.isStoreCollapse"
          :collapse-transition="true"
        >
          <Menu :menuList="userStore.menuRoutes" />
        </el-menu>
      </el-scrollbar>
    </div>
</template>

<script setup lang="ts">
import Menu from './menu/menu.vue'
import Tabbar from './tabbar/tabbar.vue'
// 获取用户相关小仓库
import useUserStore from '@/store/modules/users'
// 引用路由
import { useRoute } from 'vue-router'
    
// 使用仓库
let userStore = useUserStore()
// 获取路由对象
let $route = useRoute()

</script>

子组件:menu.vue

html 复制代码
<template>
  <template v-for="(item, index) in menuList" :key="item.path">
    <!-- 没有子路由 -->
    <template v-if="!item.children">
      <el-menu-item :index="item.path" v-if="item.meta.show" @click="goRoute">
        <el-icon>
          <component :is="item.meta.icon"></component>
        </el-icon>
        <template #title>
          <span>{{ item.meta.title }}</span>
        </template>
      </el-menu-item>
    </template>
    <!-- 子组件有1个 -->
    <template v-if="item.children && item.children.length == 1">
      <el-menu-item
        :index="item.children[0].path"
        v-if="item.children[0].meta.show"
        @click="goRoute"
      >
        <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
      :index="item.path"
      v-if="item.children && item.children.length > 1"
    >
      <template #title>
        <el-icon>
          <component :is="item.meta.icon"></component>
        </el-icon>
        <span>{{ item.meta.title }}</span>
      </template>
      <!-- 递归展示子路由 -->
      <Menu :menuList="item.children"></Menu>
    </el-sub-menu>
  </template>
</template>

<script setup lang="ts">
// 引用路由
import { useRouter } from 'vue-router'
// 获取父组件中所有的路由数据
defineProps(['menuList'])

let $router = useRouter()
const goRoute = (vc: any) => {
  $router.push(vc.index)
}
</script>

<script lang="ts">
export default {
  name: 'Menu',
}
</script>

实际效果

图解

相关推荐
学习使我快乐013 小时前
JS进阶 3——深入面向对象、原型
开发语言·前端·javascript
bobostudio19953 小时前
TypeScript 设计模式之【策略模式】
前端·javascript·设计模式·typescript·策略模式
黄尚圈圈4 小时前
Vue 中引入 ECharts 的详细步骤与示例
前端·vue.js·echarts
浮华似水5 小时前
简洁之道 - React Hook Form
前端
正小安7 小时前
如何在微信小程序中实现分包加载和预下载
前端·微信小程序·小程序
_.Switch9 小时前
Python Web 应用中的 API 网关集成与优化
开发语言·前端·后端·python·架构·log4j
一路向前的月光9 小时前
Vue2中的监听和计算属性的区别
前端·javascript·vue.js
长路 ㅤ   9 小时前
vite学习教程06、vite.config.js配置
前端·vite配置·端口设置·本地开发
长路 ㅤ   9 小时前
vue-live2d看板娘集成方案设计使用教程
前端·javascript·vue.js·live2d