如何使用Vue3+Element-plus+Pinia优雅的实现菜单栏的动态展示(递归)
在后台管理系统中常见的布局通常都存在侧边菜单栏,不管是侧边菜单蓝或者是顶部菜单栏,都会存在多级菜单的可能,真多此情况,利用Vue3+Element-plus+Pinia实现菜单栏的动态实现
环境准备:
vue3: v3.3.4
element-plus: v2.3.12,
mockjs: v1.1.0,
pinia: v2.1.6,
vite: v4.4.5
typescript: v5.0.2
vite-plugin-mock: v2.9.6
vue-router: v4.2.4
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并使用TSimport { 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
文件tsimport 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配置
目的:
- 模拟数据: 可以使用
Mock.js
创建虚拟的 JSON 数据,包括字符串、数字、布尔值等,以模拟真实数据的结构和类型。- 拦截请求: 可以拦截前端发出的 Ajax 请求,并根据预定义的规则返回模拟数据,而不是实际向后端发送请求。这对于前端开发人员来说很有用,因为他们可以独立于后端进行开发和测试。
- 快速开发:
Mock.js
可以帮助前端开发人员快速创建原型和演示,无需依赖于后端数据,从而加快开发速度。- 模拟异常情况: 可以模拟服务器返回的异常情况,如错误的 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
文件存放仓库中所存在的字段TSimport type { RouteRecordRaw } from 'vue-router' // 小仓库的类型 export interface UserState { token: string | null // 存放用户可访问的菜单的路由配置 menuRoutes: RouteRecordRaw[] username: string avatar: string }
-
新建文件夹modules,创建
menu.ts
、user.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
TSexport 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
实现思路与代码:
- 将所有的一级和多级路由配置完成之后,把路由数组存入到pinia中的state实例当中
- 单独配置state中的menuRoutes存储配置进行类型设置
- 将数组constantRoute 存入到menuRoutes中
- 父组件
index.vue
将数据传入到封装好的menu.vue
中,父组件通过变量调用 useUserStore 函数,使用menuRoutes- 子组件
menu.vue
通过defineProps(['menuList'])
获取负组件中的数据- 子组件对所有路由配置信息进行循环并且条件处理
- 路由 == 1
- 直接展示
- 子路由 && 子路由 == 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>