feat: 重构上下文菜单组件,使用 Ant Design 的下拉菜单替代原有实现,优化子菜单逻辑和样式,提升用户体验

This commit is contained in:
xudan 2025-09-10 10:34:12 +08:00
parent d270e82c8b
commit a4b1975492
2 changed files with 315 additions and 297 deletions

View File

@ -1,42 +1,59 @@
<template>
<div v-if="visible" ref="mainMenuRef" class="context-menu" :style="menuStyle" @click.stop @contextmenu.prevent>
<div class="context-menu-header">
<div class="menu-title">{{ headerTitle }}</div>
</div>
<!-- 库位菜单 -->
<StorageMenu
v-if="menuType === 'storage-background' && storageLocations?.length"
:storage-locations="storageLocations"
:menu-x="x"
:menu-y="y"
:main-menu-width="mainMenuWidth"
@action-complete="handleActionComplete"
<a-dropdown
v-if="visible"
:open="visible"
:trigger="[]"
:placement="dropdownPlacement"
:get-popup-container="getPopupContainer"
@open-change="handleOpenChange"
>
<div
ref="triggerRef"
class="context-menu-trigger"
:style="triggerStyle"
/>
<template #overlay>
<div class="context-menu-overlay">
<div class="context-menu-header">
<div class="menu-title">{{ headerTitle }}</div>
</div>
<!-- 机器人菜单 -->
<RobotMenu
v-else-if="menuType === 'robot' && robotInfo"
:robot-info="robotInfo"
@action-complete="handleActionComplete"
/>
<!-- 库位菜单 -->
<StorageMenu
v-if="menuType === 'storage-background' && storageLocations?.length"
:storage-locations="storageLocations"
:menu-x="x"
:menu-y="y"
:main-menu-width="mainMenuWidth"
@action-complete="handleActionComplete"
/>
<!-- 默认菜单 -->
<DefaultMenu
v-else
@action-complete="handleActionComplete"
/>
</div>
<!-- 机器人菜单 -->
<RobotMenu
v-else-if="menuType === 'robot' && robotInfo"
:robot-info="robotInfo"
@action-complete="handleActionComplete"
/>
<!-- 默认菜单 -->
<DefaultMenu
v-else
@action-complete="handleActionComplete"
/>
</div>
</template>
</a-dropdown>
</template>
<script setup lang="ts">
import { computed, nextTick,ref, watch } from 'vue';
import { computed, defineAsyncComponent, ref } from 'vue';
import type { StorageLocationInfo } from '../../services/context-menu';
import type { RobotInfo } from '../../services/context-menu';
import DefaultMenu from './default-menu.vue';
import RobotMenu from './robot-menu.vue';
import StorageMenu from './storage-menu.vue';
import type { StorageLocationInfo } from '../../services/context-menu';
// 使 TypeScript
const DefaultMenu = defineAsyncComponent(() => import('./default-menu.vue'));
const RobotMenu = defineAsyncComponent(() => import('./robot-menu.vue'));
const StorageMenu = defineAsyncComponent(() => import('./storage-menu.vue'));
interface Props {
visible: boolean;
@ -67,57 +84,26 @@ defineOptions({
name: 'ContextMenu',
});
//
const mainMenuRef = ref<HTMLElement | null>(null);
//
const triggerRef = ref<HTMLElement | null>(null);
const mainMenuWidth = ref(200); //
//
watch(mainMenuRef, async (newRef) => {
if (newRef) {
await nextTick();
mainMenuWidth.value = newRef.offsetWidth;
}
});
// -
const triggerStyle = computed(() => ({
position: 'fixed' as const,
left: `${props.x}px`,
top: `${props.y}px`,
width: '1px',
height: '1px',
pointerEvents: 'none' as const,
zIndex: 9999,
}));
//
const menuStyle = computed(() => {
//
const menuWidth = mainMenuWidth.value || 200;
const menuHeight = 300; //
//
const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight;
let left = props.x;
let top = props.y;
//
if (left + menuWidth > viewportWidth) {
left = viewportWidth - menuWidth - 10; // 10px
}
//
if (top + menuHeight > viewportHeight) {
top = viewportHeight - menuHeight - 10; // 10px
}
//
if (top < 0) {
top = 10; // 10px
}
//
if (left < 0) {
left = 10; // 10px
}
const style = {
left: `${left}px`,
top: `${top}px`,
};
return style;
});
// 使 Ant Design
const dropdownPlacement = 'bottomLeft' as const;
//
const getPopupContainer = () => document.body;
//
const headerTitle = computed(() => {
@ -129,6 +115,13 @@ const headerTitle = computed(() => {
return '右键菜单';
});
//
const handleOpenChange = (open: boolean) => {
if (!open) {
emit('close');
}
};
//
const handleActionComplete = (data: any) => {
console.log('菜单操作完成:', data);
@ -142,9 +135,17 @@ const handleActionComplete = (data: any) => {
</script>
<style scoped>
.context-menu {
/* 触发器样式 - 不可见定位点 */
.context-menu-trigger {
position: fixed;
z-index: 1;
width: 1px;
height: 1px;
pointer-events: none;
z-index: 9999;
}
/* 下拉菜单覆盖层样式 */
.context-menu-overlay {
background: white;
border: 1px solid #d9d9d9;
border-radius: 6px;
@ -156,16 +157,33 @@ const handleActionComplete = (data: any) => {
pointer-events: auto;
}
/* 黑色主题 */
:root[theme='dark'] .context-menu-overlay {
background: #141414;
border-color: #424242;
color: #ffffffd9;
}
/* 菜单头部 */
.context-menu-header {
padding: 8px 12px;
background-color: #fafafa;
padding: 8px 12px;
border-radius: 6px 6px 0 0;
}
/* 黑色主题菜单头部 */
:root[theme='dark'] .context-menu-header {
background-color: #212121;
}
.menu-title {
color: #000 !important;
font-size: 14px;
font-weight: 600;
color: #000;
}
/* 黑色主题标题 */
:root[theme='dark'] .menu-title {
color: #ffffffd9 !important;
}
</style>

View File

@ -35,55 +35,66 @@
<div class="arrow-icon"></div>
</div>
<!-- 库位操作子菜单 -->
<div
v-if="showSubMenu && selectedLocation"
class="sub-menu-container"
:style="subMenuStyle"
@click.stop
@mouseenter="handleSubMenuMouseEnter"
@mouseleave="handleSubMenuMouseLeave"
<!-- 库位操作子菜单 - 使用 Ant Design Dropdown -->
<a-dropdown
v-if="showSubMenu && selectedLocation"
:open="!!showSubMenu"
:trigger="[]"
:placement="subMenuPlacement"
:get-popup-container="getSubMenuContainer"
@open-change="handleSubMenuOpenChange"
>
<!-- 连接区域确保鼠标移动时不会断开 -->
<div class="sub-menu-connector"></div>
<div class="sub-menu">
<div class="sub-menu-header">
<div class="sub-menu-title">{{ selectedLocation.name }} - 操作</div>
<div
ref="subMenuTriggerRef"
class="sub-menu-trigger"
:style="subMenuTriggerStyle"
/>
<template #overlay>
<div
class="sub-menu-overlay"
@mouseenter="handleSubMenuMouseEnter"
@mouseleave="handleSubMenuMouseLeave"
>
<div class="sub-menu-header">
<div class="sub-menu-title">{{ selectedLocation.name }} - 操作</div>
</div>
<div class="sub-menu-actions">
<div class="menu-item" @click="handleStorageAction('occupy', '占用')">
<span class="action-icon">📦</span>
<span>占用</span>
</div>
<div class="menu-item" @click="handleStorageAction('release', '释放')">
<span class="action-icon">📤</span>
<span>释放</span>
</div>
<div class="menu-item" @click="handleStorageAction('lock', '锁定')">
<span class="action-icon">🔒</span>
<span>锁定</span>
</div>
<div class="menu-item" @click="handleStorageAction('unlock', '解锁')">
<span class="action-icon">🔓</span>
<span>解锁</span>
</div>
<div class="menu-item" @click="handleStorageAction('enable', '启用')">
<span class="action-icon"></span>
<span>启用</span>
</div>
<div class="menu-item" @click="handleStorageAction('disable', '禁用')">
<span class="action-icon"></span>
<span>禁用</span>
</div>
<div class="menu-item" @click="handleStorageAction('set_empty_tray', '设为空托盘')">
<span class="action-icon">📋</span>
<span>设为空托盘</span>
</div>
<div class="menu-item" @click="handleStorageAction('clear_empty_tray', '清除空托盘')">
<span class="action-icon">🗑</span>
<span>清除空托盘</span>
</div>
</div>
</div>
<div class="sub-menu-item" @click="handleStorageAction('occupy', '占用')">
<span class="action-icon">📦</span>
<span>占用</span>
</div>
<div class="sub-menu-item" @click="handleStorageAction('release', '释放')">
<span class="action-icon">📤</span>
<span>释放</span>
</div>
<div class="sub-menu-item" @click="handleStorageAction('lock', '锁定')">
<span class="action-icon">🔒</span>
<span>锁定</span>
</div>
<div class="sub-menu-item" @click="handleStorageAction('unlock', '解锁')">
<span class="action-icon">🔓</span>
<span>解锁</span>
</div>
<div class="sub-menu-item" @click="handleStorageAction('enable', '启用')">
<span class="action-icon"></span>
<span>启用</span>
</div>
<div class="sub-menu-item" @click="handleStorageAction('disable', '禁用')">
<span class="action-icon"></span>
<span>禁用</span>
</div>
<div class="sub-menu-item" @click="handleStorageAction('set_empty_tray', '设为空托盘')">
<span class="action-icon">📋</span>
<span>设为空托盘</span>
</div>
<div class="sub-menu-item" @click="handleStorageAction('clear_empty_tray', '清除空托盘')">
<span class="action-icon">🗑</span>
<span>清除空托盘</span>
</div>
</div>
</div>
</template>
</a-dropdown>
</div>
</template>
@ -115,6 +126,7 @@ defineOptions({
const showSubMenu = ref<string | null>(null);
const selectedLocation = ref<StorageLocationInfo | null>(null);
const hideTimer = ref<number | null>(null);
const subMenuTriggerRef = ref<HTMLElement | null>(null);
//
watch(showSubMenu, (newValue) => {
@ -130,55 +142,67 @@ watch(showSubMenu, (newValue) => {
}
});
//
const subMenuStyle = computed(() => {
// 使
const handleStorageMouseLeave = () => {
// 使
if (hideTimer.value) {
clearTimeout(hideTimer.value);
}
hideTimer.value = window.setTimeout(() => {
showSubMenu.value = null;
hideTimer.value = null;
}, 500); // 500ms
};
//
const handleSubMenuMouseEnter = () => {
//
if (hideTimer.value) {
clearTimeout(hideTimer.value);
hideTimer.value = null;
}
};
//
const handleSubMenuMouseLeave = () => {
//
showSubMenu.value = null;
if (hideTimer.value) {
clearTimeout(hideTimer.value);
hideTimer.value = null;
}
};
// -
const subMenuTriggerStyle = computed(() => {
if (!showSubMenu.value) return {};
//
//
const menuItemHeight = 60; //
const headerHeight = 40; //
const itemIndex = props.storageLocations.findIndex(loc => loc.id === showSubMenu.value);
const offsetY = headerHeight + (itemIndex * menuItemHeight) - 10; // 10px
//
const subMenuWidth = 180; //
const subMenuHeight = 300; //
//
let left = props.menuX + props.mainMenuWidth;
let top = props.menuY + offsetY;
//
const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight;
//
if (left + subMenuWidth > viewportWidth) {
left = props.menuX - subMenuWidth; //
}
//
if (top + subMenuHeight > viewportHeight) {
top = viewportHeight - subMenuHeight - 50; // 10px
}
//
if (top < 0) {
top = 10; // 10px
}
//
if (left < 0) {
left = 10; // 10px
}
const offsetY = headerHeight + (itemIndex * menuItemHeight);
const style = {
left: `${left}px`,
top: `${top}px`,
position: 'absolute' as const,
left: '100%', //
top: `${offsetY}px`, //
width: '1px',
height: '1px',
pointerEvents: 'none' as const,
zIndex: 10,
// 使
transform: 'translateY(-1px)',
};
return style;
});
// - 使 rightTop
const subMenuPlacement = 'rightTop' as const;
//
const getSubMenuContainer = () => document.body;
//
const handleSelectStorage = (location: StorageLocationInfo) => {
console.log('选择库位:', location);
@ -194,34 +218,52 @@ const hideSubMenu = () => {
}
};
//
const hideSubMenuDelayed = () => {
if (hideTimer.value) {
clearTimeout(hideTimer.value);
// Ant Design
// const hideSubMenuDelayed = () => {
// if (hideTimer.value) {
// clearTimeout(hideTimer.value);
// }
// hideTimer.value = window.setTimeout(() => {
// showSubMenu.value = null;
// hideTimer.value = null;
// }, 150); // 150ms
// };
// Ant Design
// const handleStorageMouseLeave = () => {
// hideSubMenuDelayed();
// };
// Ant Design
// const handleSubMenuMouseEnter = () => {
// //
// if (hideTimer.value) {
// clearTimeout(hideTimer.value);
// hideTimer.value = null;
// }
// };
// Ant Design
// const handleSubMenuMouseLeave = () => {
// hideSubMenu();
// };
//
const handleSubMenuOpenChange = (open: boolean) => {
if (!open) {
//
if (hideTimer.value) {
clearTimeout(hideTimer.value);
hideTimer.value = null;
}
hideSubMenu();
} else {
//
if (hideTimer.value) {
clearTimeout(hideTimer.value);
hideTimer.value = null;
}
}
hideTimer.value = window.setTimeout(() => {
showSubMenu.value = null;
hideTimer.value = null;
}, 150); // 150ms
};
//
const handleStorageMouseLeave = () => {
hideSubMenuDelayed();
};
//
const handleSubMenuMouseEnter = () => {
//
if (hideTimer.value) {
clearTimeout(hideTimer.value);
hideTimer.value = null;
}
};
//
const handleSubMenuMouseLeave = () => {
hideSubMenu();
};
//
@ -287,10 +329,9 @@ const getStorageTooltip = (location: StorageLocationInfo) => {
<style scoped>
.storage-menu {
width: 100%;
position: relative;
}
/* 库位项样式 */
.storage-item {
display: flex;
align-items: center;
@ -305,6 +346,10 @@ const getStorageTooltip = (location: StorageLocationInfo) => {
border-left-color: #1890ff;
}
:root[theme='dark'] .storage-item:hover {
background-color: #262626;
}
.storage-info {
display: flex;
flex-direction: column;
@ -313,7 +358,6 @@ const getStorageTooltip = (location: StorageLocationInfo) => {
.storage-name {
font-weight: 500;
color: #000;
font-size: 14px;
}
@ -334,79 +378,28 @@ const getStorageTooltip = (location: StorageLocationInfo) => {
width: 8px;
height: 8px;
border-radius: 50%;
display: inline-block;
background-color: #d9d9d9; /* 默认灰色 */
transition: background-color 0.2s;
background-color: #d9d9d9;
}
.status-indicator.occupied {
background-color: #d9d9d9; /* 默认灰色 */
}
.status-indicator.occupied.active {
background-color: #ff4d4f; /* 占用时红色 */
}
.status-indicator.locked {
background-color: #d9d9d9; /* 默认灰色 */
}
.status-indicator.locked.active {
background-color: #faad14; /* 锁定时橙色 */
}
.status-indicator.disabled {
background-color: #d9d9d9; /* 默认灰色 */
}
.status-indicator.disabled.active {
background-color: #8c8c8c; /* 禁用时深灰色 */
}
.status-indicator.empty-tray {
background-color: #d9d9d9; /* 默认灰色 */
}
.status-indicator.empty-tray.active {
background-color: #13c2c2; /* 空托盘时青色 */
}
.status-indicator.occupied.active { background-color: #ff4d4f; }
.status-indicator.locked.active { background-color: #faad14; }
.status-indicator.disabled.active { background-color: #8c8c8c; }
.status-indicator.empty-tray.active { background-color: #13c2c2; }
.status-text {
color: #666;
font-size: 11px;
}
/* 库位状态样式 */
.storage-occupied {
background-color: #fff2f0;
border-left-color: #ff4d4f;
}
.storage-occupied { background-color: #fff2f0; border-left-color: #ff4d4f; }
.storage-locked { background-color: #fffbe6; border-left-color: #faad14; }
.storage-disabled { background-color: #f5f5f5; border-left-color: #8c8c8c; }
.storage-empty-tray { background-color: #e6fffb; border-left-color: #13c2c2; }
.storage-available { background-color: #f6ffed; border-left-color: #52c41a; }
.storage-locked {
background-color: #fffbe6;
border-left-color: #faad14;
}
.storage-disabled {
background-color: #f5f5f5;
border-left-color: #8c8c8c;
}
.storage-empty-tray {
background-color: #e6fffb;
border-left-color: #13c2c2;
}
.storage-available {
background-color: #f6ffed;
border-left-color: #52c41a;
}
/* 箭头图标 */
.arrow-icon {
color: #999;
font-size: 12px;
transition: color 0.2s;
flex-shrink: 0;
}
@ -414,76 +407,83 @@ const getStorageTooltip = (location: StorageLocationInfo) => {
color: #1890ff;
}
/* 子菜单容器样式 */
.sub-menu-container {
position: fixed;
z-index: 2;
pointer-events: auto;
display: flex;
align-items: flex-start;
.sub-menu-trigger {
position: absolute;
width: 1px;
height: 1px;
pointer-events: none;
z-index: 10;
}
/* 连接区域 - 确保鼠标移动流畅 */
.sub-menu-connector {
width: 12px;
height: 100%;
background: transparent;
pointer-events: auto;
/* 确保连接区域覆盖可能的间隙 */
margin-left: -6px;
/* 添加一个微妙的背景,帮助用户理解连接关系 */
background: linear-gradient(to right, transparent 0%, rgba(24, 144, 255, 0.05) 50%, transparent 100%);
}
/* 子菜单样式 */
.sub-menu {
.sub-menu-overlay {
background: white;
border: 1px solid #d9d9d9;
border-left: none; /* 移除左边框,与主菜单无缝连接 */
border-radius: 0 6px 6px 0; /* 只圆角右侧,左侧与主菜单连接 */
box-shadow: 2px 4px 12px rgba(0, 0, 0, 0.15); /* 调整阴影,避免左侧阴影 */
border-radius: 6px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
padding: 4px 0;
min-width: 160px;
user-select: none;
color: #000;
pointer-events: auto;
/* 确保与主菜单无缝连接 */
margin-left: 0;
/* 添加一个微妙的左边框,模拟与主菜单的连接 */
border-left: 1px solid #e8e8e8;
}
:root[theme='dark'] .sub-menu-overlay {
background: #141414;
border-color: #424242;
}
.sub-menu-header {
padding: 8px 12px;
background-color: #fafafa;
border-radius: 0 6px 0 0; /* 只圆角右上角,与子菜单整体设计一致 */
border-bottom: 1px solid #f0f0f0;
padding: 8px 12px;
border-radius: 6px 6px 0 0;
}
:root[theme='dark'] .sub-menu-header {
background-color: #212121;
border-bottom-color: #303030;
}
.sub-menu-title {
font-size: 13px;
font-weight: 600;
color: #000;
}
.sub-menu-item {
.sub-menu-actions {
border: none;
box-shadow: none;
background: #fff !important;
color: #000 !important;
}
.sub-menu-actions .menu-item {
display: flex;
align-items: center;
padding: 8px 12px;
font-size: 13px;
gap: 8px;
height: auto;
line-height: 1.4;
background: transparent;
color: #000;
cursor: pointer;
transition: background-color 0.2s;
font-size: 13px;
color: #000;
gap: 8px;
}
.sub-menu-item:hover {
.sub-menu-actions .menu-item:hover {
background-color: #f5f5f5;
}
:root[theme='dark'] .sub-menu-actions .menu-item {
background: transparent;
color: #fff;
}
:root[theme='dark'] .sub-menu-actions .menu-item:hover {
background-color: #262626;
}
.action-icon {
font-size: 14px;
width: 16px;
text-align: center;
flex-shrink: 0;
}
</style>