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

View File

@ -35,55 +35,66 @@
<div class="arrow-icon"></div> <div class="arrow-icon"></div>
</div> </div>
<!-- 库位操作子菜单 --> <!-- 库位操作子菜单 - 使用 Ant Design Dropdown -->
<div <a-dropdown
v-if="showSubMenu && selectedLocation" v-if="showSubMenu && selectedLocation"
class="sub-menu-container" :open="!!showSubMenu"
:style="subMenuStyle" :trigger="[]"
@click.stop :placement="subMenuPlacement"
@mouseenter="handleSubMenuMouseEnter" :get-popup-container="getSubMenuContainer"
@mouseleave="handleSubMenuMouseLeave" @open-change="handleSubMenuOpenChange"
> >
<!-- 连接区域确保鼠标移动时不会断开 --> <div
<div class="sub-menu-connector"></div> ref="subMenuTriggerRef"
<div class="sub-menu"> class="sub-menu-trigger"
<div class="sub-menu-header"> :style="subMenuTriggerStyle"
<div class="sub-menu-title">{{ selectedLocation.name }} - 操作</div> />
<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>
<div class="sub-menu-item" @click="handleStorageAction('occupy', '占用')"> </template>
<span class="action-icon">📦</span> </a-dropdown>
<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>
</div> </div>
</template> </template>
@ -115,6 +126,7 @@ defineOptions({
const showSubMenu = ref<string | null>(null); const showSubMenu = ref<string | null>(null);
const selectedLocation = ref<StorageLocationInfo | null>(null); const selectedLocation = ref<StorageLocationInfo | null>(null);
const hideTimer = ref<number | null>(null); const hideTimer = ref<number | null>(null);
const subMenuTriggerRef = ref<HTMLElement | null>(null);
// //
watch(showSubMenu, (newValue) => { 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 {}; if (!showSubMenu.value) return {};
// //
const menuItemHeight = 60; // const menuItemHeight = 60; //
const headerHeight = 40; // const headerHeight = 40; //
const itemIndex = props.storageLocations.findIndex(loc => loc.id === showSubMenu.value); const itemIndex = props.storageLocations.findIndex(loc => loc.id === showSubMenu.value);
const offsetY = headerHeight + (itemIndex * menuItemHeight) - 10; // 10px const offsetY = headerHeight + (itemIndex * menuItemHeight);
//
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 style = { const style = {
left: `${left}px`, position: 'absolute' as const,
top: `${top}px`, left: '100%', //
top: `${offsetY}px`, //
width: '1px',
height: '1px',
pointerEvents: 'none' as const,
zIndex: 10,
// 使
transform: 'translateY(-1px)',
}; };
return style; return style;
}); });
// - 使 rightTop
const subMenuPlacement = 'rightTop' as const;
//
const getSubMenuContainer = () => document.body;
// //
const handleSelectStorage = (location: StorageLocationInfo) => { const handleSelectStorage = (location: StorageLocationInfo) => {
console.log('选择库位:', location); console.log('选择库位:', location);
@ -194,34 +218,52 @@ const hideSubMenu = () => {
} }
}; };
// // Ant Design
const hideSubMenuDelayed = () => { // const hideSubMenuDelayed = () => {
if (hideTimer.value) { // if (hideTimer.value) {
clearTimeout(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> <style scoped>
.storage-menu { .storage-menu {
width: 100%; position: relative;
} }
/* 库位项样式 */
.storage-item { .storage-item {
display: flex; display: flex;
align-items: center; align-items: center;
@ -305,6 +346,10 @@ const getStorageTooltip = (location: StorageLocationInfo) => {
border-left-color: #1890ff; border-left-color: #1890ff;
} }
:root[theme='dark'] .storage-item:hover {
background-color: #262626;
}
.storage-info { .storage-info {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@ -313,7 +358,6 @@ const getStorageTooltip = (location: StorageLocationInfo) => {
.storage-name { .storage-name {
font-weight: 500; font-weight: 500;
color: #000;
font-size: 14px; font-size: 14px;
} }
@ -334,79 +378,28 @@ const getStorageTooltip = (location: StorageLocationInfo) => {
width: 8px; width: 8px;
height: 8px; height: 8px;
border-radius: 50%; border-radius: 50%;
display: inline-block; background-color: #d9d9d9;
background-color: #d9d9d9; /* 默认灰色 */
transition: background-color 0.2s;
} }
.status-indicator.occupied { .status-indicator.occupied.active { background-color: #ff4d4f; }
background-color: #d9d9d9; /* 默认灰色 */ .status-indicator.locked.active { background-color: #faad14; }
} .status-indicator.disabled.active { background-color: #8c8c8c; }
.status-indicator.empty-tray.active { background-color: #13c2c2; }
.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-text { .status-text {
color: #666; color: #666;
font-size: 11px; font-size: 11px;
} }
/* 库位状态样式 */ .storage-occupied { background-color: #fff2f0; border-left-color: #ff4d4f; }
.storage-occupied { .storage-locked { background-color: #fffbe6; border-left-color: #faad14; }
background-color: #fff2f0; .storage-disabled { background-color: #f5f5f5; border-left-color: #8c8c8c; }
border-left-color: #ff4d4f; .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 { .arrow-icon {
color: #999; color: #999;
font-size: 12px; font-size: 12px;
transition: color 0.2s;
flex-shrink: 0; flex-shrink: 0;
} }
@ -414,76 +407,83 @@ const getStorageTooltip = (location: StorageLocationInfo) => {
color: #1890ff; color: #1890ff;
} }
/* 子菜单容器样式 */ .sub-menu-trigger {
.sub-menu-container { position: absolute;
position: fixed; width: 1px;
z-index: 2; height: 1px;
pointer-events: auto; pointer-events: none;
display: flex; z-index: 10;
align-items: flex-start;
} }
/* 连接区域 - 确保鼠标移动流畅 */ .sub-menu-overlay {
.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 {
background: white; background: white;
border: 1px solid #d9d9d9; border: 1px solid #d9d9d9;
border-left: none; /* 移除左边框,与主菜单无缝连接 */ border-radius: 6px;
border-radius: 0 6px 6px 0; /* 只圆角右侧,左侧与主菜单连接 */ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
box-shadow: 2px 4px 12px rgba(0, 0, 0, 0.15); /* 调整阴影,避免左侧阴影 */
padding: 4px 0; padding: 4px 0;
min-width: 160px; min-width: 160px;
user-select: none; }
color: #000;
pointer-events: auto; :root[theme='dark'] .sub-menu-overlay {
/* 确保与主菜单无缝连接 */ background: #141414;
margin-left: 0; border-color: #424242;
/* 添加一个微妙的左边框,模拟与主菜单的连接 */
border-left: 1px solid #e8e8e8;
} }
.sub-menu-header { .sub-menu-header {
padding: 8px 12px;
background-color: #fafafa; background-color: #fafafa;
border-radius: 0 6px 0 0; /* 只圆角右上角,与子菜单整体设计一致 */
border-bottom: 1px solid #f0f0f0; 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 { .sub-menu-title {
font-size: 13px; font-size: 13px;
font-weight: 600; 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; display: flex;
align-items: center; align-items: center;
padding: 8px 12px; padding: 8px 12px;
font-size: 13px;
gap: 8px;
height: auto;
line-height: 1.4;
background: transparent;
color: #000;
cursor: pointer; cursor: pointer;
transition: background-color 0.2s; 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; 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 { .action-icon {
font-size: 14px; font-size: 14px;
width: 16px; width: 16px;
text-align: center; text-align: center;
flex-shrink: 0;
} }
</style> </style>