菜单组件
说明:实现一个可配置的菜单组件。
实现效果:
1.类型文件type.d.ts、手动生成菜单项索引文件useMenu.ts
(1)类型文件type.d.ts
import type { IconifyIcon } from '@iconify/vue'
/**
* 定义一个泛型类型 Components,默认泛型参数 T 为 any 类型
* 这个类型通常用于表示组件的类型定义
* import('*.vue') 表示动态导入一个 Vue 组件,须配置shims.d.ts
*/
export type Components<T = unknown> =
| ReturnType<typeof defineComponent>
| (() => Promise<T>)
| (() => Promise<typeof import('*.vue')>)
// 菜单项路由元信息的类型结构
// Record<string | undefined | number | symbol> 用于构造一个对象类型,
// 对象的键可以是字符串、数字或符号,值可以是任意类型
export interface RouteMeta extends Record<string | number | symbol, unknown> {
title?: string
layout?: string
order?: number
icon?: string | IconifyIcon
hideMenu?: boolean
disabled?: boolean
}
// 根据路由属性设置interface
export interface AppRouteMenuItem {
name?: string
path: string
meta?: RouteMeta
alias?: string
children?: AppRouteMenuItem[]
component?: Components
}
(2)useMenu.ts
import type { AppRouteMenuItem } from './type'
/**
* 1.创建一个useMenu组合式函数,用于手动递归生成菜单索引index(或key)
* 2.根据菜单数据获取菜单索引
* 3.根据菜单数据判断是否有子菜单
*/
export function useMenu() {
// 根据菜单数据生成菜单索引
function genaratorMenuIndex(menus: AppRouteMenuItem[], level = '0') {
// 过滤出未隐藏的菜单项
const filterMenus = menus.filter((item) => !item.meta?.hideMenu)
let i = 0
filterMenus.forEach((item) => {
// 判断等级level是否包含'-',如果包含则将level与i拼接,否则直接将i赋值给level
const index = level.indexOf('-') !== -1 ? `${level}${i}` : `${i}`
item.meta = {
...item.meta,
index
}
// 如果菜单项有子菜单,则递归调用genaratorMenuIndex函数
if (item.children && item.children?.length > 0) {
item.children = genaratorMenuIndex(item.children, `${index}-`)
}
i++
})
return filterMenus
}
// 获取菜单索引
function getMenuIndex(item: AppRouteMenuItem): string {
return `${item.meta?.index}`
}
// 判断是否有子菜单
function menuHasChildren(item: AppRouteMenuItem): boolean {
return !item.meta?.hideMenu && Array.isArray(item.children) && item.children?.length > 0
}
return {
genaratorMenuIndex,
getMenuIndex,
menuHasChildren
}
}
2.主菜单Menu.vue、二级菜单SubMenu.vue、菜单项组件MenuItem.vue
(1)主菜单Menu.vue
<template>
<el-menu v-bind="menuProps">
<slot name="icon"></slot>
<!-- 左右logo+菜单的场景, 如果有icon内容就添加元素 -->
<div class="grow" v-if="isDefined(slots['icon'])"></div>
<sub-menu
v-for="menu in filterMenus"
:key="menu.path"
:data="menu"
:collapse="collapse"
v-bind="subMenuProps"
></sub-menu>
</el-menu>
</template>
<script setup lang="ts">
import type { MenuProps as ElMenuProps, SubMenuProps } from 'element-plus'
import SubMenu from './SubMenu.vue'
import type { AppRouteMenuItem } from './type'
import { useMenu } from './useMenu'
import { isDefined } from '@vueuse/core'
interface MenuProps extends Partial<ElMenuProps> {
data?: AppRouteMenuItem[]
subMenuProps?: Partial<SubMenuProps>
}
const props = withDefaults(defineProps<MenuProps>(), {
data: () => []
})
// 生成菜单索引
const { genaratorMenuIndex } = useMenu()
const filterMenus = computed(() => genaratorMenuIndex(props.data))
// 获取传递的slot
const slots = useSlots()
// 过滤出menu的props数据
const menuProps = computed(() => {
const { data, subMenuProps, ...rest } = props
return rest
})
</script>
<style scoped></style>
(2)二级菜单SubMenu.vue
<template>
<menu-item v-if="!menuHasChildren(data)" :data="data"></menu-item>
<!-- 存在多级菜单的场景 -->
<el-sub-menu :index="getMenuIndex(data)" v-if="menuHasChildren(data)">
<template #title v-if="!data.meta?.icon">{{ data.meta?.title }}</template>
<!-- 折叠或侧栏场景 -->
<template #title v-else>
<Icon :icon="data.meta?.icon" />
<span>{{ data.meta?.title }}</span>
</template>
<!-- 二级菜单 -->
<sub-menu
v-for="child in data.children"
:key="`${data.path}/${child.path}`"
:data="child"
v-bind="subAttrs"
></sub-menu>
</el-sub-menu>
</template>
<script setup lang="ts">
import type { SubMenuProps as ElSubMenuProps } from 'element-plus'
import type { AppRouteMenuItem } from './type'
import { Icon } from '@iconify/vue'
import { useMenu } from './useMenu'
interface SubMenuProps extends Partial<ElSubMenuProps> {
data: AppRouteMenuItem
collapse?: boolean
}
const props = defineProps<SubMenuProps>()
// 过滤出二级菜单需要的属性
const subAttrs = computed(() => {
const { data, ...restAttr } = props
return restAttr
})
// 获取菜单索引和判断菜单是否有子菜单
const { getMenuIndex, menuHasChildren } = useMenu()
</script>
<style scoped></style>
(3)菜单项组件MenuItem.vue
<template>
<!-- 一级菜单 -->
<el-menu-item
:index="getMenuIndex(data)"
:disabled="data.meta?.disabled"
v-if="!data.meta?.icon"
>{{ data.meta?.title }}</el-menu-item
>
<template v-else>
<!-- 折叠场景 -->
<el-menu-item v-if="collapse" :index="getMenuIndex(data)" :disabled="data.meta?.disabled">
<Icon :icon="data.meta.icon" />
<template #title>{{ data.meta?.title }}</template>
</el-menu-item>
<!-- 侧栏场景 -->
<el-menu-item v-else :index="getMenuIndex(data)" :disabled="data.meta?.disabled">
<Icon :icon="data.meta.icon" />
<span>{{ data.meta?.title }}</span>
</el-menu-item>
</template>
</template>
<script setup lang="ts">
import type { SubMenuProps as ElSubMenuProps } from 'element-plus'
import type { AppRouteMenuItem } from './type'
import { useMenu } from './useMenu'
import { Icon } from '@iconify/vue'
interface SubMenuProps extends Partial<ElSubMenuProps> {
data: AppRouteMenuItem
collapse?: boolean
}
const props = defineProps<SubMenuProps>()
// 获取菜单索引
const { getMenuIndex } = useMenu()
</script>
<style scoped></style>
3.demo实现
<template>
<Menu mode="vertical" :data="data"> </Menu>
</template>
<script setup lang="ts">
import type { AppRouteMenuItem } from './components/menu/type'
const data: AppRouteMenuItem[] = [
{
name: 'dashboard',
path: '/dashboard',
meta: {
title: '首页',
layout: 'default',
order: 1,
icon: 'ep:dashboard',
hideMenu: false,
disabled: false
}
},
{
name: 'userManagement',
path: '/user',
meta: {
title: '用户管理',
layout: 'default',
order: 2,
icon: 'mdi:account-group',
hideMenu: false,
disabled: false
},
children: [
{
name: 'userList',
path: 'list',
meta: {
title: '用户列表',
layout: 'default',
order: 1,
icon: 'mdi:account-multiple',
hideMenu: false
},
children: [
{
name: 'adminUser',
path: 'profile/:id',
meta: {
title: '管理员用户',
layout: 'default',
order: 1,
icon: 'mdi:account-details',
hideMenu: false
}
},
{
name: 'defaultUser',
path: 'profile/:id',
meta: {
title: '普通用户',
layout: 'default',
order: 1,
icon: 'mdi:account-details',
hideMenu: false
}
}
]
},
{
name: 'userProfile',
path: 'profile/:id',
meta: {
title: '用户详情',
layout: 'default',
order: 2,
icon: 'mdi:account-details',
hideMenu: true // 隐藏菜单,通常用于详情页
}
}
]
},
{
name: 'system',
path: '/system',
meta: {
title: '系统管理',
layout: 'default',
order: 3,
icon: 'ic:baseline-settings',
hideMenu: false
},
children: [
{
name: 'settings',
path: 'settings',
meta: {
title: '系统设置',
layout: 'default',
order: 1,
icon: 'mdi:cog',
hideMenu: false
}
},
{
name: 'logs',
path: 'logs',
meta: {
title: '操作日志',
layout: 'default',
order: 2,
icon: 'mdi:clipboard-text',
hideMenu: false
}
}
]
},
{
name: 'externalLink',
path: 'https://example.com',
meta: {
title: '外部链接',
layout: 'default',
order: 4,
icon: 'mdi:link',
hideMenu: false,
disabled: false,
external: true // 扩展属性,演示 Record 的灵活性
}
},
{
name: 'hiddenPage',
path: '/hidden',
meta: {
title: '隐藏页面',
layout: 'default',
order: 99,
hideMenu: true, // 完全隐藏菜单
disabled: false,
permission: 'admin' // 扩展权限控制字段
}
}
]
</script>
<style scoped></style>
<route lang="yaml">
meta:
layout: default
</route>