Vue3导航菜单(Menu)

效果如下图:

在线预览

APIs

名称 说明 类型 默认值
openKeys v-model 当前展开的子菜单 key 数组;传入即受控,不传时由组件内部维护 [MenuKey](#名称 说明 类型 默认值 openKeys v-model 当前展开的子菜单 key 数组;传入即受控,不传时由组件内部维护 MenuKey[] undefined selectedKeys v-model 当前选中的菜单项 key 数组;传入即受控,不传时由组件内部维护 MenuKey[] undefined items 菜单内容 ItemType[] [] mode 菜单类型 ‘horizontal’ ‘vertical’
selectedKeys v-model 当前选中的菜单项 key 数组;传入即受控,不传时由组件内部维护 [MenuKey](#名称 说明 类型 默认值 openKeys v-model 当前展开的子菜单 key 数组;传入即受控,不传时由组件内部维护 MenuKey[] undefined selectedKeys v-model 当前选中的菜单项 key 数组;传入即受控,不传时由组件内部维护 MenuKey[] undefined items 菜单内容 ItemType[] [] mode 菜单类型 ‘horizontal’ ‘vertical’
items 菜单内容 [ItemType](#名称 说明 类型 默认值 openKeys v-model 当前展开的子菜单 key 数组;传入即受控,不传时由组件内部维护 MenuKey[] undefined selectedKeys v-model 当前选中的菜单项 key 数组;传入即受控,不传时由组件内部维护 MenuKey[] undefined items 菜单内容 ItemType[] [] mode 菜单类型 ‘horizontal’ ‘vertical’
mode 菜单类型 'horizontal' | 'vertical' | 'inline' 'vertical'
theme 主题颜色 'light' | 'dark' 'light'
inlineCollapsed inline / vertical 模式下是否收起,收起时子菜单改为浮层展开;菜单位于侧边栏内时以侧边栏的收起状态为准 boolean false
inlineIndent inline 模式每一级菜单项的缩进宽度 number 24
expandIcon 自定义子菜单的展开收起图标 (info: [MenuExpandIconInfo](#名称 说明 类型 默认值 openKeys v-model 当前展开的子菜单 key 数组;传入即受控,不传时由组件内部维护 MenuKey[] undefined selectedKeys v-model 当前选中的菜单项 key 数组;传入即受控,不传时由组件内部维护 MenuKey[] undefined items 菜单内容 ItemType[] [] mode 菜单类型 ‘horizontal’ ‘vertical’
disabled 是否禁用整个菜单 boolean false
selectable 是否允许选中 boolean true
multiple 是否允许多选 boolean false
triggerSubMenuAction 子菜单的展开触发方式;inline 模式未收起时子菜单就地展开,不受此属性影响 'click' | 'hover' 'hover'
subMenuOpenDelay 鼠标进入子菜单后开启的延时,单位秒 number 0
subMenuCloseDelay 鼠标离开子菜单后关闭的延时,单位秒 number 0.1
forceSubMenuRender 是否在子菜单展示之前就渲染进 DOM(首次展开因而无需等待挂载) boolean false
名称 值
MenuKey string | number
名称 值
MenuNode VNodeChild | (() => VNodeChild)
名称 值
MenuIcon VNodeChild | ((item: [SubMenuType](#名称 值 MenuIcon VNodeChild

ItemType Type

名称 值
ItemType [SubMenuType](#名称 值 ItemType SubMenuType
名称 说明 类型
key 唯一标志 [MenuKey](#名称 说明 类型 key 唯一标志 MenuKey label? 菜单项标题,支持渲染函数 MenuNode disabled? 是否禁用 boolean danger? 是否展示错误状态样式 boolean icon? 菜单图标,渲染函数入参为所在菜单项的配置 MenuIcon title? 收起时展示的悬浮标题,支持渲染函数;显式 false 表示不展示 MenuNode style? 菜单项自定义样式 CSSProperties class? 菜单项自定义类名 string)
label? 菜单项标题,支持渲染函数 [MenuNode](#名称 说明 类型 key 唯一标志 MenuKey label? 菜单项标题,支持渲染函数 MenuNode disabled? 是否禁用 boolean danger? 是否展示错误状态样式 boolean icon? 菜单图标,渲染函数入参为所在菜单项的配置 MenuIcon title? 收起时展示的悬浮标题,支持渲染函数;显式 false 表示不展示 MenuNode style? 菜单项自定义样式 CSSProperties class? 菜单项自定义类名 string)
disabled? 是否禁用 boolean
danger? 是否展示错误状态样式 boolean
icon? 菜单图标,渲染函数入参为所在菜单项的配置 [MenuIcon](#名称 说明 类型 key 唯一标志 MenuKey label? 菜单项标题,支持渲染函数 MenuNode disabled? 是否禁用 boolean danger? 是否展示错误状态样式 boolean icon? 菜单图标,渲染函数入参为所在菜单项的配置 MenuIcon title? 收起时展示的悬浮标题,支持渲染函数;显式 false 表示不展示 MenuNode style? 菜单项自定义样式 CSSProperties class? 菜单项自定义类名 string)
title? 收起时展示的悬浮标题,支持渲染函数;显式 false 表示不展示 [MenuNode](#名称 说明 类型 key 唯一标志 MenuKey label? 菜单项标题,支持渲染函数 MenuNode disabled? 是否禁用 boolean danger? 是否展示错误状态样式 boolean icon? 菜单图标,渲染函数入参为所在菜单项的配置 MenuIcon title? 收起时展示的悬浮标题,支持渲染函数;显式 false 表示不展示 MenuNode style? 菜单项自定义样式 CSSProperties class? 菜单项自定义类名 string)
style? 菜单项自定义样式 CSSProperties
class? 菜单项自定义类名 string
名称 说明 类型
key 唯一标志 [MenuKey](#名称 说明 类型 key 唯一标志 MenuKey label? 子菜单标题,支持渲染函数 MenuNode children 子菜单的菜单项 ItemType[] disabled? 是否禁用 boolean icon? 菜单图标,渲染函数入参为所在子菜单的配置 MenuIcon expandIcon? 子菜单级展开收起图标,优先于 Menu 的 expandIcon (info: MenuExpandIconInfo) => VNodeChild theme? 子菜单主题,不传则继承 Menu 的 theme ‘light’
label? 子菜单标题,支持渲染函数 [MenuNode](#名称 说明 类型 key 唯一标志 MenuKey label? 子菜单标题,支持渲染函数 MenuNode children 子菜单的菜单项 ItemType[] disabled? 是否禁用 boolean icon? 菜单图标,渲染函数入参为所在子菜单的配置 MenuIcon expandIcon? 子菜单级展开收起图标,优先于 Menu 的 expandIcon (info: MenuExpandIconInfo) => VNodeChild theme? 子菜单主题,不传则继承 Menu 的 theme ‘light’
children 子菜单的菜单项 [ItemType](#名称 说明 类型 key 唯一标志 MenuKey label? 子菜单标题,支持渲染函数 MenuNode children 子菜单的菜单项 ItemType[] disabled? 是否禁用 boolean icon? 菜单图标,渲染函数入参为所在子菜单的配置 MenuIcon expandIcon? 子菜单级展开收起图标,优先于 Menu 的 expandIcon (info: MenuExpandIconInfo) => VNodeChild theme? 子菜单主题,不传则继承 Menu 的 theme ‘light’
disabled? 是否禁用 boolean
icon? 菜单图标,渲染函数入参为所在子菜单的配置 [MenuIcon](#名称 说明 类型 key 唯一标志 MenuKey label? 子菜单标题,支持渲染函数 MenuNode children 子菜单的菜单项 ItemType[] disabled? 是否禁用 boolean icon? 菜单图标,渲染函数入参为所在子菜单的配置 MenuIcon expandIcon? 子菜单级展开收起图标,优先于 Menu 的 expandIcon (info: MenuExpandIconInfo) => VNodeChild theme? 子菜单主题,不传则继承 Menu 的 theme ‘light’
expandIcon? 子菜单级展开收起图标,优先于 Menu 的 expandIcon (info: [MenuExpandIconInfo](#名称 说明 类型 key 唯一标志 MenuKey label? 子菜单标题,支持渲染函数 MenuNode children 子菜单的菜单项 ItemType[] disabled? 是否禁用 boolean icon? 菜单图标,渲染函数入参为所在子菜单的配置 MenuIcon expandIcon? 子菜单级展开收起图标,优先于 Menu 的 expandIcon (info: MenuExpandIconInfo) => VNodeChild theme? 子菜单主题,不传则继承 Menu 的 theme ‘light’
theme? 子菜单主题,不传则继承 Menu 的 theme 'light' | 'dark'
title? 收起时展示的悬浮标题,支持渲染函数;显式 false 表示不展示 [MenuNode](#名称 说明 类型 key 唯一标志 MenuKey label? 子菜单标题,支持渲染函数 MenuNode children 子菜单的菜单项 ItemType[] disabled? 是否禁用 boolean icon? 菜单图标,渲染函数入参为所在子菜单的配置 MenuIcon expandIcon? 子菜单级展开收起图标,优先于 Menu 的 expandIcon (info: MenuExpandIconInfo) => VNodeChild theme? 子菜单主题,不传则继承 Menu 的 theme ‘light’
popupClassName? 弹出子菜单的自定义类名,inline 模式下无效 string
popupOffset? 弹出子菜单与锚点的偏移,inline 模式下无效 number, number
onTitleClick? 点击子菜单标题 (info: [MenuTitleInfo](#名称 说明 类型 key 唯一标志 MenuKey label? 子菜单标题,支持渲染函数 MenuNode children 子菜单的菜单项 ItemType[] disabled? 是否禁用 boolean icon? 菜单图标,渲染函数入参为所在子菜单的配置 MenuIcon expandIcon? 子菜单级展开收起图标,优先于 Menu 的 expandIcon (info: MenuExpandIconInfo) => VNodeChild theme? 子菜单主题,不传则继承 Menu 的 theme ‘light’
style? 子菜单自定义样式 CSSProperties
class? 子菜单自定义类名 string
名称 说明 类型
type 固定为 group,标记该项为分组 'group'
label? 分组标题,支持渲染函数 [MenuNode](#名称 说明 类型 type 固定为 group,标记该项为分组 ‘group’ label? 分组标题,支持渲染函数 MenuNode children? 分组的菜单项 ItemType[] style? 分组自定义样式(作用于分组容器) CSSProperties class? 分组自定义类名(作用于分组容器) string)
children? 分组的菜单项 [ItemType](#名称 说明 类型 type 固定为 group,标记该项为分组 ‘group’ label? 分组标题,支持渲染函数 MenuNode children? 分组的菜单项 ItemType[] style? 分组自定义样式(作用于分组容器) CSSProperties class? 分组自定义类名(作用于分组容器) string)\[\]
style? 分组自定义样式(作用于分组容器) CSSProperties
class? 分组自定义类名(作用于分组容器) string
名称 说明 类型
type 固定为 divider,标记该项为分割线 'divider'
dashed? 是否虚线 boolean
style? 分割线自定义样式 CSSProperties
class? 分割线自定义类名 string

expandIcon 属性与 #expandIcon 插槽的入参类型,为 SubMenuType 与以下字段的交叉类型:

名称 说明 类型
isOpen? 所在子菜单当前是否展开 boolean
名称 说明 类型
key 触发项的 key [MenuKey](#名称 说明 类型 key 触发项的 key MenuKey keyPath 由根到触发项的 key 路径 MenuKey[] item 触发项的原始配置 SubMenuType
keyPath 由根到触发项的 key 路径 [MenuKey](#名称 说明 类型 key 触发项的 key MenuKey keyPath 由根到触发项的 key 路径 MenuKey[] item 触发项的原始配置 SubMenuType
item 触发项的原始配置 [SubMenuType](#名称 说明 类型 key 触发项的 key MenuKey keyPath 由根到触发项的 key 路径 MenuKey[] item 触发项的原始配置 SubMenuType
domEvent 触发的原生事件 MouseEvent | KeyboardEvent

为 MenuInfo 与以下字段的交叉类型:

名称 说明 类型
selectedKeys 变化后的选中项 key 数组 [MenuKey](#名称 说明 类型 selectedKeys 变化后的选中项 key 数组 MenuKey[])\[\]
名称 说明 类型
key 子菜单的 key [MenuKey](#名称 说明 类型 key 子菜单的 key MenuKey domEvent 触发的原生事件 MouseEvent
domEvent 触发的原生事件 MouseEvent | KeyboardEvent

<Menu> 默认插槽中的菜单项子组件(配置式写法请用 MenuItemType 数据):

名称 说明 类型 默认值
title 收起时展示的悬浮标题 string undefined
disabled 是否禁用 boolean false
danger 是否展示错误状态样式 boolean false

<Menu> 默认插槽中的子菜单子组件(配置式写法请用 SubMenuType 数据),默认插槽内书写 MenuItem / MenuSubMenu:

名称 说明 类型 默认值
title 子菜单标题,收起态下同时作为悬浮标题 string undefined
theme 子菜单主题,不传则继承 Menu 的 theme 'light' | 'dark' undefined
popupClassName 弹出子菜单的自定义类名,inline 模式下无效 string undefined
popupOffset 弹出子菜单与锚点的偏移,inline 模式下无效 number, number undefined
disabled 是否禁用 boolean false

<Menu> 默认插槽中的分组子组件(配置式写法请用 MenuItemGroupType 数据),组内书写 MenuItem:

名称 说明 类型 默认值
title 分组标题 string undefined

<Menu> 默认插槽中的分割线子组件(配置式写法请用 MenuDividerType 数据):

名称 说明 类型 默认值
dashed 是否虚线 boolean false

ℹ️ 组件式写法下菜单项与子菜单的 key 取子组件的 key 属性,未设置时按其所在层级的下标兜底(分组与分割线不参与选中,其 key 由渲染时按位置合成)。items 与默认插槽同时提供时,本库以默认插槽为准 ,沿用本库「插槽优先于 prop」的统一约定。

Slots

名称 说明 用法
default 子组件式菜单内容,直接书写 MenuItem / MenuSubMenu / MenuItemGroup / MenuDivider;与 items 同时提供时以此为准 v-slot:default
expandIcon 自定义子菜单的展开收起图标,存在时优先于 expandIcon 属性 v-slot:expandIcon="{ isOpen, ...item }"
overflowedIndicator 水平空间不足时收进省略子菜单的指示器,缺省为内置三点图标 v-slot:overflowedIndicator
名称 说明 用法
default 菜单项标题 v-slot:default
icon 菜单图标 v-slot:icon
title 收起时展示的悬浮标题 v-slot:title
名称 说明 用法
default 子菜单的菜单项,书写 MenuItem / MenuSubMenu v-slot:default
title 子菜单标题 v-slot:title
icon 菜单图标 v-slot:icon
expandIcon 子菜单的展开收起图标,存在时优先于 Menu 的 expandIcon v-slot:expandIcon="{ isOpen, ...item }"
名称 说明 用法
default 分组的菜单项,书写 MenuItem v-slot:default
title 分组标题 v-slot:title

Events

名称 说明 类型
click 点击菜单项时调用 (info: [MenuInfo](#名称 说明 类型 click 点击菜单项时调用 (info: MenuInfo) => void select 被选中时调用 (info: MenuSelectInfo) => void deselect 取消选中时调用,仅在 multiple 生效 (info: MenuSelectInfo) => void openChange 子菜单展开 / 收起时调用 (openKeys: MenuKey[]) => void titleClick 点击子菜单标题时调用(MenuSubMenu 的事件,需在 上监听,而非 ) (info: MenuTitleInfo) => void)) => void
select 被选中时调用 (info: [MenuSelectInfo](#名称 说明 类型 click 点击菜单项时调用 (info: MenuInfo) => void select 被选中时调用 (info: MenuSelectInfo) => void deselect 取消选中时调用,仅在 multiple 生效 (info: MenuSelectInfo) => void openChange 子菜单展开 / 收起时调用 (openKeys: MenuKey[]) => void titleClick 点击子菜单标题时调用(MenuSubMenu 的事件,需在 上监听,而非 ) (info: MenuTitleInfo) => void)) => void
deselect 取消选中时调用,仅在 multiple 生效 (info: [MenuSelectInfo](#名称 说明 类型 click 点击菜单项时调用 (info: MenuInfo) => void select 被选中时调用 (info: MenuSelectInfo) => void deselect 取消选中时调用,仅在 multiple 生效 (info: MenuSelectInfo) => void openChange 子菜单展开 / 收起时调用 (openKeys: MenuKey[]) => void titleClick 点击子菜单标题时调用(MenuSubMenu 的事件,需在 上监听,而非 ) (info: MenuTitleInfo) => void)) => void
openChange 子菜单展开 / 收起时调用 (openKeys: [MenuKey](#名称 说明 类型 click 点击菜单项时调用 (info: MenuInfo) => void select 被选中时调用 (info: MenuSelectInfo) => void deselect 取消选中时调用,仅在 multiple 生效 (info: MenuSelectInfo) => void openChange 子菜单展开 / 收起时调用 (openKeys: MenuKey[]) => void titleClick 点击子菜单标题时调用(MenuSubMenu 的事件,需在 上监听,而非 ) (info: MenuTitleInfo) => void)\[\]) => void
titleClick 点击子菜单标题时调用(MenuSubMenu 的事件,需在 <MenuSubMenu> 上监听,而非 <Menu>) (info: [MenuTitleInfo](#名称 说明 类型 click 点击菜单项时调用 (info: MenuInfo) => void select 被选中时调用 (info: MenuSelectInfo) => void deselect 取消选中时调用,仅在 multiple 生效 (info: MenuSelectInfo) => void openChange 子菜单展开 / 收起时调用 (openKeys: MenuKey[]) => void titleClick 点击子菜单标题时调用(MenuSubMenu 的事件,需在 上监听,而非 ) (info: MenuTitleInfo) => void)) => void
ts 复制代码
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, provide, ref, useSlots, watch } from 'vue'
import type { VNode, VNodeChild } from 'vue'
import { FLOATING_LAYER_Z_INDEX, injectFromChain, siderCollapsedKey, useControlledModel } from 'components/utils'
import MenuNodes from './MenuNodes'
import { getItemKey, menuContextKey } from './context'
import type { MenuContext } from './context'
import { splitOverflowItems } from './overflow'
import { parseSlotItems } from './slotItems'
import type {
  ItemType,
  MenuExpandIconInfo,
  MenuInfo,
  MenuKey,
  MenuMode,
  MenuSelectInfo,
  MenuTheme,
  SubMenuType
} from './interface'

/**
 * 导航菜单
 *
 * 以 `items` 配置描述菜单结构:`children` 表示子菜单、`type: 'group'` 表示分组、`type: 'divider'` 表示分割线。
 * 展开与选中状态受控(`v-model:openKeys` / `v-model:selectedKeys`)与非受控均可使用。
 */
export interface Props {
  // 双向绑定
  openKeys?: MenuKey[] // 当前展开的子菜单 key 数组
  selectedKeys?: MenuKey[] // 当前选中的菜单项 key 数组
  // 内容数据
  items?: ItemType[] // 菜单内容
  // 形态外观
  mode?: MenuMode // 菜单类型
  theme?: MenuTheme // 主题颜色
  inlineCollapsed?: boolean // inline 模式下是否收起(收起时子菜单改为浮层展开);外层为 LayoutSider 时以其收起态为准
  inlineIndent?: number // inline 模式每一级菜单项的缩进宽度
  expandIcon?: (info: MenuExpandIconInfo) => VNodeChild // 自定义子菜单的展开收起图标
  // 状态反馈
  disabled?: boolean // 是否禁用整个菜单
  selectable?: boolean // 是否允许选中
  multiple?: boolean // 是否允许多选
  // 行为交互
  triggerSubMenuAction?: 'click' | 'hover' // 子菜单的展开触发方式
  subMenuOpenDelay?: number // 鼠标进入子菜单后开启的延时,单位秒
  subMenuCloseDelay?: number // 鼠标离开子菜单后关闭的延时,单位秒
  forceSubMenuRender?: boolean // 是否在子菜单展示之前就渲染进 DOM(首次展开因而无需等待挂载)
}

export interface MenuSlots {
  default?: () => VNode[] // 子组件式菜单内容(MenuItem / MenuSubMenu / MenuItemGroup / MenuDivider)
  expandIcon?: (info: MenuExpandIconInfo) => VNode[]
  overflowedIndicator?: () => VNode[] // 水平空间不足时收进省略子菜单的指示器,缺省为内置三点图标
}

const props = withDefaults(defineProps<Props>(), {
  openKeys: undefined,
  selectedKeys: undefined,
  items: () => [],
  mode: 'vertical',
  theme: 'light',
  inlineCollapsed: false,
  inlineIndent: 24,
  expandIcon: undefined,
  disabled: false,
  selectable: true,
  multiple: false,
  triggerSubMenuAction: 'hover',
  subMenuOpenDelay: 0,
  subMenuCloseDelay: 0.1,
  forceSubMenuRender: false
})
defineSlots<MenuSlots>()

const emit = defineEmits<{
  click: [info: MenuInfo] // 点击菜单项时调用
  deselect: [info: MenuSelectInfo] // 取消选中时调用,仅在 multiple 生效
  openChange: [openKeys: MenuKey[]] // 子菜单展开 / 收起时调用
  select: [info: MenuSelectInfo] // 被选中时调用
  'update:openKeys': [openKeys: MenuKey[]] // 展开的子菜单 key 数组变化
  'update:selectedKeys': [selectedKeys: MenuKey[]] // 选中的菜单项 key 数组变化
}>()

const EMPTY_KEYS: MenuKey[] = [] // 稳定的空数组:避免每次渲染都产生新引用而触发子组件更新
// 显式标注插槽类型:`useSlots()` 的返回值参与 `menuItems` / `displayItems` 的推导,构成推断环
// (构建期的 dts 程序会报 TS7022 / TS7024),标注后即断开;类型与 `defineSlots<MenuSlots>()` 一致
const slots: MenuSlots = useSlots()
// 强调色与选中底色:皮肤 CSS 直接消费公开令牌 `--va-menu-primary-*`(未配置时回落到全局主色)。
// 组件级主色由 ConfigProvider 写在作用域节点上;浮层面板经 Teleport 脱离作用域时,
// Provider 的边界转发会把这些令牌一并带到面板根节点,因此不再需要把私有变量随节点下发。

// (v-model:openKeys) 展开的子菜单 key 集合:传值即受控,未传时组件自持
// (v-model:selectedKeys) 选中的菜单项 key 集合:传值即受控,未传时组件自持
// 注:两个模型读到的都可能是父级数组本体,写入必须整体替换(禁止 push / splice)
const openKeysModel = useControlledModel<MenuKey[]>('openKeys', [])
const selectedKeysModel = useControlledModel<MenuKey[]>('selectedKeys', [])

// 当前激活路径:悬浮或焦点所在项的完整 key 链(含祖先)。子菜单浮层经 Teleport 脱离 DOM 树,
// 浮层内的项被悬浮时父级标题无法用 `:hover` 感知,故统一由这条路径驱动高亮
const activeKeys = ref<MenuKey[]>([])
function changeActiveKeys(keys: MenuKey[]): void {
  activeKeys.value = keys
}

// 模式归一:inline 收起后菜单退化为 vertical(子菜单改用浮层承载),样式与布局随之为同一套
// 外层侧边栏下发的收起态优先:此时菜单宽度由侧边栏决定,自身 inlineCollapsed 不再参与
const siderCollapsed = injectFromChain(siderCollapsedKey) ?? null
const collapsed = computed(() => Boolean(siderCollapsed?.value ?? props.inlineCollapsed))
const mergedInlineCollapsed = computed(() => (props.mode === 'inline' || props.mode === 'vertical') && collapsed.value)
const mergedMode = computed<MenuMode>(() => (mergedInlineCollapsed.value ? 'vertical' : props.mode))
const isInlineMode = computed(() => mergedMode.value === 'inline')
const theme = computed(() => props.theme)

// 菜单内容:组件式写法(default 插槽)存在时以插槽为准,否则取 items(项目统一约定:插槽优先于 prop)
// 子组件只承载配置描述,这里读回为与 items 同构的配置树,两条数据源因此共用后续全部逻辑
const menuItems = computed<ItemType[]>(() => (slots.default ? parseSlotItems(slots.default()) : props.items))

// 水平溢出:放不下的菜单项收进「...」子菜单。起点由内核测量后回传,切分后的配置树参与
// 渲染与下方索引,故溢出子菜单对 keyPath、父级高亮、后代收起也与用户配置等价
const overflowStart = ref<number>(Number.POSITIVE_INFINITY)
const displayItems = computed<ItemType[]>(() =>
  props.mode === 'horizontal'
    ? splitOverflowItems(menuItems.value, overflowStart.value, slots.overflowedIndicator)
    : menuItems.value
)

// 结构索引:key → 祖先链与是否为子菜单,供 keyPath、父级高亮、后代收起使用
// 索引建立在切分后的配置树上:溢出子菜单合成于根组件,其子项因此与平铺时同源
const keyMetaMap = computed(() => {
  const map = new Map<MenuKey, { parents: MenuKey[]; isSubMenu: boolean }>()
  const walk = (nodes: ItemType[], parents: MenuKey[]): void => {
    nodes.forEach((node, index) => {
      if (!node || ('type' in node && node.type === 'divider')) {
        return
      }
      if ('type' in node && node.type === 'group') {
        walk(node.children ?? [], parents)
        return
      }
      const isSubMenu = 'children' in node && Array.isArray(node.children)
      // key 的取法与渲染内核同源(`getItemKey`):配置未提供 key 时同样按下标兜底,
      // 否则本索引存下的 key 与内核渲染出的 key 对不上,父级高亮与后代收起都会失准
      const key = getItemKey(node, index)
      map.set(key, { parents, isSubMenu })
      if (isSubMenu) {
        walk((node as SubMenuType).children, [...parents, key])
      }
    })
  }
  walk(displayItems.value, [])
  return map
})
// 含选中项的子菜单整链高亮:父级被选中态需要它(内嵌与浮层两种形态共用)
const selectedSubMenuKeys = computed(() => {
  const keys = new Set<MenuKey>()
  selectedKeysModel.value.forEach((key) => {
    keyMetaMap.value.get(key)?.parents.forEach((parent) => keys.add(parent))
  })
  return [...keys]
})

function isSameKeys(current: MenuKey[], next: MenuKey[]): boolean {
  return current.length === next.length && next.every((key) => current.includes(key))
}

// 后代 key:浮层型子菜单关闭时须一并收起,否则后代留在展开集合中形成「幽灵展开」
function collectDescendantKeys(key: MenuKey): MenuKey[] {
  const keys: MenuKey[] = []
  keyMetaMap.value.forEach((meta, itemKey) => {
    if (meta.parents.includes(key)) {
      keys.push(itemKey)
    }
  })
  return keys
}

function triggerOpenKeys(keys: MenuKey[]): void {
  openKeysModel.value = keys
  emit('openChange', keys)
}

function handleOpenChange(key: MenuKey, open: boolean): void {
  let keys = openKeysModel.value.filter((item) => item !== key)
  if (open) {
    keys.push(key)
  } else if (!isInlineMode.value) {
    const descendants = collectDescendantKeys(key)
    keys = keys.filter((item) => !descendants.includes(item))
  }
  if (!isSameKeys(keys, openKeysModel.value)) {
    triggerOpenKeys(keys)
  }
}

function handleItemClick(info: MenuInfo): void {
  emit('click', info)
  if (props.selectable) {
    const { key } = info
    const existed = selectedKeysModel.value.includes(key)
    const nextKeys = props.multiple
      ? existed
        ? selectedKeysModel.value.filter((item) => item !== key)
        : [...selectedKeysModel.value, key]
      : [key]
    if (!isSameKeys(nextKeys, selectedKeysModel.value)) {
      // 写模型即完成两种路径:受控时只回传(等外部更新后生效),非受控直接生效
      selectedKeysModel.value = nextKeys
      const menuSelectInfo: MenuSelectInfo = { ...info, selectedKeys: nextKeys }
      if (existed && props.multiple) {
        emit('deselect', menuSelectInfo)
      } else {
        emit('select', menuSelectInfo)
      }
    }
  }
  // 浮层型子菜单在选中后收起;多选不收起,便于连续勾选
  if (!isInlineMode.value && !props.multiple && openKeysModel.value.length) {
    triggerOpenKeys([])
  }
}

function handleTitleClick(item: SubMenuType, domEvent: MouseEvent): void {
  item.onTitleClick?.({ key: item.key, domEvent })
  if (isInlineMode.value) {
    handleOpenChange(item.key, !openKeysModel.value.includes(item.key))
  } else if (props.triggerSubMenuAction === 'click') {
    // 点击触发时同级只保留一个展开项,与「点击外部即收起」的语义一致
    triggerOpenKeys(openKeysModel.value.includes(item.key) ? [] : [item.key])
  }
}

// 展开集合在 inline 与浮层形态之间不可直接沿用(浮层态无处表达「内嵌展开」),故缓存后还原
const cachedOpenKeys = ref<MenuKey[]>([...openKeysModel.value])
let modeInitialized = false
watch(
  openKeysModel,
  (keys) => {
    if (isInlineMode.value) {
      cachedOpenKeys.value = [...keys]
    }
  },
  { immediate: true }
)
// 收起瞬间保留内嵌渲染:子菜单列表的收起动画要一个动画时长才播完,若当即换成浮层形态,
// 列表会整块消失、收起过程少一段「内容收拢」的动作(时长与样式表里的折叠过渡同源)
const INLINE_COLLAPSE_DURATION = 200
const retainingInline = ref(false)
let retainInlineTimer: ReturnType<typeof setTimeout> | undefined

function setRetainingInline(retaining: boolean): void {
  if (retainInlineTimer !== undefined) {
    clearTimeout(retainInlineTimer)
    retainInlineTimer = undefined
  }
  retainingInline.value = retaining
  if (retaining) {
    retainInlineTimer = setTimeout(() => {
      retainingInline.value = false
      retainInlineTimer = undefined
    }, INLINE_COLLAPSE_DURATION)
  }
}

watch(
  isInlineMode,
  (inline) => {
    if (!modeInitialized) {
      modeInitialized = true
      return
    }
    if (inline) {
      setRetainingInline(false)
      openKeysModel.value = [...cachedOpenKeys.value]
    } else if (openKeysModel.value.length) {
      setRetainingInline(true)
      triggerOpenKeys([])
    }
  },
  { immediate: true }
)

// 展开图标:插槽优先于属性(项目统一约定);图标类名由渲染内核统一补,与子菜单级 expandIcon 同源
const expandIcon = computed<MenuContext['expandIcon']['value']>(() => {
  const fromSlot = slots.expandIcon
  const fromProp = props.expandIcon
  if (!fromSlot && !fromProp) {
    return undefined
  }
  return (info: MenuExpandIconInfo): VNodeChild => (fromSlot ? fromSlot(info) : fromProp?.(info))
})

// 点击触发模式下,浮层需在点击菜单外部时收起
function handleDocumentMouseDown(event: MouseEvent): void {
  if (props.triggerSubMenuAction !== 'click' || isInlineMode.value || !openKeysModel.value.length) {
    return
  }
  const target = event.target as HTMLElement | null
  if (target?.closest('.menu-wrap') || target?.closest('.menu-submenu-popup')) {
    return
  }
  triggerOpenKeys([])
}
onMounted(() => {
  // 文档级监听在挂载后挂接:SSR 环境下没有 document
  if (typeof document !== 'undefined') {
    document.addEventListener('mousedown', handleDocumentMouseDown, true)
  }
})
onBeforeUnmount(() => {
  if (typeof document !== 'undefined') {
    document.removeEventListener('mousedown', handleDocumentMouseDown, true)
  }
  if (retainInlineTimer !== undefined) {
    clearTimeout(retainInlineTimer)
  }
})

provide<MenuContext>(menuContextKey, {
  mode: mergedMode,
  theme,
  inlineCollapsed: mergedInlineCollapsed,
  retainingInline,
  inlineIndent: computed(() => props.inlineIndent),
  disabled: computed(() => props.disabled),
  triggerSubMenuAction: computed(() => props.triggerSubMenuAction),
  subMenuOpenDelay: computed(() => props.subMenuOpenDelay),
  subMenuCloseDelay: computed(() => props.subMenuCloseDelay),
  openKeys: openKeysModel,
  selectedKeys: selectedKeysModel,
  activeKeys,
  changeActiveKeys,
  overflowStart,
  onOverflowChange: (start: number) => {
    overflowStart.value = start
  },
  overflowedIndicator: computed(() => slots.overflowedIndicator),
  forceSubMenuRender: computed(() => props.forceSubMenuRender),
  selectedSubMenuKeys,
  subMenuZIndex: FLOATING_LAYER_Z_INDEX.select,
  expandIcon,
  onOpenChange: handleOpenChange,
  onItemClick: handleItemClick,
  onTitleClick: handleTitleClick
})
</script>

<template>
  <MenuNodes :nodes="displayItems" is-root :level="1" :parent-keys="EMPTY_KEYS" />
</template>

<style lang="less">
/**
 * 样式统一落全局块:整棵菜单树由内部渲染内核(`MenuNodes`)递归生成,其元素不带本 SFC 的
 * scope id(与 `<Popup>` 宿主编译的面板壳同理,见 `Tooltip` 的全局块说明),scoped 规则匹配不到。
 * 为免 `menu-*` 裸类名外溢到宿主页面,除主题变量外,其余规则一律以 `.menu-wrap`(根列表)
 * 与 `.menu-submenu-popup` / `.menu-tooltip-popup`(浮层面板)三个锚点收口。
 */
/* 主题变量:`<ul>` 与浮层面板都会带上主题类,故两处都能取到值。
   主色直接消费公开令牌 `--va-menu-primary-*`:ConfigProvider 写在作用域节点上,浮层由边界转发带入,
   因此不需要在组件内声明私有主色变量 */
.menu-light {
  --menu-background: var(--va-bg-container-color);
  /* 弹出子菜单是浮层:暗色下取 elevated,避免与页面容器同色 */
  --menu-popup-background: var(--va-bg-elevated-color);
  --menu-color: var(--va-text-color-1);
  --menu-border-color: var(--va-border-split-color);
  /* 悬浮态只换底色、不换字色(字色与常态一致) */
  --menu-item-hover-color: var(--va-text-color-1);
  --menu-item-hover-background: var(--va-fill-color-2);
  /* 选中项是「主色文字」:优先跟随组件级主色文字桥,未配置时回落 primary-text 语义位 */
  --menu-item-selected-color: var(--va-menu-primary-text-color, var(--va-primary-text-color));
  /* 选中态底色取主色的最浅一阶:组件级主色浅底桥,未配置时回落主色浅底 */
  --menu-item-selected-background: var(--va-menu-primary-palette, var(--va-primary-bg-color));
  /* 水平菜单:浅色由下划线表达选中,故不着底色 */
  --menu-horizontal-selected-background: transparent;
  /* 水平菜单状态条(下划线)粗细 */
  --menu-horizontal-active-bar-height: 2px;
  /* 水平菜单容器下边界的厚度:浅色有一条边界,水平项需上移同量,状态条才压在边界上 */
  --menu-horizontal-active-bar-border-size: 1px;
  --menu-item-danger-color: var(--va-error-color);
  /* 危险项的悬浮字色:浅色下与常态同色 */
  --menu-item-danger-hover-color: var(--va-error-color);
  /* 危险项按下 / 选中时的底色:错误色的最浅一阶 */
  --menu-item-danger-active-background: var(--va-error-bg-color);
  --menu-item-danger-selected-background: var(--va-error-bg-color);
  --menu-item-danger-selected-color: var(--va-error-color);
  --menu-item-disabled-color: var(--va-text-color-disabled);
  --menu-group-title-color: var(--va-text-color-3);
  --menu-inline-submenu-background: var(--va-fill-color-1);
}
.menu-dark {
  --menu-background: var(--va-cp-menu-dark-bg-color, var(--va-bg-brand-dark-color, #001529));
  --menu-popup-background: var(--va-cp-menu-dark-bg-color, var(--va-bg-brand-dark-color, #001529));
  --menu-color: rgba(255, 255, 255, 0.65);
  --menu-border-color: rgba(255, 255, 255, 0.12);
  --menu-item-hover-color: #fff;
  --menu-item-hover-background: rgba(255, 255, 255, 0.08);
  --menu-item-selected-color: #fff;
  --menu-item-selected-background: var(--va-menu-primary-color, var(--va-primary-color));
  --menu-horizontal-selected-background: var(--va-menu-primary-color, var(--va-primary-color));
  --menu-horizontal-active-bar-height: 0;
  --menu-horizontal-active-bar-border-size: 0px;
  --menu-item-danger-color: var(--va-error-color);
  --menu-item-danger-hover-color: var(--va-error-color);
  --menu-item-danger-active-background: var(--va-error-color);
  --menu-item-danger-selected-background: var(--va-error-color);
  --menu-item-disabled-color: rgba(255, 255, 255, 0.25);
  --menu-group-title-color: rgba(255, 255, 255, 0.65);
  --menu-inline-submenu-background: var(--va-cp-menu-dark-bg-deep-color, var(--va-bg-brand-dark-deep-color, #000c17));
}
.menu-wrap {
  box-sizing: border-box;
  margin: 0;
  padding: 0;
  font-size: 14px;
  line-height: 1.5714285714285714;
  color: var(--menu-color);
  background-color: var(--va-menu-background, var(--menu-background));
  list-style: none;
  outline: none;
  transition: width 0.3s cubic-bezier(0.2, 0, 0, 1);
}
.menu-wrap.menu-root {
  margin: 0;
  padding: 0;
  list-style: none;
}
.menu-wrap.menu-horizontal {
  display: flex;
  flex-wrap: nowrap;
  line-height: 46px;
  border-bottom: 1px solid var(--menu-border-color);
}
.menu-wrap.menu-vertical,
.menu-wrap.menu-inline {
  width: 100%;
}
.menu-wrap.menu-light.menu-vertical,
.menu-wrap.menu-light.menu-inline {
  border-right: 1px solid var(--menu-border-color);
}
.menu-wrap.menu-vertical,
.menu-wrap.menu-inline,
.menu-submenu-list {
  &::before,
  &::after {
    display: table;
    content: '';
  }
  &::after {
    clear: both;
  }
}
.menu-submenu-popup {
  min-width: 160px;
  font-size: 14px;
  line-height: 1.5714285714285714;
  color: var(--menu-color);
  background-color: var(--va-menu-popup-background, var(--menu-popup-background));
  border-radius: 8px;
  box-shadow:
    0 3px 6px -4px rgba(0, 0, 0, 0.12),
    0 6px 16px 0 var(--va-shadow-color),
    0 9px 28px 8px rgba(0, 0, 0, 0.05);
}
.menu-tooltip-popup {
  --menu-tooltip-background: var(--va-bg-spotlight-color);
  width: max-content;
  pointer-events: none;
  &.va-popup-placement-right,
  &.va-popup-placement-rightTop,
  &.va-popup-placement-rightBottom {
    padding-left: 12px;
  }
  &.va-popup-placement-left,
  &.va-popup-placement-leftTop,
  &.va-popup-placement-leftBottom {
    padding-right: 12px;
  }
  .menu-tooltip-card {
    max-width: 250px;
    min-height: 32px;
    padding: 6px 8px;
    font-size: 14px;
    line-height: 1.5714285714285714;
    color: #fff;
    word-break: break-word;
    background-color: var(--menu-tooltip-background);
    border-radius: 6px;
    box-shadow:
      0 6px 16px 0 var(--va-shadow-color),
      0 3px 6px -4px rgba(0, 0, 0, 0.12),
      0 9px 28px 8px rgba(0, 0, 0, 0.05);
  }
  .menu-tooltip-arrow {
    position: absolute;
    z-index: 9;
    display: block;
    pointer-events: none;
    width: 16px;
    height: 16px;
    overflow: hidden;
    transition: top 0.2s cubic-bezier(0.645, 0.045, 0.355, 1);
    &::before {
      position: absolute;
      bottom: 0;
      left: 0;
      width: 16px;
      height: 8px;
      background-color: var(--menu-tooltip-background);
      clip-path: path(
        'M 0 8 A 4 4 0 0 0 2.82842712474619 6.82842712474619 L 6.585786437626905 3.0710678118654755 A 2 2 0 0 1 9.414213562373096 3.0710678118654755 L 13.17157287525381 6.82842712474619 A 4 4 0 0 0 16 8 Z'
      );
      content: '';
    }
    &::after {
      position: absolute;
      right: 0;
      bottom: 0;
      left: 0;
      width: 8.970562748477143px;
      height: 8.970562748477143px;
      margin: auto;
      border-radius: 0 0 2px 0;
      transform: translateY(50%) rotate(-135deg);
      box-shadow: 3px 3px 7px rgba(0, 0, 0, 0.1);
      z-index: -1;
      background: transparent;
      content: '';
    }
  }
  &.va-popup-placement-right .menu-tooltip-arrow,
  &.va-popup-placement-rightTop .menu-tooltip-arrow,
  &.va-popup-placement-rightBottom .menu-tooltip-arrow {
    top: calc(50% - 8px);
    left: 12px;
    transform: translateX(-100%) rotate(-90deg);
  }
  &.va-popup-placement-left .menu-tooltip-arrow,
  &.va-popup-placement-leftTop .menu-tooltip-arrow,
  &.va-popup-placement-leftBottom .menu-tooltip-arrow {
    top: calc(50% - 8px);
    right: 12px;
    transform: translateX(100%) rotate(90deg);
  }
  &.va-popup-placement-rightTop .menu-tooltip-arrow {
    top: 12px;
    transform: translateX(-100%) rotate(-90deg);
  }
  &.va-popup-placement-rightBottom .menu-tooltip-arrow {
    top: calc(100% - 28px);
    transform: translateX(-100%) rotate(-90deg);
  }
  &.va-popup-placement-leftTop .menu-tooltip-arrow {
    top: 12px;
    transform: translateX(100%) rotate(90deg);
  }
  &.va-popup-placement-leftBottom .menu-tooltip-arrow {
    top: calc(100% - 28px);
    transform: translateX(100%) rotate(90deg);
  }
}
.menu-wrap,
.menu-submenu-popup {
  .menu-submenu-list,
  .menu-item-group-list {
    margin: 0;
    padding: 0;
    list-style: none;
  }
  .menu-submenu-list {
    transition:
      background-color 0.3s cubic-bezier(0.645, 0.045, 0.355, 1),
      padding 0.3s cubic-bezier(0.645, 0.045, 0.355, 1);
  }
  .menu-submenu,
  .menu-item-group {
    margin: 0;
  }
  .menu-item-group-list {
    .menu-item,
    .menu-submenu-title {
      padding-left: 2em;
    }
  }
  .menu-item {
    position: relative;
    display: block;
    box-sizing: border-box;
    width: calc(100% - 8px);
    height: 40px;
    margin: 4px;
    padding: 0 16px;
    line-height: 40px;
    white-space: nowrap;
    overflow: hidden;
    text-overflow: ellipsis;
    color: var(--menu-color);
    border-radius: 8px;
    cursor: pointer;
    transition:
      color 0.3s,
      background-color 0.3s,
      border-color 0.3s,
      padding 0.3s cubic-bezier(0.645, 0.045, 0.355, 1);
    &:hover,
    &.menu-item-active {
      color: var(--menu-item-hover-color);
      background-color: var(--menu-item-hover-background);
    }
    &.menu-item-selected {
      color: var(--va-menu-item-selected-color, var(--menu-item-selected-color));
      background-color: var(--va-menu-item-selected-background, var(--menu-item-selected-background));
    }
    &.menu-item-disabled {
      color: var(--menu-item-disabled-color);
      background-color: transparent;
      cursor: not-allowed;
      &:hover {
        color: var(--menu-item-disabled-color);
        background-color: transparent;
      }
    }
  }
  .menu-item,
  .menu-submenu-title {
    a,
    a:hover {
      color: inherit;
      text-decoration: none;
    }
    a::before {
      position: absolute;
      inset: 0;
      background-color: transparent;
      content: '';
    }
  }
  .menu-item-danger {
    color: var(--va-error-color);
  }
  .menu-item-danger:not(.menu-item-selected):not(.menu-item-disabled):hover,
  .menu-item-danger:not(.menu-item-selected):not(.menu-item-disabled).menu-item-active {
    color: var(--menu-item-danger-hover-color);
  }
  .menu-item-danger.menu-item-selected:not(.menu-item-disabled) {
    color: var(--menu-item-danger-selected-color);
    background-color: var(--menu-item-danger-selected-background);
  }
  .menu-item-group-title {
    padding: 8px 16px;
    font-size: 14px;
    line-height: 1.5714285714285714;
    color: var(--menu-group-title-color);
    transition: all 0.3s;
  }
  .menu-item-divider {
    margin: 1px 0;
    overflow: hidden;
    line-height: 0;
    border-bottom: 1px solid var(--menu-border-color);
    &.menu-item-divider-dashed {
      border-bottom-style: dashed;
    }
  }
  .menu-title-content {
    transition: color 0.3s;
  }
  .menu-item-icon {
    display: inline-flex;
    align-items: center;
    min-width: 14px;
    font-size: 14px;
    vertical-align: -0.125em;
    transition:
      font-size 0.2s cubic-bezier(0.215, 0.61, 0.355, 1),
      margin 0.3s cubic-bezier(0.645, 0.045, 0.355, 1),
      color 0.3s;
    + .menu-title-content {
      margin-left: 10px;
      opacity: 1;
      transition:
        opacity 0.3s cubic-bezier(0.645, 0.045, 0.355, 1),
        margin 0.3s,
        color 0.3s;
    }
  }
  .menu-submenu {
    transition:
      border-color 0.3s cubic-bezier(0.645, 0.045, 0.355, 1),
      background-color 0.3s cubic-bezier(0.645, 0.045, 0.355, 1),
      padding 0.2s cubic-bezier(0.645, 0.045, 0.355, 1);
    &::after {
      display: table;
      content: '';
    }
  }
  .menu-submenu-title {
    position: relative;
    display: block;
    box-sizing: border-box;
    width: calc(100% - 8px);
    height: 40px;
    margin: 4px;
    padding: 0 34px 0 16px;
    line-height: 40px;
    white-space: nowrap;
    overflow: hidden;
    text-overflow: ellipsis;
    color: var(--menu-color);
    border-radius: 8px;
    cursor: pointer;
    transition:
      background-color 0.3s,
      border-color 0.3s,
      padding 0.3s cubic-bezier(0.645, 0.045, 0.355, 1);
    &:hover {
      color: var(--menu-item-hover-color);
      background-color: var(--menu-item-hover-background);
    }
  }
  .menu-submenu-selected > .menu-submenu-title {
    color: var(--va-menu-item-selected-color, var(--menu-item-selected-color));
  }
  .menu-submenu:not(.menu-submenu-disabled) > .menu-submenu-title:hover {
    color: var(--menu-item-hover-color);
    background-color: var(--menu-item-hover-background);
  }
  .menu-submenu-disabled > .menu-submenu-title {
    color: var(--menu-item-disabled-color);
    cursor: not-allowed;
  }
  .menu-submenu-arrow {
    position: absolute;
    top: 50%;
    right: 16px;
    width: 10px;
    color: currentColor;
    transform: translateY(-50%);
    transition:
      transform 0.3s cubic-bezier(0.645, 0.045, 0.355, 1),
      opacity 0.3s;
    &::before,
    &::after {
      position: absolute;
      width: 6px;
      height: 1.5px;
      background-color: currentColor;
      border-radius: 6px;
      transition:
        background 0.3s cubic-bezier(0.645, 0.045, 0.355, 1),
        transform 0.3s cubic-bezier(0.645, 0.045, 0.355, 1),
        top 0.3s cubic-bezier(0.645, 0.045, 0.355, 1),
        color 0.3s cubic-bezier(0.645, 0.045, 0.355, 1);
      content: '';
    }
    /* 默认指向右侧:纵向菜单与浮层内的子菜单共用 */
    &::before {
      transform: rotate(45deg) translateY(-2.5px);
    }
    &::after {
      transform: rotate(-45deg) translateY(2.5px);
    }
  }
}
.menu-wrap:not(.menu-horizontal),
.menu-submenu-popup {
  .menu-item:not(.menu-item-selected):not(.menu-item-disabled):active {
    background-color: var(--va-menu-item-selected-background, var(--menu-item-selected-background));
  }
  .menu-item-danger.menu-item:not(.menu-item-selected):not(.menu-item-disabled):active {
    background-color: var(--menu-item-danger-active-background);
  }
  .menu-submenu:not(.menu-submenu-disabled) > .menu-submenu-title:active {
    background-color: var(--va-menu-item-selected-background, var(--menu-item-selected-background));
  }
}
.menu-wrap.menu-vertical .menu-submenu-active:not(.menu-submenu-disabled) > .menu-submenu-title,
.menu-submenu-popup .menu-submenu-active:not(.menu-submenu-disabled) > .menu-submenu-title {
  color: var(--menu-item-hover-color);
  background-color: var(--menu-item-hover-background);
}
.menu-wrap.menu-inline {
  .menu-item,
  .menu-submenu-title {
    display: flex;
    align-items: center;
    transition:
      color 0.3s,
      background-color 0.3s,
      border-color 0.3s,
      padding 0.2s cubic-bezier(0.215, 0.61, 0.355, 1);
    > .menu-title-content {
      flex: auto;
      min-width: 0;
      overflow: hidden;
      text-overflow: ellipsis;
    }
    > * {
      flex: none;
    }
  }
}
.menu-wrap .menu-item.menu-item-selected,
.menu-submenu-popup .menu-item.menu-item-selected {
  transition:
    color 0.3s,
    border-color 0.3s,
    padding 0.3s cubic-bezier(0.645, 0.045, 0.355, 1);
}
.menu-wrap.menu-inline .menu-item.menu-item-selected {
  transition:
    color 0.3s,
    border-color 0.3s,
    padding 0.2s cubic-bezier(0.215, 0.61, 0.355, 1);
}
.menu-wrap.menu-inline .menu-submenu-list {
  background-color: var(--va-menu-inline-submenu-background, var(--menu-inline-submenu-background));
}
.menu-wrap.menu-inline .menu-submenu .menu-submenu-arrow {
  &::before {
    transform: rotate(-45deg) translateX(2.5px);
  }
  &::after {
    transform: rotate(45deg) translateX(-2.5px);
  }
}
.menu-wrap.menu-inline .menu-submenu-open > .menu-submenu-title > .menu-submenu-arrow {
  transform: translateY(-2px);
  &::before {
    transform: rotate(45deg) translateX(2.5px);
  }
  &::after {
    transform: rotate(-45deg) translateX(-2.5px);
  }
}
.menu-submenu-popup {
  .menu-item,
  .menu-submenu-title {
    border-radius: 4px;
  }
}
.menu-wrap.menu-horizontal {
  > .menu-item,
  > .menu-submenu {
    position: relative;
    flex: none;
    height: auto;
    margin: calc(-1 * var(--menu-horizontal-active-bar-border-size)) 0 0;
    padding: 0 16px;
    line-height: inherit;
    width: auto;
    border-radius: 0;
    overflow: visible;
    &::after {
      position: absolute;
      display: block;
      right: 16px;
      bottom: 0;
      left: 16px;
      border-bottom: var(--menu-horizontal-active-bar-height) solid transparent;
      transition: border-color 0.3s cubic-bezier(0.645, 0.045, 0.355, 1);
      content: '';
    }
  }
  > .menu-item {
    &:hover::after,
    &.menu-item-active::after,
    &.menu-item-selected::after {
      border-bottom-color: var(--va-menu-item-selected-color, var(--menu-item-selected-color));
    }
  }
  > .menu-submenu {
    &:hover::after,
    &.menu-submenu-active::after,
    &.menu-submenu-open::after,
    &.menu-submenu-selected::after {
      border-bottom-color: var(--va-menu-item-selected-color, var(--menu-item-selected-color));
    }
  }
  > .menu-item:hover,
  > .menu-item-active,
  > .menu-submenu > .menu-submenu-title:hover,
  > .menu-submenu-active > .menu-submenu-title {
    background-color: transparent;
  }
  > .menu-item.menu-item-selected,
  > .menu-submenu.menu-submenu-selected {
    background-color: var(--menu-horizontal-selected-background);
  }
  > .menu-submenu > .menu-submenu-title {
    height: 100%;
    margin: 0;
    padding: 0;
    line-height: inherit;
    width: auto;
    overflow: visible;
  }
  .menu-submenu-arrow {
    display: none;
  }
}
.menu-wrap.menu-dark.menu-horizontal {
  border-bottom: 0;
}
.menu-wrap.menu-horizontal > .menu-overflow-probe {
  position: absolute;
  top: 0;
  left: 0;
  visibility: hidden;
  pointer-events: none;
}
.menu-overflow-indicator {
  display: inline-block;
  vertical-align: -0.125em;
}
.menu-wrap.menu-inline-collapsed {
  width: var(--layout-sider-width, 80px);
  > .menu-item,
  > .menu-item-group > .menu-item-group-list > .menu-item,
  > .menu-item-group > .menu-item-group-list > .menu-submenu > .menu-submenu-title,
  > .menu-submenu > .menu-submenu-title {
    padding: 0 calc(50% - 12px);
    text-overflow: clip;
    .menu-submenu-arrow,
    .menu-submenu-expand-icon {
      opacity: 0;
    }
    .menu-item-icon {
      margin: 0;
      font-size: 16px;
      line-height: 40px;
      + .menu-title-content {
        display: inline-block;
        opacity: 0;
      }
    }
  }
  .menu-inline-collapsed-noicon {
    display: inline-block;
    font-size: 16px;
    line-height: 40px;
  }
  .menu-item-group-title {
    padding-inline: 8px;
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
  }
}
.menu-wrap .menu-submenu-list.menu-motion-collapse-enter-active,
.menu-wrap .menu-submenu-list.menu-motion-collapse-leave-active {
  overflow: hidden;
  transition:
    height 0.2s cubic-bezier(0.645, 0.045, 0.355, 1),
    opacity 0.2s cubic-bezier(0.645, 0.045, 0.355, 1);
}
.menu-zoom-enter,
.menu-zoom-leave {
  animation-duration: 0.2s;
  animation-fill-mode: both;
  animation-play-state: paused;
}
.menu-zoom-fast-enter,
.menu-zoom-fast-leave {
  animation-duration: 0.1s;
  animation-fill-mode: both;
  animation-play-state: paused;
}
.menu-zoom-fast-enter {
  scale: none;
  opacity: 0;
  animation-timing-function: cubic-bezier(0.08, 0.82, 0.17, 1);
}
.menu-zoom-fast-enter-active {
  animation-name: menu-zoom-in;
  animation-play-state: running;
}
.menu-zoom-fast-leave {
  animation-timing-function: cubic-bezier(0.78, 0.14, 0.15, 0.86);
}
.menu-zoom-fast-leave-active {
  animation-name: menu-zoom-out;
  animation-play-state: running;
}
.menu-slide-enter,
.menu-slide-leave {
  animation-duration: 0.2s;
  animation-fill-mode: both;
  animation-play-state: paused;
}
.menu-slide-enter {
  scale: 1 0.8;
  opacity: 0;
  animation-timing-function: cubic-bezier(0.23, 1, 0.32, 1);
}
.menu-slide-enter-active {
  animation-name: menu-slide-in;
  animation-play-state: running;
}
.menu-slide-leave {
  animation-timing-function: cubic-bezier(0.755, 0.05, 0.855, 0.06);
}
.menu-slide-leave-active {
  animation-name: menu-slide-out;
  animation-play-state: running;
}
@keyframes menu-slide-in {
  0% {
    scale: 1 0.8;
    opacity: 0;
  }
  100% {
    scale: 1;
    opacity: 1;
  }
}
@keyframes menu-slide-out {
  0% {
    scale: 1;
    opacity: 1;
  }
  100% {
    scale: 1 0.8;
    opacity: 0;
  }
}
.menu-zoom-enter {
  scale: none;
  opacity: 0;
  animation-timing-function: cubic-bezier(0.08, 0.82, 0.17, 1);
}
.menu-zoom-enter-active {
  animation-name: menu-zoom-in;
  animation-play-state: running;
}
.menu-zoom-leave {
  animation-timing-function: cubic-bezier(0.78, 0.14, 0.15, 0.86);
}
.menu-zoom-leave-active {
  animation-name: menu-zoom-out;
  animation-play-state: running;
}
@keyframes menu-zoom-in {
  0% {
    scale: 0.8;
    opacity: 0;
  }
  100% {
    scale: 1;
    opacity: 1;
  }
}
@keyframes menu-zoom-out {
  0% {
    scale: 1;
    opacity: 1;
  }
  100% {
    scale: 0.8;
    opacity: 0;
  }
}
</style>

在要使用的页面引入

ts 复制代码
<script setup lang="ts">
import Menu from './Menu.vue'
import { computed, h, ref } from 'vue'
import {
  AppstoreOutlined,
  CalendarOutlined,
  DesktopOutlined,
  DownOutlined,
  InboxOutlined,
  MailOutlined,
  MenuFoldOutlined,
  MenuUnfoldOutlined,
  MinusOutlined,
  PieChartOutlined,
  PlusOutlined,
  RightOutlined,
  SettingOutlined
} from '@ant-design/icons-vue'
import type { ItemType, MenuExpandIconInfo, MenuIcon, MenuKey } from 'vue-amazing-ui'
// 演示数据构造:图标以渲染函数给出
function getItem(label: string, key: string, icon?: MenuIcon, children?: ItemType[], type?: 'group'): ItemType {
  return { key, icon, children, label, type } as ItemType
}
// 顶部导航
const horizontalSelected = ref<MenuKey[]>(['mail'])
const horizontalItems: ItemType[] = [
  { key: 'mail', icon: () => h(MailOutlined), label: 'Navigation One', title: 'Navigation One' },
  { key: 'app', icon: () => h(AppstoreOutlined), label: 'Navigation Two', title: 'Navigation Two' },
  {
    key: 'sub1',
    icon: () => h(SettingOutlined),
    label: 'Navigation Three - Submenu',
    title: 'Navigation Three - Submenu',
    children: [
      getItem('Item 1', 'g1', undefined, [getItem('Option 1', 'setting:1'), getItem('Option 2', 'setting:2')], 'group'),
      getItem('Item 2', 'g2', undefined, [getItem('Option 3', 'setting:3'), getItem('Option 4', 'setting:4')], 'group')
    ]
  },
  { key: 'alipay', label: 'Navigation Four - Link', title: 'Navigation Four - Link' }
]
// 内嵌菜单
const inlineSelected = ref<MenuKey[]>(['1'])
const inlineOpenKeys = ref<MenuKey[]>(['sub1'])
const inlineItems: ItemType[] = [
  getItem('Navigation One', 'sub1', () => h(MailOutlined), [
    getItem('Item 1', 'g1', undefined, [getItem('Option 1', '1'), getItem('Option 2', '2')], 'group'),
    getItem('Item 2', 'g2', undefined, [getItem('Option 3', '3'), getItem('Option 4', '4')], 'group')
  ]),
  getItem('Navigation Two', 'sub2', () => h(AppstoreOutlined), [
    getItem('Option 5', '5'),
    getItem('Option 6', '6'),
    getItem('Submenu', 'sub3', undefined, [getItem('Option 7', '7'), getItem('Option 8', '8')])
  ]),
  { type: 'divider' },
  getItem('Navigation Three', 'sub4', () => h(SettingOutlined), [
    getItem('Option 9', '9'),
    getItem('Option 10', '10'),
    getItem('Option 11', '11'),
    getItem('Option 12', '12')
  ]),
  getItem('Group', 'grp', undefined, [getItem('Option 13', '13'), getItem('Option 14', '14')], 'group')
]
// 垂直菜单
const verticalSelected = ref<MenuKey[]>(['1'])
const verticalOpenKeys = ref<MenuKey[]>(['sub1'])
const verticalItems: ItemType[] = [
  { key: '1', icon: () => h(MailOutlined), label: 'Navigation One', title: 'Navigation One' },
  { key: '2', icon: () => h(CalendarOutlined), label: 'Navigation Two', title: 'Navigation Two' },
  {
    key: 'sub1',
    icon: () => h(AppstoreOutlined),
    label: 'Navigation Three',
    title: 'Navigation Three',
    children: [
      { key: '3', label: 'Option 3', title: 'Option 3' },
      { key: '4', label: 'Option 4', title: 'Option 4' },
      {
        key: 'sub1-2',
        label: 'Submenu',
        title: 'Submenu',
        children: [
          { key: '5', label: 'Option 5', title: 'Option 5' },
          { key: '6', label: 'Option 6', title: 'Option 6' }
        ]
      }
    ]
  },
  {
    key: 'sub2',
    icon: () => h(SettingOutlined),
    label: 'Navigation Four',
    title: 'Navigation Four',
    children: [
      { key: '7', label: 'Option 7', title: 'Option 7' },
      { key: '8', label: 'Option 8', title: 'Option 8' },
      { key: '9', label: 'Option 9', title: 'Option 9' },
      { key: '10', label: 'Option 10', title: 'Option 10' }
    ]
  }
]
// 切换菜单类型
const switchMode = ref(false)
const switchTheme = ref(false)
const switchSelected = ref<MenuKey[]>(['1'])
const switchOpenKeys = ref<MenuKey[]>(['sub1'])
const switchItems: ItemType[] = [
  getItem('Navigation One', '1', () => h(MailOutlined)),
  getItem('Navigation Two', '2', () => h(CalendarOutlined)),
  getItem('Navigation Three', 'sub1', () => h(AppstoreOutlined), [
    getItem('Option 3', '3'),
    getItem('Option 4', '4'),
    getItem('Submenu', 'sub1-2', undefined, [getItem('Option 5', '5'), getItem('Option 6', '6')])
  ]),
  getItem('Navigation Four', 'sub2', () => h(SettingOutlined), [
    getItem('Option 7', '7'),
    getItem('Option 8', '8'),
    getItem('Option 9', '9'),
    getItem('Option 10', '10')
  ])
]
// 子菜单展开触发方式与开合延时
const triggerHover = ref(true)
const triggerDelaySelected = ref<MenuKey[]>(['1'])
const triggerDelayItems: ItemType[] = [
  getItem('Navigation One', '1', () => h(MailOutlined)),
  getItem('Navigation Two', 'sub1', () => h(AppstoreOutlined), [
    getItem('Option 1', '1-1'),
    getItem('Option 2', '1-2')
  ]),
  getItem('Navigation Three', 'sub2', () => h(SettingOutlined), [
    getItem('Option 3', '2-1'),
    getItem('Option 4', '2-2')
  ])
]
// 自定义缩进:同一份层级结构并排对比默认 24 与 32,两个实例各自持展开状态
const indentDefaultOpenKeys = ref<MenuKey[]>(['sub1', 'sub1-2'])
const indentWideOpenKeys = ref<MenuKey[]>(['sub1', 'sub1-2'])
const indentItems: ItemType[] = [
  getItem('Navigation One', 'sub1', () => h(MailOutlined), [
    getItem('Option 1', '1'),
    getItem('Submenu', 'sub1-2', undefined, [getItem('Option 2', '2')])
  ]),
  getItem('Navigation Two', '2', () => h(AppstoreOutlined))
]
// 缩起内嵌菜单
const collapsed = ref(false)
const collapsedSelected = ref<MenuKey[]>(['1'])
const collapsedOpenKeys = ref<MenuKey[]>(['sub1'])
const collapsedItems: ItemType[] = [
  { key: '1', icon: () => h(PieChartOutlined), label: 'Option 1', title: 'Option 1' },
  { key: '2', icon: () => h(DesktopOutlined), label: 'Option 2', title: 'Option 2' },
  { key: '3', icon: () => h(InboxOutlined), label: 'Option 3', title: 'Option 3' },
  {
    key: 'sub1',
    icon: () => h(MailOutlined),
    label: 'Navigation One',
    title: 'Navigation One',
    children: [
      { key: '5', label: 'Option 5', title: 'Option 5' },
      { key: '6', label: 'Option 6', title: 'Option 6' },
      { key: '7', label: 'Option 7', title: 'Option 7' },
      { key: '8', label: 'Option 8', title: 'Option 8' }
    ]
  },
  {
    key: 'sub2',
    icon: () => h(AppstoreOutlined),
    label: 'Navigation Two',
    title: 'Navigation Two',
    children: [
      { key: '9', label: 'Option 9', title: 'Option 9' },
      { key: '10', label: 'Option 10', title: 'Option 10' },
      {
        key: 'sub3',
        label: 'Submenu',
        title: 'Submenu',
        children: [
          { key: '11', label: 'Option 11', title: 'Option 11' },
          { key: '12', label: 'Option 12', title: 'Option 12' }
        ]
      }
    ]
  }
]
// 收起态长标题提示:标题长到换行 ⇒ 提示面板比菜单项更高
const longTitleSelected = ref<MenuKey[]>(['1'])
const longTitleItems: ItemType[] = [
  {
    key: '1',
    icon: () => h(PieChartOutlined),
    label: '很长的标题 One:这段文案刻意写长,用于让收起态的悬浮提示折成多行,展示提示内容超出菜单项时的换行效果',
    title: '很长的标题 One:这段文案刻意写长,用于让收起态的悬浮提示折成多行,展示提示内容超出菜单项时的换行效果'
  },
  {
    key: '2',
    icon: () => h(DesktopOutlined),
    label: '较长的标题 Two:这段文案也写得较长,悬浮提示会折成多行,展示提示折成两行时的形态',
    title: '较长的标题 Two:这段文案也写得较长,悬浮提示会折成多行,展示提示折成两行时的形态'
  },
  {
    key: '3',
    icon: () => h(InboxOutlined),
    label: '很长的标题 Three:这段文案刻意写长,用于让收起态的悬浮提示折成多行,展示提示内容超出菜单项时的换行效果',
    title: '很长的标题 Three:这段文案刻意写长,用于让收起态的悬浮提示折成多行,展示提示内容超出菜单项时的换行效果'
  },
  {
    key: '4',
    icon: () => h(MailOutlined),
    label: '较长的标题 Four:这段文案也写得较长,悬浮提示会折成多行,展示提示折成两行时的形态',
    title: '较长的标题 Four:这段文案也写得较长,悬浮提示会折成多行,展示提示折成两行时的形态'
  },
  {
    key: '5',
    icon: () => h(AppstoreOutlined),
    label: '很长的标题 Five:这段文案刻意写长,用于让收起态的悬浮提示折成多行,展示提示内容超出菜单项时的换行效果',
    title: '很长的标题 Five:这段文案刻意写长,用于让收起态的悬浮提示折成多行,展示提示内容超出菜单项时的换行效果'
  }
]
// 只展开当前父级菜单
const rootSubmenuKeys: MenuKey[] = ['sub1', 'sub2', 'sub4']
const parentOpenKeys = ref<MenuKey[]>(['sub1'])
const parentSelected = ref<MenuKey[]>([])
const parentItems: ItemType[] = [
  getItem('Navigation One', 'sub1', () => h(MailOutlined), [
    getItem('Option 1', '1'),
    getItem('Option 2', '2'),
    getItem('Option 3', '3'),
    getItem('Option 4', '4')
  ]),
  getItem('Navigation Two', 'sub2', () => h(AppstoreOutlined), [
    getItem('Option 5', '5'),
    getItem('Option 6', '6'),
    getItem('Submenu', 'sub3', undefined, [getItem('Option 7', '7'), getItem('Option 8', '8')])
  ]),
  getItem('Navigation Three', 'sub4', () => h(SettingOutlined), [
    getItem('Option 9', '9'),
    getItem('Option 10', '10'),
    getItem('Option 11', '11'),
    getItem('Option 12', '12')
  ])
]
// 展开集合受控:仅保留最新展开的顶层子菜单,其余一并收起
function onParentOpenChange(openKeys: MenuKey[]) {
  const latestOpenKey = openKeys.find((key) => !parentOpenKeys.value.includes(key))
  if (latestOpenKey === undefined || !rootSubmenuKeys.includes(latestOpenKey)) {
    parentOpenKeys.value = openKeys
  } else {
    parentOpenKeys.value = [latestOpenKey]
  }
}
// 多选
const multipleSelected = ref<MenuKey[]>(['1'])
const multipleItems: ItemType[] = [
  { key: '1', label: 'Option 1' },
  { key: '2', label: 'Option 2' },
  { key: '3', label: 'Option 3' },
  {
    key: 'sub1',
    label: 'Navigation',
    children: [
      { key: '4', label: 'Option 4' },
      { key: '5', label: 'Option 5' }
    ]
  }
]
// 禁用
const disabledSelected = ref<MenuKey[]>(['1'])
const wholeDisabled = ref(false)
const disabledItems: ItemType[] = [
  { key: '1', label: 'Option 1' },
  { key: '2', label: 'Option 2(disabled)', disabled: true },
  { key: '3', label: 'Option 3(danger)', danger: true },
  { key: '4', label: 'Option 4', disabled: true }
]
// 空数据
const emptyItems: ItemType[] = []
// 自定义展开图标
const expandIconSelected = ref<MenuKey[]>(['1'])
const expandIconOpenKeys = ref<MenuKey[]>(['sub1'])
const expandIconItems: ItemType[] = [
  getItem('Navigation One', 'sub1', () => h(MailOutlined), [
    getItem('Option 1', '1'),
    getItem('Option 2', '2'),
    getItem('Submenu', 'sub2', undefined, [getItem('Option 3', '3'), getItem('Option 4', '4')])
  ]),
  getItem('Navigation Two', 'sub3', () => h(AppstoreOutlined), [getItem('Option 5', '5'), getItem('Option 6', '6')])
]
// 属性形态的展开图标:与插槽形态等价,渲染函数入参含所在子菜单配置与 isOpen
const expandIconByProp = (info: MenuExpandIconInfo) =>
  h(RightOutlined, { class: ['demo-expand-icon', { 'demo-expand-icon-open': info.isOpen }] })
const expandIconPropOpenKeys = ref<MenuKey[]>(['sub1'])
// 主题:深色皮肤的底色由主题色派生,故颜色选择器直接绑定作用域种子
const themePrimary = ref('#1677ff')
const themeDark = ref(true)
const themeSelected = ref<MenuKey[]>(['1'])
const themeOpenKeys = ref<MenuKey[]>(['sub1'])
const themeItems: ItemType[] = [
  { key: '1', icon: () => h(MailOutlined), label: 'Navigation One', title: 'Navigation One' },
  { key: '2', icon: () => h(CalendarOutlined), label: 'Navigation Two', title: 'Navigation Two' },
  {
    key: 'sub1',
    icon: () => h(AppstoreOutlined),
    label: 'Navigation Three',
    title: 'Navigation Three',
    children: [
      { key: '3', label: 'Option 3', title: 'Option 3' },
      { key: '4', label: 'Option 4', title: 'Option 4' },
      {
        key: 'sub1-2',
        label: 'Submenu',
        title: 'Submenu',
        children: [
          { key: '5', label: 'Option 5', title: 'Option 5' },
          { key: '6', label: 'Option 6', title: 'Option 6' }
        ]
      }
    ]
  },
  {
    key: 'sub2',
    icon: () => h(SettingOutlined),
    label: 'Navigation Four',
    title: 'Navigation Four',
    children: [
      { key: '7', label: 'Option 7', title: 'Option 7' },
      { key: '8', label: 'Option 8', title: 'Option 8' },
      { key: '9', label: 'Option 9', title: 'Option 9' },
      { key: '10', label: 'Option 10', title: 'Option 10' }
    ]
  }
]
// 子菜单主题:根目录深色、子目录浅色(子菜单 theme 随开关变化)
const submenuDark = ref(false)
const submenuSelected = ref<MenuKey[]>(['1'])
const submenuOpenKeys = ref<MenuKey[]>(['sub1'])
const submenuItems = computed<ItemType[]>(() => [
  {
    key: 'sub1',
    icon: () => h(MailOutlined),
    label: 'Navigation One',
    title: 'Navigation One',
    theme: submenuDark.value ? 'dark' : 'light',
    children: [
      { key: '1', label: 'Option 1', title: 'Option 1' },
      { key: '2', label: 'Option 2', title: 'Option 2' },
      { key: '3', label: 'Option 3', title: 'Option 3' }
    ]
  },
  { key: '5', label: 'Option 5', title: 'Option 5' },
  { key: '6', label: 'Option 6', title: 'Option 6' }
])
// 组件式用法
const componentSelected = ref<MenuKey[]>(['1'])
const componentOpenKeys = ref<MenuKey[]>(['sub2'])
// 组件式自定义展开图标:子菜单级 expandIcon 优先,未提供的子菜单回落 Menu 级
const componentExpandIconSelected = ref<MenuKey[]>(['1'])
const componentExpandIconOpenKeys = ref<MenuKey[]>(['sub1'])
// 水平溢出指示器:容器放不下的项收进省略子菜单,其指示器由 overflowedIndicator 插槽自定义
const overflowItems: ItemType[] = [
  { key: 'mail', label: 'Navigation One', title: 'Navigation One' },
  { key: 'app', label: 'Navigation Two', title: 'Navigation Two' },
  { key: 'calendar', label: 'Navigation Three', title: 'Navigation Three' },
  { key: 'setting', label: 'Navigation Four', title: 'Navigation Four' },
  { key: 'desktop', label: 'Navigation Five', title: 'Navigation Five' }
]
// 子菜单预渲染:开启后子菜单面板在首次展开前就已渲染进 DOM(保持不可见)
const forceRenderEnabled = ref(false)
const forceRenderItems: ItemType[] = [
  { key: '1', icon: () => h(MailOutlined), label: 'Navigation One', title: 'Navigation One' },
  {
    key: 'sub1',
    icon: () => h(AppstoreOutlined),
    label: 'Navigation Two',
    title: 'Navigation Two',
    children: [
      { key: '2', label: 'Option 2', title: 'Option 2' },
      { key: '3', label: 'Option 3', title: 'Option 3' }
    ]
  },
  {
    key: 'sub2',
    icon: () => h(SettingOutlined),
    label: 'Navigation Three',
    title: 'Navigation Three',
    children: [
      { key: '4', label: 'Option 4', title: 'Option 4' },
      { key: '5', label: 'Option 5', title: 'Option 5' }
    ]
  }
]
</script>
<template>
  <div>
    <h1>{{ $route.name }} {{ $route.meta.title }}</h1>
    <h2 class="mt30 mb10">顶部导航</h2>
    <p class="mb10">水平的顶部导航菜单</p>
    <Menu v-model:selectedKeys="horizontalSelected" mode="horizontal" :items="horizontalItems" />
    <h2 class="mt30 mb10">内嵌菜单</h2>
    <p class="mb10">子菜单内嵌在菜单区域内,可逐级展开</p>
    <Menu
      v-model:openKeys="inlineOpenKeys"
      v-model:selectedKeys="inlineSelected"
      style="width: 256px"
      mode="inline"
      :items="inlineItems"
    />
    <h2 class="mt30 mb10">垂直菜单</h2>
    <p class="mb10">子菜单以弹出形式展示,支持多级嵌套</p>
    <Menu
      v-model:openKeys="verticalOpenKeys"
      v-model:selectedKeys="verticalSelected"
      style="width: 256px"
      mode="vertical"
      :items="verticalItems"
    />
    <h2 class="mt30 mb10">切换菜单类型</h2>
    <p class="mb10">动态切换菜单模式与主题</p>
    <Flex vertical>
      <Flex align="center">
        <Switch v-model:value="switchMode" checked="vertical" unchecked="inline" />
        <Switch v-model:value="switchTheme" checked="dark" unchecked="light" />
      </Flex>
      <Menu
        v-model:openKeys="switchOpenKeys"
        v-model:selectedKeys="switchSelected"
        style="width: 256px"
        :mode="switchMode ? 'vertical' : 'inline'"
        :theme="switchTheme ? 'dark' : 'light'"
        :items="switchItems"
      />
    </Flex>
    <h2 class="mt30 mb10">子菜单展开触发与延时</h2>
    <p class="mb10"
      >切换子菜单的展开触发方式;<code>hover</code> 触发时,<code>subMenuOpenDelay</code> /
      <code>subMenuCloseDelay</code> 分别决定展开与收起的延时</p
    >
    <Space vertical>
      <Switch v-model:value="triggerHover" checked="hover" unchecked="click" />
      <Menu
        v-model:selectedKeys="triggerDelaySelected"
        style="width: 256px"
        mode="vertical"
        :trigger-sub-menu-action="triggerHover ? 'hover' : 'click'"
        :sub-menu-open-delay="0.3"
        :sub-menu-close-delay="0.8"
        :items="triggerDelayItems"
      />
    </Space>
    <h2 class="mt30 mb10">自定义缩进</h2>
    <p class="mb10"
      ><code>inline</code> 模式下 <code>inlineIndent</code> 决定每一级菜单项的缩进宽度,左侧为默认
      <code>24</code>、右侧为 <code>32</code></p
    >
    <Flex wrap="wrap" :gap="24">
      <Menu v-model:openKeys="indentDefaultOpenKeys" style="width: 256px" mode="inline" :items="indentItems" />
      <Menu
        v-model:openKeys="indentWideOpenKeys"
        style="width: 256px"
        mode="inline"
        :inline-indent="32"
        :items="indentItems"
      />
    </Flex>
    <h2 class="mt30 mb10">缩起内嵌菜单</h2>
    <p class="mb10">收起时只显示图标,子菜单以浮层展示,悬浮菜单项可查看完整标题</p>
    <!-- 宽度交给外层容器:内嵌菜单收起后自身宽度收到 80px,若把宽度写在菜单上会把它钉死在展开宽度 -->
    <Space vertical style="width: 256px">
      <Button type="primary" @click="collapsed = !collapsed">
        <MenuUnfoldOutlined v-if="collapsed" />
        <MenuFoldOutlined v-else />
      </Button>
      <Menu
        v-model:openKeys="collapsedOpenKeys"
        v-model:selectedKeys="collapsedSelected"
        mode="inline"
        theme="dark"
        :inline-collapsed="collapsed"
        :items="collapsedItems"
      />
    </Space>
    <h2 class="mt30 mb10">收起态长标题提示</h2>
    <p class="mb10"
      >提示取
      <code>title</code
      >,长标题会让提示折成多行(面板高于菜单项);把某项滚动到窗口上/下缘附近再悬浮,提示面板会贴边完整显示,箭头依然对准该项</p
    >
    <!-- 标题写长使提示折成多行:提示面板高于菜单项,箭头仍指向该项 -->
    <div style="width: 256px">
      <Menu
        v-model:selectedKeys="longTitleSelected"
        mode="inline"
        theme="dark"
        :inline-collapsed="true"
        :items="longTitleItems"
      />
    </div>
    <h2 class="mt30 mb10">只展开当前父级菜单</h2>
    <p class="mb10">点击菜单时收起其他已展开的菜单,保持菜单聚焦简洁</p>
    <Menu
      style="width: 256px"
      mode="inline"
      :items="parentItems"
      :open-keys="parentOpenKeys"
      v-model:selectedKeys="parentSelected"
      @open-change="onParentOpenChange"
    />
    <h2 class="mt30 mb10">多选</h2>
    <p class="mb10">通过 <code>multiple</code> 允许多选,再次点击已选中项会触发取消选中</p>
    <Menu v-model:selectedKeys="multipleSelected" style="width: 256px" mode="inline" multiple :items="multipleItems" />
    <h2 class="mt30 mb10">禁用</h2>
    <p class="mb10">
      通过 <code>disabled</code> 禁用整个菜单;菜单项自身的 <code>disabled</code> 只禁用该项,<code>danger</code>
      用于呈现错误状态样式
    </p>
    <Space vertical>
      <Switch v-model:value="wholeDisabled" checked="禁用" unchecked="可用" />
      <Menu
        v-model:selectedKeys="disabledSelected"
        style="width: 256px"
        mode="inline"
        :disabled="wholeDisabled"
        :items="disabledItems"
      />
    </Space>
    <h2 class="mt30 mb10">空数据</h2>
    <p class="mb10">菜单内容为空时渲染空列表,不报错也不残留占位</p>
    <Menu style="width: 256px" mode="inline" :items="emptyItems" />
    <h2 class="mt30 mb10">自定义展开图标</h2>
    <p class="mb10"
      >通过
      <code>expandIcon</code> 属性或同名插槽自定义子菜单的展开收起图标:上方为插槽形态、下方为属性形态,两者等价</p
    >
    <Menu
      v-model:openKeys="expandIconOpenKeys"
      v-model:selectedKeys="expandIconSelected"
      style="width: 256px"
      mode="inline"
      :items="expandIconItems"
    >
      <template #expandIcon="{ isOpen }">
        <RightOutlined class="demo-expand-icon" :class="{ 'demo-expand-icon-open': isOpen }" />
      </template>
    </Menu>
    <Menu
      v-model:openKeys="expandIconPropOpenKeys"
      style="width: 256px; margin-top: 16px"
      mode="inline"
      :items="expandIconItems"
      :expand-icon="expandIconByProp"
    />
    <h2 class="mt30 mb10">组件式用法</h2>
    <p class="mb10">
      以 <code>MenuItem</code> / <code>MenuSubMenu</code> / <code>MenuItemGroup</code> /
      <code>MenuDivider</code> 子组件描述菜单结构,与 <code>items</code> 配置等价;两者同时提供时以子组件为准
    </p>
    <Menu
      v-model:openKeys="componentOpenKeys"
      v-model:selectedKeys="componentSelected"
      style="width: 256px"
      mode="inline"
    >
      <MenuItem key="1">
        <template #icon><MailOutlined /></template>
        Option 1
      </MenuItem>
      <MenuSubMenu key="sub2">
        <template #icon><AppstoreOutlined /></template>
        <template #title>Navigation Two</template>
        <MenuItem key="2">Option 2</MenuItem>
        <MenuItem key="3">Option 3</MenuItem>
        <MenuSubMenu key="sub2-1" title="Submenu">
          <MenuItem key="4">Option 4</MenuItem>
          <MenuItem key="5">Option 5</MenuItem>
        </MenuSubMenu>
      </MenuSubMenu>
      <MenuDivider />
      <MenuItemGroup title="Group">
        <MenuItem key="6">Option 6</MenuItem>
        <MenuItem key="7">Option 7</MenuItem>
      </MenuItemGroup>
      <MenuItem key="8" disabled>Option 8</MenuItem>
    </Menu>
    <h2 class="mt30 mb10">组件式自定义展开图标</h2>
    <p class="mb10">
      子菜单的 <code>expandIcon</code> 插槽优先于 <code>Menu</code> 的同名插槽,未提供时回落到 <code>Menu</code> 级图标
    </p>
    <Menu
      v-model:openKeys="componentExpandIconOpenKeys"
      v-model:selectedKeys="componentExpandIconSelected"
      style="width: 256px"
      mode="inline"
    >
      <template #expandIcon="{ isOpen }">
        <RightOutlined class="demo-expand-icon" :class="{ 'demo-expand-icon-open': isOpen }" />
      </template>
      <MenuSubMenu key="sub1" title="Navigation One">
        <template #icon><MailOutlined /></template>
        <template #expandIcon="{ isOpen }">
          <span class="demo-expand-switch">
            <PlusOutlined :class="{ 'demo-expand-switch-hidden': isOpen }" />
            <MinusOutlined :class="{ 'demo-expand-switch-hidden': !isOpen }" />
          </span>
        </template>
        <MenuItem key="1">Option 1</MenuItem>
        <MenuItem key="2">Option 2</MenuItem>
      </MenuSubMenu>
      <MenuSubMenu key="sub3" title="Navigation Three">
        <template #icon><SettingOutlined /></template>
        <MenuItem key="3">Option 3</MenuItem>
        <MenuItem key="4">Option 4</MenuItem>
      </MenuSubMenu>
    </Menu>
    <h2 class="mt30 mb10">水平溢出指示器</h2>
    <p class="mb10">
      水平空间不足时,放不下的菜单项会收进省略子菜单;其指示器由
      <code>overflowedIndicator</code> 插槽自定义(未提供时使用内置三点图标)
    </p>
    <div class="demo-overflow-box">
      <Menu mode="horizontal" :items="overflowItems">
        <template #overflowedIndicator>
          <DownOutlined />
        </template>
      </Menu>
    </div>
    <h2 class="mt30 mb10">子菜单预渲染</h2>
    <p class="mb10">
      开启 <code>forceSubMenuRender</code> 后,子菜单面板在首次展开前就已渲染进 DOM(保持不可见),首次展开因此
      无需等待挂载;可用开发者工具查看未展开的子菜单下是否已存在 <code>.menu-submenu-popup</code> 面板
    </p>
    <Space vertical>
      <Switch v-model:value="forceRenderEnabled" checked="On" unchecked="Off" />
      <Menu
        style="width: 256px"
        mode="vertical"
        :force-sub-menu-render="forceRenderEnabled"
        :items="forceRenderItems"
      />
    </Space>
    <h2 class="mt30 mb10">主题</h2>
    <p class="mb10"
      >内建 <code>light</code> 与 <code>dark</code> 两套主题,默认为
      <code>light</code>;深色皮肤的底色由主题色派生,改主色时根列表、浮层与内联子菜单同步换色相</p
    >
    <Flex vertical>
      <Space align="center">
        <Switch v-model:value="themeDark" checked="Dark" unchecked="Light" />
        <!-- 主题色只作用于本用例:作用域种子随选择器变化,深色皮肤的派生底色随之更新 -->
        <ColorPicker v-model:value="themePrimary" :show-alpha="false" style="width: 160px" />
      </Space>
      <ConfigProvider :theme="{ seed: { primary: themePrimary } }">
        <Menu
          v-model:openKeys="themeOpenKeys"
          v-model:selectedKeys="themeSelected"
          style="width: 256px"
          mode="inline"
          :theme="themeDark ? 'dark' : 'light'"
          :items="themeItems"
        />
      </ConfigProvider>
    </Flex>
    <h2 class="mt30 mb10">子菜单主题</h2>
    <p class="mb10">通过子菜单的 <code>theme</code> 属性设置其主题,可实现根目录深色、子目录浅色的效果</p>
    <Space vertical>
      <Switch v-model:value="submenuDark" checked="Dark" unchecked="Light" />
      <Menu
        style="width: 256px"
        mode="vertical"
        theme="dark"
        v-model:openKeys="submenuOpenKeys"
        v-model:selectedKeys="submenuSelected"
        :items="submenuItems"
      />
    </Space>
  </div>
</template>
<style lang="less" scoped>
/* 展开图标用「单个箭头旋转 90°」表达展开态:直接切换图标会有一次瞬跳,旋转则连续过渡 */
.demo-expand-icon {
  transition: transform 0.3s cubic-bezier(0.645, 0.045, 0.355, 1);
  &.demo-expand-icon-open {
    transform: rotate(90deg);
  }
}
/* 加号 / 减号叠放在同一位置,靠「旋转 + 淡出」互换:若并列放置,过渡期间会同时出现两个图标并把标题挤动 */
.demo-expand-switch {
  position: relative;
  display: inline-block;
  width: 1em;
  height: 1em;
  > * {
    position: absolute;
    top: 0;
    left: 0;
    transition:
      opacity 0.3s cubic-bezier(0.645, 0.045, 0.355, 1),
      transform 0.3s cubic-bezier(0.645, 0.045, 0.355, 1);
    &.demo-expand-switch-hidden {
      opacity: 0;
      transform: rotate(-90deg);
    }
  }
}
/* 水平溢出用例的限宽容器:宽度不足以容纳全部菜单项时才会触发溢出省略 */
.demo-overflow-box {
  width: 480px;
  max-width: 100%;
}
</style>
相关推荐
theMuseCatcher17 小时前
Vue3评论(Comment)
typescript·vue3
afumu1 天前
把五个网页版 AI 拉进同一个群聊:不用 API key 的多 Agent 编排
人工智能·chrome·typescript
RuoyiOffice2 天前
Vue3+Gantt:把任务排期、依赖关系和里程碑放进项目管理
项目管理·vue3·springboot·dhtmlx-gantt·wbs·gantt·ruoyi office
孟陬3 天前
Foobar 技术周刊#160:大模型让大规模形式化软件验证成为现实丨AI写C++代码之后会发生什么?丨我们真的需要alpha() 函数吗丨重试,有时会让故障更
前端·后端·typescript
AI开源软件4 天前
SpringBoot3+Vue3 财务管理系统如何设计:从业务单据到总账的模块划分
spring boot·vue3·领域建模·会计凭证·ruoyi office·业财协同·财务系统设计
螺丝钉tina4 天前
[Playwright ][16]Playwright + TypeScript API 自动化测试实战:API测试
前端·javascript·typescript
华玥作者4 天前
Vue 3 + TypeScript + uni-app 组件库推荐:hy-app(华玥组件库)
vue.js·typescript·uni-app
Momo__5 天前
你写的 import 重试,从来没生效过
前端·javascript·typescript
长不大的杨瑞杰6 天前
OpenCode v2 Custom Tools 实战:开发数据库、工单与发布工具
人工智能·typescript·开发工具·opencode·custom tools