201 lines
4.5 KiB
Vue
Raw Normal View History

2025-10-31 15:24:11 +08:00
<template>
<div class="context-menu-wrapper" @contextmenu.prevent="handleContextMenu($event)">
<!-- 插槽:包裹需要绑定右键菜单的内容 -->
<slot />
<!-- 右键菜单 -->
<div v-if="visible" class="hospital-menu" :style="{
top: `${top}px`,
left: `${left}px`,
display: visible ? 'block' : 'none',
width: `${menuWidth}px`
}" @click.stop @contextmenu.prevent>
<template v-for="(item, index) in items" :key="index">
<div v-if="item.type === 'divider'" class="menu-divider"></div>
<div v-if="item.type !== 'divider'" class="menu-item" :class="{
danger: item.danger,
disabled: item.disabled
}" @click="handleItemClick(item)">
<span class="menu-shortcut" v-if="item.shortcut">{{ item.shortcut }}</span>
<span class="menu-label">{{ item.label }}</span>
</div>
</template>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted, computed } from 'vue';
import { ContextMenuItem } from '@/types/index';
import emitter from '@/utils/mitt';
// 组件Props
const props = withDefaults(defineProps<{
2025-11-03 18:07:51 +08:00
items: ContextMenuItem[]; // 菜单项
autoClose?: boolean; // 是否自动关闭菜单
menuWidth?: number | string; // 菜单宽度
2025-10-31 15:24:11 +08:00
}>(), {
autoClose: true,
menuWidth: 180,
});
// 组件Emits
const emit = defineEmits<{
(e: 'select', item: ContextMenuItem): void;
(e: 'open'): void;
(e: 'close'): void;
}>();
// 菜单状态
const visible = ref(false);
const top = ref(0);
const left = ref(0);
2025-11-03 18:07:51 +08:00
const handleContextMenu = async (e: MouseEvent) => {
2025-10-31 15:24:11 +08:00
emitter.emit('closeAllContextMenus');
2025-11-03 18:07:51 +08:00
// 先计算初始位置
left.value = Math.min(e.clientX, window.innerWidth - (typeof props.menuWidth === 'number' ? props.menuWidth : 180));
top.value = e.clientY;
2025-10-31 15:24:11 +08:00
visible.value = true;
emit('open');
2025-11-03 18:07:51 +08:00
// 等待菜单渲染完成后获取实际高度
await nextTick();
const menuEl = document.querySelector('.hospital-menu') as HTMLElement;
if (menuEl) {
const menuHeight = menuEl.offsetHeight;
const maxTop = window.innerHeight - menuHeight;
// 确保菜单不会超出底部可视区域
if (top.value > maxTop) {
top.value = maxTop;
// 如果向上调整后仍超出顶部,则取最小0值
if (top.value < 0) top.value = 0;
}
}
2025-10-31 15:24:11 +08:00
};
// 处理菜单项点击
const handleItemClick = (item: ContextMenuItem) => {
if (item.disabled) return;
emit('select', item);
if (props.autoClose !== false) {
hideMenu();
}
};
// 隐藏菜单
const hideMenu = () => {
2025-11-03 12:12:47 +08:00
visible.value = false;
emit('close');
2025-10-31 15:24:11 +08:00
};
// 点击外部区域关闭菜单
const handleClickOutside = (e: MouseEvent) => {
hideMenu();
};
// 键盘事件处理
const handleKeyDown = (e: KeyboardEvent) => {
2025-11-03 12:12:47 +08:00
if (!visible.value) return;
2025-10-31 15:24:11 +08:00
if (e.key === 'Escape') {
hideMenu();
}
2025-11-03 18:07:51 +08:00
// 统一转换小写
const row: ContextMenuItem | undefined = props.items.find(item => item.shortcut?.toLowerCase() === e.key?.toLowerCase());
if (row && !row.disabled) {
handleItemClick(row)
2025-10-31 15:24:11 +08:00
}
};
onMounted(() => {
document.addEventListener('click', handleClickOutside);
document.addEventListener('keydown', handleKeyDown);
emitter.on('closeAllContextMenus', () => {
if (visible.value) {
hideMenu();
}
});
});
// 卸载时移除事件监听
onUnmounted(() => {
document.removeEventListener('click', handleClickOutside);
document.removeEventListener('keydown', handleKeyDown);
emitter.off('closeAllContextMenus');
});
</script>
<style scoped>
.context-menu-wrapper {
position: relative;
height: 100%;
}
.hospital-menu {
position: fixed;
background-color: #fff;
border: 1px solid #dcdfe6;
border-radius: .25rem;
box-shadow: 0 .125rem .75rem rgba(0, 0, 0, 0.1);
z-index: 9999;
padding: .3125rem 0;
font-size: .8125rem;
font-family: SourceHanSansCN, SourceHanSansCN;
}
.menu-item {
padding: .3125rem .625rem;
cursor: pointer;
display: flex;
align-items: center;
transition: background-color 0.2s;
/* justify-content: space-between; */
}
.menu-item:hover:not(.disabled) {
background-color: #e6f7ff;
color: #165DFF;
}
.menu-item.disabled {
color: #c0c4cc;
cursor: not-allowed;
}
.menu-item i {
margin-right: .5rem;
font-size: .875rem;
width: 1rem;
text-align: center;
}
.menu-divider {
height: 1px;
background-color: #e5e7eb;
margin: .25rem 0;
}
.menu-item.danger {
color: #f53f3f;
}
.menu-item.danger:hover:not(.disabled) {
background-color: #fff1f0;
color: #f53f3f;
}
.menu-shortcut {
color: #909399;
margin-right: .3125rem;
}
</style>