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>
|