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

33 阅读3分钟

菜单组件

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

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>