204 lines
4.6 KiB
Vue
204 lines
4.6 KiB
Vue
<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<{
|
||
items: ContextMenuItem[]; // 菜单项
|
||
autoClose?: boolean; // 是否自动关闭菜单
|
||
menuWidth?: number | string; // 菜单宽度
|
||
}>(), {
|
||
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);
|
||
|
||
|
||
const handleContextMenu = async (e: MouseEvent) => {
|
||
emitter.emit('closeAllContextMenus');
|
||
|
||
// 先计算初始位置
|
||
left.value = Math.min(e.clientX, window.innerWidth - (typeof props.menuWidth === 'number' ? props.menuWidth : 180));
|
||
top.value = e.clientY;
|
||
visible.value = true;
|
||
emit('open');
|
||
|
||
// 等待菜单渲染完成后获取实际高度
|
||
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;
|
||
}
|
||
}
|
||
};
|
||
|
||
// 处理菜单项点击
|
||
const handleItemClick = (item: ContextMenuItem) => {
|
||
if (item.disabled) return;
|
||
emit('select', item);
|
||
|
||
|
||
if (props.autoClose !== false) {
|
||
hideMenu();
|
||
}
|
||
};
|
||
|
||
// 隐藏菜单
|
||
const hideMenu = () => {
|
||
visible.value = false;
|
||
emit('close');
|
||
};
|
||
|
||
// 点击外部区域关闭菜单
|
||
const handleClickOutside = (e: MouseEvent) => {
|
||
hideMenu();
|
||
};
|
||
|
||
// 键盘事件处理
|
||
const handleKeyDown = (e: KeyboardEvent) => {
|
||
if (!visible.value) return;
|
||
if (e.key === 'Escape') {
|
||
hideMenu();
|
||
}
|
||
// 统一转换小写
|
||
const row: ContextMenuItem | undefined = props.items.find(item => item.shortcut?.toLowerCase() === e.key?.toLowerCase());
|
||
|
||
if (row && !row.disabled) {
|
||
handleItemClick(row)
|
||
}
|
||
|
||
};
|
||
|
||
|
||
onMounted(() => {
|
||
// 关闭所有菜单
|
||
emitter.on('closeAllContextMenus', () => {
|
||
if (visible.value) {
|
||
hideMenu();
|
||
}
|
||
});
|
||
|
||
document.addEventListener('click', handleClickOutside);
|
||
document.addEventListener('keydown', handleKeyDown);
|
||
|
||
|
||
});
|
||
|
||
// 卸载时移除事件监听
|
||
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> |