通用管理后台组件库-5-菜单组件

61 阅读7分钟

菜单组件

说明:实现一个可配置的菜单组件。
实现效果:\ image.png

1.路由插件unplugin-vue-router根据page文件夹下的文件等级来自动配置路由,多级目录须同步添加同名文件,设置router-view,需要显示的菜单目录,需配置definePage

image.png

2.类型文件type.d.ts、手动生成菜单项索引文件useMenu.ts

(1)类型文件type.d.ts

import type { IconifyIcon } from '@iconify/vue'
import type { MenuItemClicked } from 'element-plus'
import type { CSSProperties } from 'vue'
import type { NavigationFailure } from 'vue-router'

/**
 * 定义一个泛型类型 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 | symbol
  path: string
  meta?: RouteMeta
  alias?: string
  children?: AppRouteMenuItem[]
  component?: Components
}
// 设置Icony样式
export interface IconOptions {
  style: CSSProperties
  class: string
}

// menu组件事件类型
export type EmitSelectType = [
  index: string,
  indexPath: string[],
  item: MenuItemClicked,
  routerResult?: Promise<void | NavigationFailure>
]
export type OpenCloseType = [index: string, indexPath: string[]]

(2)useMenu.ts

import type { AppRouteMenuItem } from './type'

/**
 * 1.创建一个useMenu组合式函数,用于手动递归生成菜单索引index(或key)
 * 2.根据菜单数据获取菜单索引
 * 3.根据菜单数据判断是否有子菜单
 */
export function useMenu() {
  function filterAndOrderMenus(menus: AppRouteMenuItem[]) {
    // 过滤出未隐藏的菜单项,并根据order字段排序
    return menus
      .filter((item) => !item.meta?.hideMenu)
      .sort((a, b) => {
        // 符合??运算符,如果a.meta?.order存在则返回a.meta?.order,否则返回100
        const orderA = a.meta?.order ?? 100
        const orderB = b.meta?.order ?? 100
        return orderA - orderB
      })
      .map((item) => ({ ...item }))
  }
  // 根据菜单数据生成菜单索引
  function genaratorMenuIndex(menus: AppRouteMenuItem[], level = '0') {
    let i = 0
    const filterMenus = filterAndOrderMenus(menus)
    // 遍历过滤后的菜单项
    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
  }

  // 获取一级的菜单列表,删除其他等级children的菜单
  function getTopMenus(menus: AppRouteMenuItem[]) {
    const filterMenus = filterAndOrderMenus(menus)
    return filterMenus.map((item) => {
      delete item.children
      return item
    })
  }
  // 获取子菜单,通过点击的菜单项对应路由获取子菜单
  function getSubMenus(menus: AppRouteMenuItem[]) {
    // 获取路由path
    const route = useRoute()
    const path = computed(() => {
      if (route.path === '/') return '/'
      const rootPath = route.path.split('/')[1]
      return rootPath ? `/${rootPath}` : '/'
    })
    const filterMenus = filterAndOrderMenus(menus)
    return filterMenus.find((item) => item.path === path.value)?.children || []
  }

  // 递归查找菜单节点,fn作为条件函数
  function getItemCondition(menus: AppRouteMenuItem[], fn: (item: AppRouteMenuItem) => boolean) {
    for (let i = 0; i < menus.length; i++) {
      if (fn(menus[i])) {
        return menus[i]
      } else {
        if (menus[i].children && Array.isArray(menus[i].children)) {
          const result = getItemCondition(menus[i].children!, fn) as AppRouteMenuItem | undefined
          if (result) {
            return result
          }
        }
      }
    }
  }
  // 获取叶子菜单
  function getLeafMenus(menus: AppRouteMenuItem[], index: string) {
    // for (let i = 0; i < menus.length; i++) {
    // const item = menus[i]
    // if (item.meta?.index === index) {
    // return item
    // } else {
    // if (item.children && Array.isArray(item.children)) {
    // const menuItem = getLeafMenus(item.children, index) as AppRouteMenuItem | undefined
    // if (menuItem) {
    // return menuItem
    // }
    // }
    // }
    // }
    return getItemCondition(menus, (item) => item.meta?.index === index)
  }
  // 获取指定第三级菜单的父菜单
  function getParentMenus(menus: AppRouteMenuItem[]): AppRouteMenuItem | undefined {
    const route = useRoute()
    const path = computed(() => route.path)
    return getItemCondition(menus, (item) => {
      const arr = path.value.split('/')
      // 不是三级菜单
      if (arr.length < 3) return false
      arr.pop()
      return arr.join('/') === item.name
    })
  }
  return {
    filterAndOrderMenus,
    genaratorMenuIndex,
    getMenuIndex,
    menuHasChildren,
    getTopMenus,
    getSubMenus,
    getLeafMenus,
    getParentMenus
  }
}

3.主菜单Menu.vue、二级菜单SubMenu.vue、菜单项组件MenuItem.vue、下拉菜单组件DropDown.vue

(1)主菜单Menu.vue

<template>
  <el-menu
    ref="menuRef"
    v-bind="menuProps"
    :style="{ '--bg-color': backgroundColor }"
    class="border-r-0!"
    @select="handleSelect"
    @open="handleOpen"
    @close="handleClose"
  >
    <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 type { AppRouteMenuItem, EmitSelectType, IconOptions, OpenCloseType } from './type'
import { useMenu } from './useMenu'
import { isDefined } from '@vueuse/core'

interface MenuProps extends Partial<ElMenuProps> {
  data?: AppRouteMenuItem[]
  subMenuProps?: Partial<SubMenuProps>
  iconProps?: Partial<IconOptions>
}

const props = withDefaults(defineProps<MenuProps>(), {
  data: () => [],
  iconProps: () => ({
    style: { fontSize: '16px' },
    class: 'mr-3'
  }),
  backgroundColor: 'transparent',
  ellipsis: true
})

const iconProps = reactive(props.iconProps)
const menuRef = ref()
watch(
  () => props.collapse,
  () => {
    iconProps.class = props.collapse ? '' : 'mr-3'
  }
)

provide('iconProps', iconProps)

// 生成菜单索引
const { genaratorMenuIndex, getLeafMenus, getParentMenus } = useMenu()
const filterMenus = computed(() => genaratorMenuIndex(props.data))

// 获取传递的slot
const slots = useSlots()

// 过滤出menu的props数据
const menuProps = computed(() => {
  const { data, subMenuProps, ...rest } = props
  return rest
})

onMounted(() => {
  // 路由输入三级菜单时,对应其父节点都打开
  const item = getParentMenus(filterMenus.value)
  if (item && item.meta && item.meta.index) {
    if (menuRef.value && menuRef.value.open) {
     !props.collapse && menuRef.value.open(item.meta.index)
    }
  }
})

const emits = defineEmits<{
  select: [menuItem: AppRouteMenuItem]
  open: [args: OpenCloseType]
  close: [args: OpenCloseType]
}>()
// 选择菜单项事件
const handleSelect = (...args: EmitSelectType) => {
  const [index] = args
  const menuItem = getLeafMenus(filterMenus.value, index)
  if (menuItem) {
    emits('select', menuItem)
  }
}
const handleClose = (...args: OpenCloseType) => {
  emits('close', args)
}
const handleOpen = (...args: OpenCloseType) => {
  emits('open', args)
}
</script>

<style lang="scss">
.el-menu--vertical .el-sub-menu__title {
  padding-right: 0 !important;
}
.el-menu--horizontal.el-menu {
  border-bottom: 0 !important;
}
</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">
      <span :class="iconProps?.class"></span>
      <span>{{ $t(data.meta?.title || '') }}</span>
    </template>
    <!-- 折叠或侧栏场景 -->
    <template #title v-else>
      <Icon :icon="data.meta?.icon" :style="iconProps?.style" :class="iconProps?.class" />
      <span>{{ $t(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, IconOptions } 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()

// 接收icon样式
const iconProps = inject('iconProps') as IconOptions
</script>

<style scoped></style>

(3)菜单项组件MenuItem.vue

<template>
  <!-- 一级菜单 -->
  <el-menu-item
    :index="getMenuIndex(data)"
    :disabled="data.meta?.disabled"
    v-if="!data.meta?.icon"
    >{{ $t(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" :style="iconProps?.style" :class="iconProps?.class" />
      <template #title>{{ $t(data.meta?.title || '') }}</template>
    </el-menu-item>
    <!-- 侧栏场景 -->
    <el-menu-item v-else :index="getMenuIndex(data)" :disabled="data.meta?.disabled">
      <Icon :icon="data.meta.icon" :style="iconProps?.style" :class="iconProps?.class" />
      <span>{{ $t(data.meta?.title || '') }}</span>
    </el-menu-item>
  </template>
</template>

<script setup lang="ts">
import type { SubMenuProps as ElSubMenuProps } from 'element-plus'
import type { AppRouteMenuItem, IconOptions } 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()

// 接收icon样式
const iconProps = inject('iconProps') as IconOptions
</script>

<style scoped></style>

(4) 下拉菜单组件DropDown.vue

<template>
  <el-dropdown trigger="click" @command="handleCommand">
    <slot name="header">
      <!-- <span class="el-dropdown-link"> -->
        <!-- <Icon icon="meteor-icons:language" class="text-2xl" /> -->
      <!-- </span> -->
    </slot>
    <template #dropdown>
      <el-dropdown-menu>
        <el-dropdown-item
          v-for="(item, index) in items"
          :key="index"
          :class="{ active: index === currentIndex }"
          :command="{ item, index }"
        >
          <div class="flex items-center">
            <Icon v-if="item.icon" :icon="item.icon" class="mr-2 text-xl" />
            <slot name="item" :item="item"></slot>
          </div>
        </el-dropdown-item>
      </el-dropdown-menu>
    </template>
  </el-dropdown>
</template>
<script setup lang="ts" generic="T extends { icon?: string | IconifyIcon }">
import type { IconifyIcon } from '@iconify/vue'
import { Icon } from '@iconify/vue'
export interface DropDownProps<T> {
  items: T[]
}
defineProps<DropDownProps<T>>()
const emits = defineEmits<{ change: [item: T, index: number] }>()

// 选中的值
const currentIndex = defineModel('modelValue', {
  default: 0
})

const handleCommand = (command: { item: T; index: number }) => {
  currentIndex.value = command.index
  emits('change', command.item, command.index)
}
</script>

<style scoped lang="scss">
:deep(.el-dropdown-menu__item) {
  &.active {
    background-color: var(--el-dropdown-menuItem-hover-fill);
    color: var(--el-dropdown-menuItem-hover-color);
  }
}
</style>

4.demo实现,根据自动路由unplugin-vue-router生成对应菜单,src/layouts/default.vue

<template>
  <div class="w-full h-screen overflow-hidden flex">
    <!-- 左右布局 -->
    <!-- sidebar -->
    <div
      :style="{
        width: mixbarMenuWidth,
        backgroundColor: setting?.backgroundColor
      }"
      class="h-full transition-width shrink-0"
      v-if="setting?.mode !== 'top'"
    >
      <el-row class="h-full">
        <el-scrollbar
          v-if="setting?.mode !== 'mix'"
          :class="[setting?.mode !== 'mixbar' ? 'flex-1' : 'w-[64px] py-4']"
          :style="{
            backgroundColor:
              setting?.mode !== 'mixbar' ? 'auto' : darkenColor(setting?.backgroundColor, 10)
          }"
        >
          <!-- 左侧菜单和左侧菜单混合模式的布局-->
          <Menu
            :class="[{ mixbar: setting?.mode === 'mixbar' }]"
            v-if="setting?.mode === 'siderbar' || setting?.mode === 'mixbar'"
            mode="vertical"
            :data="mixbarMenus"
            :collapse="setting?.mode !== 'mixbar' && localSettings.collapse"
            text-color="#b8b8b8"
            :background-color="
              setting?.mode !== 'mixbar' ? setting?.backgroundColor : 'transparent'
            "
            @select="handleMenuSelect"
          ></Menu>
        </el-scrollbar>
        <el-scrollbar v-if="setting?.mode === 'mix' || setting?.mode === 'mixbar'" class="flex-1">
          <!-- 左侧菜单混合和顶部左侧菜单混合模式的二级menu -->
          <Menu
            mode="vertical"
            :data="getSubMenus(menus)"
            :collapse="localSettings.collapse"
            text-color="#b8b8b8"
            :background-color="setting?.backgroundColor"
            @select="handleMenuSelect"
          ></Menu
        ></el-scrollbar>
      </el-row>
    </div>
    <!-- content -->
    <div class="w-full h-full">
      <!-- header -->
      <Header
        :locales="locales"
        :username="username"
        :src="avatar"
        :data="avatarMenu"
        :setting="setting"
        v-model:collapse="localSettings.collapse"
        @setting-change="handleSettingChange"
        @select="handleMenuSelect"
      >
        <template #menu>
          <!-- 顶部菜单和混合模式布局 -->
          <Menu
            v-if="setting?.mode === 'top' || setting?.mode === 'mix'"
            mode="horizontal"
            :data="setting?.mode === 'mix' ? getTopMenus(menus) : menus"
            :collapse="false"
            @select="handleMenuSelect"
          ></Menu>
        </template>
      </Header>
      <!-- main -->
      <router-view></router-view>
    </div>
    <!-- 移动端菜单抽屉 -->
    <el-drawer
      direction="ltr"
      class="w-full!"
      :style="{ backgroundColor: setting?.backgroundColor }"
      v-if="isMobile"
      :model-value="!localSettings.collapse"
      @close="localSettings.collapse = true"
    >
      <Menu
        text-color="#b8b8b8"
        :data="menus"
        :background-color="setting?.backgroundColor"
        @select="handleMenuSelect"
      ></Menu>
    </el-drawer>
  </div>
</template>

<script setup lang="ts">
import type { DropMenuItem } from '@/components/Avatar/types'
import type { HeaderProps } from '@/components/Layouts/types'
import type { ThemeSettingProps } from '@/components/Themes/type'
import type { AppRouteMenuItem } from '@/components/menu/type'
import { useMenu } from '@/components/menu/useMenu'
import { darkenColor } from '@/utils'
import type { RouteRecordRaw } from 'vue-router/auto'
import { routes } from 'vue-router/auto-routes'

interface ThemeSettingsOptions extends HeaderProps {
  username: string
  avatar: string
  avatarMenu: DropMenuItem[]
}
const router = useRouter()

// 设置配置默认数据
const localSettings = reactive<ThemeSettingsOptions>({
  username: 'admin',
  locales: [
    {
      name: 'zh-CN',
      text: '中文',
      icon: 'uil:letter-chinese-a'
    },
    {
      text: '英文',
      name: 'en',
      icon: 'ri:english-input'
    }
  ],
  avatarMenu: [
    {
      key: '1',
      value: '个人中心'
    },
    {
      key: '2',
      value: '修改密码'
    },
    {
      key: 'divider',
      value: ''
    },
    {
      key: '4',
      value: '退出登录'
    }
  ],
  avatar: '',
  collapse: false,
  setting: { menuWidth: 280 } as ThemeSettingProps
})
const { locales, avatarMenu, username, avatar } = toRefs(localSettings)

// 菜单和路由配置类型不相同,转换一下
const genrateMenuData = (routes: RouteRecordRaw[]): AppRouteMenuItem[] => {
  const menuData: AppRouteMenuItem[] = []
  routes.forEach((route) => {
    if (route.meta?.hideMenu) return
    let menuItem: AppRouteMenuItem = {
      name: route.name,
      path: route.path,
      meta: route.meta,
      alias: typeof route.redirect === 'string' ? route.redirect : undefined,
      component: route.component
    }
    // 判断是否有子路由,递归转换
    if (route.children && Array.isArray(route.children) && route.children.length > 0) {
      menuItem.children = genrateMenuData(route.children)
    }
    menuData.push(menuItem)
  })
  return menuData
}
// 路由类型数据转换为菜单类型数据
const menus = computed(() => genrateMenuData(routes))
const isMobile = ref(false)
// 设置主题
const handleSettingChange = (themeSettings: ThemeSettingProps) => {
  localSettings.setting = themeSettings
}
// 获取菜单宽度
const menuWidth = computed(() => (localSettings.setting ? localSettings.setting.menuWidth : 240))
// 获取设置菜单
const setting = computed(() => localSettings.setting)

// 获取mixbar和mix模式下的一二级菜单
const { getTopMenus, getSubMenus } = useMenu()

onMounted(() => {
  console.log(getTopMenus(menus.value))
  console.log(getSubMenus(menus.value))
})

// 混合mixbar模式下的菜单
const mixbarMenus = computed(() =>
  setting.value?.mode === 'mixbar' ? getTopMenus(menus.value) : menus.value
)
// 混合mixbar模式下的二级菜单是否都设置了icon,判断收起的显示情况
const isFullIcons = computed(() => {
  return getSubMenus(menus.value).every(
    (item) => typeof item.meta?.icon !== 'undefined' && item.meta?.icon
  )
})
// 混合mixbar模式下的菜单宽度
const mixbarMenuWidth = computed(() => {
  if (isMobile.value) return 0
  if (setting.value?.mode === 'mixbar' && isFullIcons.value) {
    return localSettings.collapse ? 'auto' : menuWidth.value + 'px'
  } else {
    return localSettings.collapse ? '64px' : menuWidth.value + 'px'
  }
})
// 选择menu事件
const handleMenuSelect = (menuItem: AppRouteMenuItem) => {
  if (menuItem && menuItem.name) {
    router.push(menuItem.name as string)
    if (isMobile.value) {
     localSettings.collapse = true
    }
  }
}

// 菜单抽屉展开折叠,屏幕宽度适配
const tmpWidth = ref(0)
const changeWidthFlag = ref(false)
useResizeObserver(document.body, (entries) => {
  // 获取浏览器宽度
  const { width } = entries[0].contentRect
  if (tmpWidth.value === 0) {
    // 记录初始宽度
    tmpWidth.value = width
  }
  if (width > tmpWidth.value) {
    // 扩大屏幕
    changeWidthFlag.value = width < 640
  } else {
    // 缩小屏幕
    changeWidthFlag.value = width > 1200
  }
  if (width < 640 && !changeWidthFlag.value) {
    localSettings.collapse = true
  }
  if (width > 1200 && !changeWidthFlag.value) {
    localSettings.collapse = false
  }
  // 是否是移动端屏幕宽度
  isMobile.value = width < 440
  tmpWidth.value = width
})
onBeforeMount(() => {
  // 是否是移动端屏幕
  if (
    navigator.userAgent.match(
      /(phone|pad|pod|iPhone|iPod|ios|iPad|Android|Mobile|BlackBerry|IEMobile|MQQBrowser|JUC|Fennec|wOSBrowser|BrowserNG|WebOS|Symbian|Windows Phone)/i
    )
  ) {
    isMobile.value = true
    localSettings.collapse = true
  }
})
</script>

<style lang="scss" scoped>
.mixbar {
  :deep(.el-menu-item) {
    height: auto;
    line-height: unset !important;
    flex-direction: column;
    margin-bottom: 15px;
    padding: 4px 0 !important;
    svg {
      margin-right: 0;
      margin-bottom: 10px;
    }
  }
}
</style>