Files
art-design/src/components/core/others/art-menu-right/index.vue
T
2026-01-10 10:10:57 +08:00

419 lines
10 KiB
Vue

<!-- 右键菜单 -->
<template>
<div class="menu-right">
<Transition name="context-menu" @before-enter="onBeforeEnter" @after-leave="onAfterLeave">
<div
v-show="visible"
:style="menuStyle"
class="context-menu art-card-xs !shadow-xl min-w-[var(--menu-width)] w-[var(--menu-width)]"
>
<ul class="menu-list m-0 list-none" :style="menuListStyle">
<template v-for="item in menuItems" :key="item.key">
<!-- 普通菜单项 -->
<li
v-if="!item.children"
class="menu-item relative flex-c c-p select-none rounded text-xs transition-colors duration-150 hover:bg-g-200"
:class="{ 'is-disabled': item.disabled, 'has-line': item.showLine }"
:style="menuItemStyle"
@click="handleMenuClick(item)"
>
<ArtSvgIcon
v-if="item.icon"
class="mr-2 shrink-0 text-base text-g-800"
:icon="item.icon"
/>
<span
class="menu-label flex-1 overflow-hidden text-ellipsis whitespace-nowrap text-g-800"
>{{ item.label }}</span
>
</li>
<!-- 子菜单 -->
<li
v-else
class="menu-item submenu relative flex-c c-p select-none rounded text-xs transition-colors duration-150 hover:bg-g-200"
:style="menuItemStyle"
>
<div class="submenu-title flex-c w-full">
<ArtSvgIcon
v-if="item.icon"
class="mr-2 shrink-0 text-base text-g-800"
:icon="item.icon"
/>
<span
class="menu-label flex-1 overflow-hidden text-ellipsis whitespace-nowrap text-g-800"
>{{ item.label }}</span
>
<ArtSvgIcon
icon="ri:arrow-right-s-line"
class="ubmenu-arrow ml-auto mr-0 text-base text-g-500 transition-transform duration-150"
/>
</div>
<ul
class="submenu-list art-card-xs absolute left-full top-0 z-[2001] hidden w-max min-w-max list-none !shadow-xl"
:style="submenuListStyle"
>
<li
v-for="child in item.children"
:key="child.key"
class="menu-item relative mx-1.5 flex-c c-p select-none rounded text-xs transition-colors duration-150 hover:bg-g-200"
:class="{
'is-disabled': child.disabled,
'has-line': child.showLine
}"
:style="menuItemStyle"
@click="handleMenuClick(child)"
>
<ArtSvgIcon
v-if="child.icon"
class="r-2 shrink-0 text-base text-g-800 mr-1"
:icon="child.icon"
/>
<span
class="menu-label flex-1 overflow-hidden text-ellipsis whitespace-nowrap text-g-800"
>{{ child.label }}</span
>
</li>
</ul>
</li>
</template>
</ul>
</div>
</Transition>
</div>
</template>
<script setup lang="ts">
import type { CSSProperties } from 'vue'
defineOptions({ name: 'ArtMenuRight' })
export interface MenuItemType {
/** 菜单项唯一标识 */
key: string
/** 菜单项标签 */
label: string
/** 菜单项图标 */
icon?: string
/** 菜单项是否禁用 */
disabled?: boolean
/** 菜单项是否显示分割线 */
showLine?: boolean
/** 子菜单 */
children?: MenuItemType[]
[key: string]: any
}
interface Props {
menuItems: MenuItemType[]
/** 菜单宽度 */
menuWidth?: number
/** 子菜单宽度 */
submenuWidth?: number
/** 菜单项高度 */
itemHeight?: number
/** 边界距离 */
boundaryDistance?: number
/** 菜单内边距 */
menuPadding?: number
/** 菜单项水平内边距 */
itemPaddingX?: number
/** 菜单圆角 */
borderRadius?: number
/** 动画持续时间 */
animationDuration?: number
}
const props = withDefaults(defineProps<Props>(), {
menuWidth: 120,
submenuWidth: 150,
itemHeight: 32,
boundaryDistance: 10,
menuPadding: 5,
itemPaddingX: 6,
borderRadius: 6,
animationDuration: 100
})
const emit = defineEmits<{
(e: 'select', item: MenuItemType): void
(e: 'show'): void
(e: 'hide'): void
}>()
const visible = ref(false)
const position = ref({ x: 0, y: 0 })
// 用于清理定时器和事件监听器
let showTimer: number | null = null
let eventListenersAdded = false
// 计算菜单样式
const menuStyle = computed(
(): CSSProperties => ({
position: 'fixed' as const,
left: `${position.value.x}px`,
top: `${position.value.y}px`,
zIndex: 2000,
width: `${props.menuWidth}px`
})
)
// 计算菜单列表样式
const menuListStyle = computed(
(): CSSProperties => ({
padding: `${props.menuPadding}px`
})
)
// 计算菜单项样式
const menuItemStyle = computed(
(): CSSProperties => ({
height: `${props.itemHeight}px`,
padding: `0 ${props.itemPaddingX}px`,
borderRadius: '4px'
})
)
// 计算子菜单列表样式
const submenuListStyle = computed(
(): CSSProperties => ({
minWidth: `${props.submenuWidth}px`,
padding: `${props.menuPadding}px 0`,
borderRadius: `${props.borderRadius}px`
})
)
// 计算菜单高度(用于边界检测)
const calculateMenuHeight = (): number => {
let totalHeight = props.menuPadding * 2 // 上下内边距
props.menuItems.forEach((item) => {
totalHeight += props.itemHeight
if (item.showLine) {
totalHeight += 10 // 分割线额外高度
}
})
return totalHeight
}
// 优化的位置计算函数
const calculatePosition = (e: MouseEvent) => {
const screenWidth = window.innerWidth
const screenHeight = window.innerHeight
const menuHeight = calculateMenuHeight()
let x = e.clientX
let y = e.clientY
// 检查右边界 - 优先显示在鼠标右侧,如果空间不足则显示在左侧
if (x + props.menuWidth > screenWidth - props.boundaryDistance) {
x = Math.max(props.boundaryDistance, x - props.menuWidth)
}
// 检查下边界 - 优先显示在鼠标下方,如果空间不足则向上调整
if (y + menuHeight > screenHeight - props.boundaryDistance) {
y = Math.max(props.boundaryDistance, screenHeight - menuHeight - props.boundaryDistance)
}
// 确保不会超出边界
x = Math.max(
props.boundaryDistance,
Math.min(x, screenWidth - props.menuWidth - props.boundaryDistance)
)
y = Math.max(
props.boundaryDistance,
Math.min(y, screenHeight - menuHeight - props.boundaryDistance)
)
return { x, y }
}
// 添加事件监听器
const addEventListeners = () => {
if (eventListenersAdded) return
document.addEventListener('click', handleDocumentClick)
document.addEventListener('contextmenu', handleDocumentContextmenu)
document.addEventListener('keydown', handleKeydown)
eventListenersAdded = true
}
// 移除事件监听器
const removeEventListeners = () => {
if (!eventListenersAdded) return
document.removeEventListener('click', handleDocumentClick)
document.removeEventListener('contextmenu', handleDocumentContextmenu)
document.removeEventListener('keydown', handleKeydown)
eventListenersAdded = false
}
// 处理文档点击事件
const handleDocumentClick = (e: Event) => {
// 检查点击是否在菜单内部
const target = e.target as Element
const menuElement = document.querySelector('.context-menu')
if (menuElement && menuElement.contains(target)) {
return
}
hide()
}
// 处理文档右键事件
const handleDocumentContextmenu = () => {
hide()
}
// 处理键盘事件
const handleKeydown = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
hide()
}
}
const show = (e: MouseEvent) => {
e.preventDefault()
e.stopPropagation()
// 清理之前的定时器
if (showTimer) {
window.clearTimeout(showTimer)
showTimer = null
}
// 计算位置
position.value = calculatePosition(e)
visible.value = true
emit('show')
// 延迟添加事件监听器,避免立即触发关闭
showTimer = window.setTimeout(() => {
if (visible.value) {
addEventListeners()
}
showTimer = null
}, 50) // 减少延迟时间,提升响应性
}
const hide = () => {
if (!visible.value) return
visible.value = false
emit('hide')
// 清理定时器
if (showTimer) {
window.clearTimeout(showTimer)
showTimer = null
}
// 移除事件监听器
removeEventListeners()
}
const handleMenuClick = (item: MenuItemType) => {
if (item.disabled) return
emit('select', item)
hide()
}
// 动画钩子函数
const onBeforeEnter = (el: Element) => {
const element = el as HTMLElement
element.style.transformOrigin = 'top left'
}
const onAfterLeave = () => {
// 确保清理所有资源
removeEventListeners()
if (showTimer) {
window.clearTimeout(showTimer)
showTimer = null
}
}
// 组件卸载时清理资源
onUnmounted(() => {
removeEventListeners()
if (showTimer) {
window.clearTimeout(showTimer)
showTimer = null
}
})
// 导出方法供父组件调用
defineExpose({
show,
hide,
visible: computed(() => visible.value)
})
</script>
<style scoped>
.menu-right {
--menu-width: v-bind('props.menuWidth + "px"');
--border-radius: v-bind('props.borderRadius + "px"');
}
.menu-item.has-line {
margin-bottom: 10px;
}
.menu-item.has-line::after {
position: absolute;
right: 0;
bottom: -5px;
left: 0;
height: 1px;
content: '';
background-color: var(--art-gray-300);
}
.menu-item.is-disabled {
color: var(--el-text-color-disabled);
cursor: not-allowed;
}
.menu-item.is-disabled:hover {
background-color: transparent !important;
}
.menu-item.is-disabled i:not(.submenu-arrow),
.menu-item.is-disabled :deep(.art-svg-icon) {
color: var(--el-text-color-disabled) !important;
}
.menu-item.is-disabled .menu-label {
color: var(--el-text-color-disabled) !important;
}
.menu-item.submenu:hover .submenu-list {
display: block;
}
.menu-item.submenu:hover .submenu-title .submenu-arrow {
transform: rotate(90deg);
}
/* 动画样式 */
.context-menu-enter-active,
.context-menu-leave-active {
transition: all v-bind('props.animationDuration + "ms"') ease-out;
}
.context-menu-enter-from,
.context-menu-leave-to {
opacity: 0;
transform: scale(0.9);
}
.context-menu-enter-to,
.context-menu-leave-from {
opacity: 1;
transform: scale(1);
}
</style>