feat: 重构右键菜单组件,支持模块化和动态数据传递,优化上下文菜单逻辑
This commit is contained in:
parent
b06ce3ebd7
commit
930df2a6f7
@ -1,515 +1,45 @@
|
||||
<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>
|
||||
|
||||
<!-- 库位列表菜单 -->
|
||||
<template v-if="(menuType === 'storage' || menuType === 'storage-background') && storageLocations.length">
|
||||
<div
|
||||
v-for="location in storageLocations"
|
||||
:key="location.id"
|
||||
class="context-menu-item storage-item"
|
||||
:class="getStorageItemClass(location)"
|
||||
@click="handleSelectStorage(location)"
|
||||
@mouseenter="showSubMenu = location.id"
|
||||
@mouseleave="handleStorageMouseLeave"
|
||||
:title="getStorageTooltip(location)"
|
||||
>
|
||||
<div class="storage-info">
|
||||
<div class="storage-name">{{ location.name }}</div>
|
||||
<div class="storage-status">
|
||||
<div class="status-row">
|
||||
<span class="status-indicator occupied" :class="{ active: location.isOccupied }"></span>
|
||||
<span class="status-text">{{ location.isOccupied ? '已占用' : '未占用' }}</span>
|
||||
</div>
|
||||
<div class="status-row">
|
||||
<span class="status-indicator locked" :class="{ active: location.isLocked }"></span>
|
||||
<span class="status-text">{{ location.isLocked ? '已锁定' : '未锁定' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="arrow-icon">▶</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 默认菜单:仅刷新 -->
|
||||
<template v-else>
|
||||
<div class="context-menu-item" @click="handleRefresh">
|
||||
<span>刷新</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 库位操作子菜单 -->
|
||||
<div
|
||||
v-if="showSubMenu && selectedLocation"
|
||||
class="sub-menu-container"
|
||||
:style="subMenuStyle"
|
||||
@click.stop
|
||||
@mouseenter="handleSubMenuMouseEnter"
|
||||
@mouseleave="handleSubMenuMouseLeave"
|
||||
>
|
||||
<!-- 连接区域,确保鼠标移动时不会断开 -->
|
||||
<div class="sub-menu-connector"></div>
|
||||
<div class="sub-menu">
|
||||
<div class="sub-menu-header">
|
||||
<div class="sub-menu-title">{{ selectedLocation.name }} - 操作</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>
|
||||
</div>
|
||||
<!-- 使用新的模块化组件 -->
|
||||
<ContextMenu
|
||||
:visible="visible"
|
||||
:x="x"
|
||||
:y="y"
|
||||
:menu-type="menuType"
|
||||
:storage-locations="storageLocations"
|
||||
:robot-info="robotInfo"
|
||||
:api-base-url="apiBaseUrl"
|
||||
@close="$emit('close')"
|
||||
@action-complete="$emit('actionComplete', $event)"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue';
|
||||
|
||||
import type { StorageLocationInfo } from '../services/storageApi';
|
||||
import { ContextMenu } from './context-menu';
|
||||
import type { StorageLocationInfo } from '../services/context-menu';
|
||||
import type { RobotInfo } from '../services/context-menu';
|
||||
|
||||
interface Props {
|
||||
visible: boolean;
|
||||
x?: number;
|
||||
y?: number;
|
||||
menuType?: 'default' | 'storage' | 'storage-background';
|
||||
menuType?: 'default' | 'storage' | 'storage-background' | 'robot' | 'point' | 'area';
|
||||
storageLocations?: StorageLocationInfo[];
|
||||
apiBaseUrl?: string; // 添加API基础URL
|
||||
robotInfo?: RobotInfo;
|
||||
apiBaseUrl?: string;
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'close'): void;
|
||||
(e: 'actionComplete', data: { action: string; location: StorageLocationInfo; success: boolean }): void;
|
||||
(e: 'actionComplete', data: any): void;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
x: 0,
|
||||
y: 0,
|
||||
menuType: 'default',
|
||||
storageLocations: () => [],
|
||||
apiBaseUrl: '',
|
||||
});
|
||||
const emit = defineEmits<Emits>();
|
||||
defineProps<Props>();
|
||||
defineEmits<Emits>();
|
||||
|
||||
// 定义组件名称
|
||||
defineOptions({
|
||||
name: 'ContextMenu',
|
||||
});
|
||||
|
||||
// 子菜单状态
|
||||
const showSubMenu = ref<string | null>(null);
|
||||
const selectedLocation = ref<StorageLocationInfo | null>(null);
|
||||
const hideTimer = ref<number | null>(null);
|
||||
|
||||
// 主菜单引用
|
||||
const mainMenuRef = ref<HTMLElement | null>(null);
|
||||
|
||||
// 监听子菜单显示状态
|
||||
watch(showSubMenu, (newValue) => {
|
||||
if (newValue) {
|
||||
selectedLocation.value = props.storageLocations.find(loc => loc.id === newValue) || null;
|
||||
// 清除之前的隐藏定时器
|
||||
if (hideTimer.value) {
|
||||
clearTimeout(hideTimer.value);
|
||||
hideTimer.value = null;
|
||||
}
|
||||
} else {
|
||||
selectedLocation.value = null;
|
||||
}
|
||||
});
|
||||
|
||||
// 菜单样式计算
|
||||
const menuStyle = computed(() => {
|
||||
const style = {
|
||||
left: `${props.x}px`,
|
||||
top: `${props.y}px`,
|
||||
};
|
||||
return style;
|
||||
});
|
||||
|
||||
// 子菜单样式计算
|
||||
const subMenuStyle = 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);
|
||||
|
||||
// 获取主菜单的实际宽度,让子菜单紧挨着
|
||||
let mainMenuWidth = 200; // 默认宽度
|
||||
if (mainMenuRef.value) {
|
||||
mainMenuWidth = mainMenuRef.value.offsetWidth;
|
||||
}
|
||||
|
||||
const style = {
|
||||
left: `${props.x + mainMenuWidth}px`, // 紧贴主菜单右边缘
|
||||
top: `${props.y + offsetY}px`, // 与对应库位项对齐
|
||||
};
|
||||
return style;
|
||||
});
|
||||
|
||||
// 动态标题
|
||||
const headerTitle = computed(() => {
|
||||
if (props.menuType === 'storage-background') return '库位状态';
|
||||
if (props.menuType === 'storage') return '库位信息';
|
||||
return '右键菜单';
|
||||
});
|
||||
|
||||
// 处理刷新点击
|
||||
const handleRefresh = () => {
|
||||
console.log('刷新操作');
|
||||
emit('close');
|
||||
};
|
||||
|
||||
// 选择库位
|
||||
const handleSelectStorage = (location: StorageLocationInfo) => {
|
||||
console.log('选择库位:', location);
|
||||
// 不立即关闭菜单,让子菜单显示
|
||||
};
|
||||
|
||||
// 隐藏子菜单(立即隐藏)
|
||||
const hideSubMenu = () => {
|
||||
showSubMenu.value = null;
|
||||
if (hideTimer.value) {
|
||||
clearTimeout(hideTimer.value);
|
||||
hideTimer.value = null;
|
||||
}
|
||||
};
|
||||
|
||||
// 延迟隐藏子菜单
|
||||
const hideSubMenuDelayed = () => {
|
||||
if (hideTimer.value) {
|
||||
clearTimeout(hideTimer.value);
|
||||
}
|
||||
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();
|
||||
};
|
||||
|
||||
// 库位操作处理
|
||||
const handleStorageAction = async (action: string, actionName: string) => {
|
||||
if (!selectedLocation.value) return;
|
||||
|
||||
try {
|
||||
// 直接调用API,不需要勾选
|
||||
const { StorageActionService } = await import('../services/storageActionService');
|
||||
await StorageActionService.handleStorageAction(action, selectedLocation.value, actionName);
|
||||
|
||||
// 发送操作完成事件
|
||||
emit('actionComplete', {
|
||||
action,
|
||||
location: selectedLocation.value,
|
||||
success: true
|
||||
});
|
||||
|
||||
// 关闭菜单
|
||||
emit('close');
|
||||
} catch (error) {
|
||||
console.error(`库位${actionName}操作失败:`, error);
|
||||
|
||||
// 发送操作失败事件
|
||||
emit('actionComplete', {
|
||||
action,
|
||||
location: selectedLocation.value,
|
||||
success: false
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// 获取库位项样式类
|
||||
const getStorageItemClass = (location: StorageLocationInfo) => {
|
||||
return {
|
||||
'storage-occupied': location.isOccupied,
|
||||
'storage-locked': location.isLocked,
|
||||
'storage-available': !location.isOccupied && !location.isLocked,
|
||||
};
|
||||
};
|
||||
|
||||
// 获取库位提示信息
|
||||
const getStorageTooltip = (location: StorageLocationInfo) => {
|
||||
const occupiedText = location.isOccupied ? '已占用' : '未占用';
|
||||
const lockedText = location.isLocked ? '已锁定' : '未锁定';
|
||||
return `${location.name} - 占用: ${occupiedText}, 锁定: ${lockedText}`;
|
||||
};
|
||||
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.context-menu {
|
||||
position: fixed;
|
||||
z-index: 1;
|
||||
background: white;
|
||||
border: 1px solid #d9d9d9;
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
padding: 4px 0;
|
||||
min-width: 120px;
|
||||
user-select: none;
|
||||
color: #000;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.context-menu-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 8px 12px;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s;
|
||||
font-size: 14px;
|
||||
color: #000;
|
||||
}
|
||||
|
||||
.context-menu-item:hover {
|
||||
background-color: #f5f5f5;
|
||||
}
|
||||
|
||||
.menu-icon {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
margin-right: 8px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.context-menu-item:hover .menu-icon {
|
||||
color: #000;
|
||||
}
|
||||
|
||||
/* 菜单头部 */
|
||||
.context-menu-header {
|
||||
padding: 8px 12px;
|
||||
background-color: #fafafa;
|
||||
border-radius: 6px 6px 0 0;
|
||||
}
|
||||
|
||||
.menu-title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #000;
|
||||
}
|
||||
|
||||
/* 分割线 */
|
||||
.context-menu-divider {
|
||||
height: 1px;
|
||||
background-color: #d9d9d9;
|
||||
margin: 4px 0;
|
||||
}
|
||||
|
||||
/* 库位项样式 */
|
||||
.storage-item {
|
||||
padding: 12px;
|
||||
border-left: 3px solid transparent;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.storage-item:hover {
|
||||
background-color: #f5f5f5;
|
||||
border-left-color: #1890ff;
|
||||
}
|
||||
|
||||
.storage-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.storage-name {
|
||||
font-weight: 500;
|
||||
color: #000;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.storage-status {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.status-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.status-indicator {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
display: inline-block;
|
||||
background-color: #d9d9d9; /* 默认灰色 */
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.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-text {
|
||||
color: #666;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
/* 库位状态样式 */
|
||||
.storage-occupied {
|
||||
background-color: #fff2f0;
|
||||
border-left-color: #ff4d4f;
|
||||
}
|
||||
|
||||
.storage-locked {
|
||||
background-color: #fffbe6;
|
||||
border-left-color: #faad14;
|
||||
}
|
||||
|
||||
.storage-available {
|
||||
background-color: #f6ffed;
|
||||
border-left-color: #52c41a;
|
||||
}
|
||||
|
||||
/* 箭头图标 */
|
||||
.arrow-icon {
|
||||
margin-left: auto;
|
||||
color: #999;
|
||||
font-size: 12px;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
|
||||
.storage-item:hover .arrow-icon {
|
||||
color: #1890ff;
|
||||
}
|
||||
|
||||
/* 子菜单容器样式 */
|
||||
.sub-menu-container {
|
||||
position: fixed;
|
||||
z-index: 2;
|
||||
pointer-events: auto;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
/* 连接区域 - 确保鼠标移动流畅 */
|
||||
.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;
|
||||
border: 1px solid #d9d9d9;
|
||||
border-left: none; /* 移除左边框,与主菜单无缝连接 */
|
||||
border-radius: 0 6px 6px 0; /* 只圆角右侧,左侧与主菜单连接 */
|
||||
box-shadow: 2px 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;
|
||||
}
|
||||
|
||||
.sub-menu-header {
|
||||
padding: 8px 12px;
|
||||
background-color: #fafafa;
|
||||
border-radius: 0 6px 0 0; /* 只圆角右上角,与子菜单整体设计一致 */
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.sub-menu-title {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #000;
|
||||
}
|
||||
|
||||
.sub-menu-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 8px 12px;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s;
|
||||
font-size: 13px;
|
||||
color: #000;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.sub-menu-item:hover {
|
||||
background-color: #f5f5f5;
|
||||
}
|
||||
|
||||
.action-icon {
|
||||
font-size: 14px;
|
||||
width: 16px;
|
||||
text-align: center;
|
||||
}
|
||||
</style>
|
||||
<!-- 样式已移至各个子组件中 -->
|
||||
|
139
src/components/context-menu/context-menu.vue
Normal file
139
src/components/context-menu/context-menu.vue
Normal file
@ -0,0 +1,139 @@
|
||||
<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"
|
||||
/>
|
||||
|
||||
<!-- 机器人菜单 -->
|
||||
<RobotMenu
|
||||
v-else-if="menuType === 'robot' && robotInfo"
|
||||
:robot-info="robotInfo"
|
||||
@action-complete="handleActionComplete"
|
||||
/>
|
||||
|
||||
<!-- 默认菜单 -->
|
||||
<DefaultMenu
|
||||
v-else
|
||||
@action-complete="handleActionComplete"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch, nextTick } from 'vue';
|
||||
import StorageMenu from './storage-menu.vue';
|
||||
import RobotMenu from './robot-menu.vue';
|
||||
import DefaultMenu from './default-menu.vue';
|
||||
import type { StorageLocationInfo } from '../../services/context-menu';
|
||||
import type { RobotInfo } from '../../services/context-menu';
|
||||
|
||||
interface Props {
|
||||
visible: boolean;
|
||||
x?: number;
|
||||
y?: number;
|
||||
menuType?: 'default' | 'storage' | 'storage-background' | 'robot' | 'point' | 'area';
|
||||
storageLocations?: StorageLocationInfo[];
|
||||
robotInfo?: RobotInfo;
|
||||
apiBaseUrl?: string;
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'close'): void;
|
||||
(e: 'actionComplete', data: any): void;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
x: 0,
|
||||
y: 0,
|
||||
menuType: 'default',
|
||||
storageLocations: () => [],
|
||||
apiBaseUrl: '',
|
||||
});
|
||||
const emit = defineEmits<Emits>();
|
||||
|
||||
// 定义组件名称
|
||||
defineOptions({
|
||||
name: 'ContextMenu',
|
||||
});
|
||||
|
||||
// 主菜单引用
|
||||
const mainMenuRef = ref<HTMLElement | null>(null);
|
||||
const mainMenuWidth = ref(200); // 默认宽度
|
||||
|
||||
// 监听主菜单元素变化,更新宽度
|
||||
watch(mainMenuRef, async (newRef) => {
|
||||
if (newRef) {
|
||||
await nextTick();
|
||||
mainMenuWidth.value = newRef.offsetWidth;
|
||||
}
|
||||
});
|
||||
|
||||
// 菜单样式计算
|
||||
const menuStyle = computed(() => {
|
||||
const style = {
|
||||
left: `${props.x}px`,
|
||||
top: `${props.y}px`,
|
||||
};
|
||||
return style;
|
||||
});
|
||||
|
||||
// 动态标题
|
||||
const headerTitle = computed(() => {
|
||||
if (props.menuType === 'storage-background') return '库位状态';
|
||||
if (props.menuType === 'storage') return '库位信息';
|
||||
if (props.menuType === 'robot') return '机器人操作';
|
||||
if (props.menuType === 'point') return '点位操作';
|
||||
if (props.menuType === 'area') return '区域操作';
|
||||
return '右键菜单';
|
||||
});
|
||||
|
||||
// 处理操作完成事件
|
||||
const handleActionComplete = (data: any) => {
|
||||
console.log('菜单操作完成:', data);
|
||||
emit('actionComplete', data);
|
||||
|
||||
// 某些操作完成后关闭菜单
|
||||
if (data.action && ['occupy', 'release', 'lock', 'unlock', 'enable', 'disable'].includes(data.action)) {
|
||||
emit('close');
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.context-menu {
|
||||
position: fixed;
|
||||
z-index: 1;
|
||||
background: white;
|
||||
border: 1px solid #d9d9d9;
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
padding: 4px 0;
|
||||
min-width: 120px;
|
||||
user-select: none;
|
||||
color: #000;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
/* 菜单头部 */
|
||||
.context-menu-header {
|
||||
padding: 8px 12px;
|
||||
background-color: #fafafa;
|
||||
border-radius: 6px 6px 0 0;
|
||||
}
|
||||
|
||||
.menu-title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #000;
|
||||
}
|
||||
</style>
|
85
src/components/context-menu/default-menu.vue
Normal file
85
src/components/context-menu/default-menu.vue
Normal file
@ -0,0 +1,85 @@
|
||||
<template>
|
||||
<div class="default-menu">
|
||||
<div class="context-menu-item" @click="handleRefresh">
|
||||
<span class="menu-icon">🔄</span>
|
||||
<span>刷新</span>
|
||||
</div>
|
||||
|
||||
<div class="context-menu-item" @click="handleViewInfo">
|
||||
<span class="menu-icon">ℹ️</span>
|
||||
<span>查看信息</span>
|
||||
</div>
|
||||
|
||||
<div class="context-menu-item" @click="handleSettings">
|
||||
<span class="menu-icon">⚙️</span>
|
||||
<span>设置</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
interface Emits {
|
||||
(e: 'actionComplete', data: { action: string; success: boolean }): void;
|
||||
}
|
||||
|
||||
const emit = defineEmits<Emits>();
|
||||
|
||||
// 定义组件名称
|
||||
defineOptions({
|
||||
name: 'DefaultMenu',
|
||||
});
|
||||
|
||||
// 处理刷新点击
|
||||
const handleRefresh = () => {
|
||||
console.log('刷新操作');
|
||||
emit('actionComplete', {
|
||||
action: 'refresh',
|
||||
success: true
|
||||
});
|
||||
};
|
||||
|
||||
// 处理查看信息
|
||||
const handleViewInfo = () => {
|
||||
console.log('查看信息操作');
|
||||
emit('actionComplete', {
|
||||
action: 'view_info',
|
||||
success: true
|
||||
});
|
||||
};
|
||||
|
||||
// 处理设置
|
||||
const handleSettings = () => {
|
||||
console.log('设置操作');
|
||||
emit('actionComplete', {
|
||||
action: 'settings',
|
||||
success: true
|
||||
});
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.default-menu {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.context-menu-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 8px 12px;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s;
|
||||
font-size: 14px;
|
||||
color: #000;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.context-menu-item:hover {
|
||||
background-color: #f5f5f5;
|
||||
}
|
||||
|
||||
.menu-icon {
|
||||
font-size: 14px;
|
||||
width: 16px;
|
||||
text-align: center;
|
||||
}
|
||||
</style>
|
9
src/components/context-menu/index.ts
Normal file
9
src/components/context-menu/index.ts
Normal file
@ -0,0 +1,9 @@
|
||||
/**
|
||||
* 右键菜单组件 - 主入口
|
||||
* 导出所有相关的组件
|
||||
*/
|
||||
|
||||
export { default as ContextMenu } from './context-menu.vue';
|
||||
export { default as DefaultMenu } from './default-menu.vue';
|
||||
export { default as RobotMenu } from './robot-menu.vue';
|
||||
export { default as StorageMenu } from './storage-menu.vue';
|
299
src/components/context-menu/robot-menu.vue
Normal file
299
src/components/context-menu/robot-menu.vue
Normal file
@ -0,0 +1,299 @@
|
||||
<template>
|
||||
<div class="robot-menu">
|
||||
<div v-if="robotInfo" class="robot-info-section">
|
||||
<!-- 机器人基本信息 -->
|
||||
<div class="robot-header">
|
||||
<div class="robot-name">{{ robotInfo.name }}</div>
|
||||
<div class="robot-status" :style="{ color: getStatusColor(robotInfo.status) }">
|
||||
{{ getStatusText(robotInfo.status) }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 机器人详细信息 -->
|
||||
<div class="robot-details">
|
||||
<div v-if="robotInfo.batteryLevel !== undefined" class="detail-item">
|
||||
<span class="detail-label">电量:</span>
|
||||
<div class="battery-container">
|
||||
<div class="battery-bar">
|
||||
<div
|
||||
class="battery-fill"
|
||||
:style="{ width: `${robotInfo.batteryLevel}%` }"
|
||||
:class="getBatteryClass(robotInfo.batteryLevel)"
|
||||
></div>
|
||||
</div>
|
||||
<span class="battery-text">{{ robotInfo.batteryLevel }}%</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="robotInfo.currentTask" class="detail-item">
|
||||
<span class="detail-label">当前任务:</span>
|
||||
<span class="detail-value">{{ robotInfo.currentTask }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 机器人操作菜单 -->
|
||||
<div class="robot-actions">
|
||||
<div class="action-group">
|
||||
<div class="action-group-title">基本操作</div>
|
||||
<div class="action-item" @click="handleRobotAction('start', '启动')">
|
||||
<span class="action-icon">🚀</span>
|
||||
<span>启动</span>
|
||||
</div>
|
||||
<div class="action-item" @click="handleRobotAction('stop', '停止')">
|
||||
<span class="action-icon">⏹️</span>
|
||||
<span>停止</span>
|
||||
</div>
|
||||
<div class="action-item" @click="handleRobotAction('pause', '暂停')">
|
||||
<span class="action-icon">⏸️</span>
|
||||
<span>暂停</span>
|
||||
</div>
|
||||
<div class="action-item" @click="handleRobotAction('resume', '恢复')">
|
||||
<span class="action-icon">▶️</span>
|
||||
<span>恢复</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="action-group">
|
||||
<div class="action-group-title">导航控制</div>
|
||||
<div class="action-item" @click="handleRobotAction('go_home', '回原点')">
|
||||
<span class="action-icon">🏠</span>
|
||||
<span>回原点</span>
|
||||
</div>
|
||||
<div class="action-item" @click="handleRobotAction('charge', '充电')">
|
||||
<span class="action-icon">🔋</span>
|
||||
<span>充电</span>
|
||||
</div>
|
||||
<div class="action-item" @click="handleRobotAction('emergency_stop', '紧急停止')">
|
||||
<span class="action-icon">🛑</span>
|
||||
<span>紧急停止</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="action-group">
|
||||
<div class="action-group-title">状态管理</div>
|
||||
<div class="action-item" @click="handleRobotAction('reset', '重置')">
|
||||
<span class="action-icon">🔄</span>
|
||||
<span>重置</span>
|
||||
</div>
|
||||
<div class="action-item" @click="handleRobotAction('diagnose', '诊断')">
|
||||
<span class="action-icon">🔧</span>
|
||||
<span>诊断</span>
|
||||
</div>
|
||||
<div class="action-item" @click="handleRobotAction('update', '更新')">
|
||||
<span class="action-icon">📱</span>
|
||||
<span>更新</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import type { RobotInfo } from '../../services/context-menu';
|
||||
import { getRobotStatusText, getRobotStatusColor } from '../../services/context-menu';
|
||||
|
||||
interface Props {
|
||||
robotInfo?: RobotInfo;
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'actionComplete', data: { action: string; robot: RobotInfo; success: boolean }): void;
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
const emit = defineEmits<Emits>();
|
||||
|
||||
// 定义组件名称
|
||||
defineOptions({
|
||||
name: 'RobotMenu',
|
||||
});
|
||||
|
||||
// 获取状态显示文本
|
||||
const getStatusText = (status: string): string => {
|
||||
return getRobotStatusText(status);
|
||||
};
|
||||
|
||||
// 获取状态颜色
|
||||
const getStatusColor = (status: string): string => {
|
||||
return getRobotStatusColor(status);
|
||||
};
|
||||
|
||||
// 获取电量条样式类
|
||||
const getBatteryClass = (batteryLevel: number) => {
|
||||
if (batteryLevel > 50) return 'battery-high';
|
||||
if (batteryLevel > 20) return 'battery-medium';
|
||||
return 'battery-low';
|
||||
};
|
||||
|
||||
// 处理机器人操作
|
||||
const handleRobotAction = async (action: string, actionName: string) => {
|
||||
if (!props.robotInfo) return;
|
||||
|
||||
try {
|
||||
console.log(`执行机器人操作: ${action}`, props.robotInfo);
|
||||
|
||||
// 模拟操作延迟
|
||||
await new Promise(resolve => setTimeout(resolve, 500));
|
||||
|
||||
// 发送操作完成事件
|
||||
emit('actionComplete', {
|
||||
action,
|
||||
robot: props.robotInfo,
|
||||
success: true
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(`机器人${actionName}操作失败:`, error);
|
||||
|
||||
// 发送操作失败事件
|
||||
emit('actionComplete', {
|
||||
action,
|
||||
robot: props.robotInfo,
|
||||
success: false
|
||||
});
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.robot-menu {
|
||||
width: 100%;
|
||||
min-width: 200px;
|
||||
}
|
||||
|
||||
/* 机器人信息区域 */
|
||||
.robot-info-section {
|
||||
padding: 12px;
|
||||
background-color: #f8f9fa;
|
||||
border-bottom: 1px solid #e9ecef;
|
||||
}
|
||||
|
||||
.robot-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.robot-name {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #000;
|
||||
}
|
||||
|
||||
.robot-status {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
padding: 2px 8px;
|
||||
border-radius: 12px;
|
||||
background-color: rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.robot-details {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.detail-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.detail-label {
|
||||
color: #666;
|
||||
min-width: 60px;
|
||||
}
|
||||
|
||||
.detail-value {
|
||||
color: #000;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* 电量条样式 */
|
||||
.battery-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.battery-bar {
|
||||
flex: 1;
|
||||
height: 6px;
|
||||
background-color: #e9ecef;
|
||||
border-radius: 3px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.battery-fill {
|
||||
height: 100%;
|
||||
transition: width 0.3s ease;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.battery-fill.battery-high {
|
||||
background-color: #52c41a;
|
||||
}
|
||||
|
||||
.battery-fill.battery-medium {
|
||||
background-color: #faad14;
|
||||
}
|
||||
|
||||
.battery-fill.battery-low {
|
||||
background-color: #ff4d4f;
|
||||
}
|
||||
|
||||
.battery-text {
|
||||
font-size: 11px;
|
||||
color: #666;
|
||||
min-width: 30px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
/* 操作区域 */
|
||||
.robot-actions {
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
.action-group {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.action-group:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.action-group-title {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: #666;
|
||||
padding: 4px 12px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.action-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 8px 12px;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s;
|
||||
font-size: 13px;
|
||||
color: #000;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.action-item:hover {
|
||||
background-color: #f5f5f5;
|
||||
}
|
||||
|
||||
.action-icon {
|
||||
font-size: 14px;
|
||||
width: 16px;
|
||||
text-align: center;
|
||||
}
|
||||
</style>
|
403
src/components/context-menu/storage-menu.vue
Normal file
403
src/components/context-menu/storage-menu.vue
Normal file
@ -0,0 +1,403 @@
|
||||
<template>
|
||||
<div class="storage-menu">
|
||||
<!-- 库位列表 -->
|
||||
<div
|
||||
v-for="location in storageLocations"
|
||||
:key="location.id"
|
||||
class="context-menu-item storage-item"
|
||||
:class="getStorageItemClass(location)"
|
||||
@click="handleSelectStorage(location)"
|
||||
@mouseenter="showSubMenu = location.id"
|
||||
@mouseleave="handleStorageMouseLeave"
|
||||
:title="getStorageTooltip(location)"
|
||||
>
|
||||
<div class="storage-info">
|
||||
<div class="storage-name">{{ location.name }}</div>
|
||||
<div class="storage-status">
|
||||
<div class="status-row">
|
||||
<span class="status-indicator occupied" :class="{ active: location.isOccupied }"></span>
|
||||
<span class="status-text">{{ location.isOccupied ? '已占用' : '未占用' }}</span>
|
||||
</div>
|
||||
<div class="status-row">
|
||||
<span class="status-indicator locked" :class="{ active: location.isLocked }"></span>
|
||||
<span class="status-text">{{ location.isLocked ? '已锁定' : '未锁定' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="arrow-icon">▶</div>
|
||||
</div>
|
||||
|
||||
<!-- 库位操作子菜单 -->
|
||||
<div
|
||||
v-if="showSubMenu && selectedLocation"
|
||||
class="sub-menu-container"
|
||||
:style="subMenuStyle"
|
||||
@click.stop
|
||||
@mouseenter="handleSubMenuMouseEnter"
|
||||
@mouseleave="handleSubMenuMouseLeave"
|
||||
>
|
||||
<!-- 连接区域,确保鼠标移动时不会断开 -->
|
||||
<div class="sub-menu-connector"></div>
|
||||
<div class="sub-menu">
|
||||
<div class="sub-menu-header">
|
||||
<div class="sub-menu-title">{{ selectedLocation.name }} - 操作</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>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue';
|
||||
|
||||
import type { StorageLocationInfo } from '../../services/context-menu';
|
||||
|
||||
interface Props {
|
||||
storageLocations: StorageLocationInfo[];
|
||||
menuX: number;
|
||||
menuY: number;
|
||||
mainMenuWidth: number;
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'actionComplete', data: { action: string; location: StorageLocationInfo; success: boolean }): void;
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
const emit = defineEmits<Emits>();
|
||||
|
||||
// 定义组件名称
|
||||
defineOptions({
|
||||
name: 'StorageMenu',
|
||||
});
|
||||
|
||||
// 子菜单状态
|
||||
const showSubMenu = ref<string | null>(null);
|
||||
const selectedLocation = ref<StorageLocationInfo | null>(null);
|
||||
const hideTimer = ref<number | null>(null);
|
||||
|
||||
// 监听子菜单显示状态
|
||||
watch(showSubMenu, (newValue) => {
|
||||
if (newValue) {
|
||||
selectedLocation.value = props.storageLocations.find(loc => loc.id === newValue) || null;
|
||||
// 清除之前的隐藏定时器
|
||||
if (hideTimer.value) {
|
||||
clearTimeout(hideTimer.value);
|
||||
hideTimer.value = null;
|
||||
}
|
||||
} else {
|
||||
selectedLocation.value = null;
|
||||
}
|
||||
});
|
||||
|
||||
// 子菜单样式计算
|
||||
const subMenuStyle = 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);
|
||||
|
||||
const style = {
|
||||
left: `${props.menuX + props.mainMenuWidth}px`, // 紧贴主菜单右边缘
|
||||
top: `${props.menuY + offsetY}px`, // 与对应库位项对齐
|
||||
};
|
||||
return style;
|
||||
});
|
||||
|
||||
// 选择库位
|
||||
const handleSelectStorage = (location: StorageLocationInfo) => {
|
||||
console.log('选择库位:', location);
|
||||
// 不立即关闭菜单,让子菜单显示
|
||||
};
|
||||
|
||||
// 隐藏子菜单(立即隐藏)
|
||||
const hideSubMenu = () => {
|
||||
showSubMenu.value = null;
|
||||
if (hideTimer.value) {
|
||||
clearTimeout(hideTimer.value);
|
||||
hideTimer.value = null;
|
||||
}
|
||||
};
|
||||
|
||||
// 延迟隐藏子菜单
|
||||
const hideSubMenuDelayed = () => {
|
||||
if (hideTimer.value) {
|
||||
clearTimeout(hideTimer.value);
|
||||
}
|
||||
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();
|
||||
};
|
||||
|
||||
// 库位操作处理
|
||||
const handleStorageAction = async (action: string, actionName: string) => {
|
||||
if (!selectedLocation.value) return;
|
||||
|
||||
try {
|
||||
// 直接调用API,不需要勾选
|
||||
const { StorageActionService } = await import('../../services/storageActionService');
|
||||
await StorageActionService.handleStorageAction(action, selectedLocation.value, actionName);
|
||||
|
||||
// 发送操作完成事件
|
||||
emit('actionComplete', {
|
||||
action,
|
||||
location: selectedLocation.value,
|
||||
success: true
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(`库位${actionName}操作失败:`, error);
|
||||
|
||||
// 发送操作失败事件
|
||||
emit('actionComplete', {
|
||||
action,
|
||||
location: selectedLocation.value,
|
||||
success: false
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// 获取库位项样式类
|
||||
const getStorageItemClass = (location: StorageLocationInfo) => {
|
||||
return {
|
||||
'storage-occupied': location.isOccupied,
|
||||
'storage-locked': location.isLocked,
|
||||
'storage-available': !location.isOccupied && !location.isLocked,
|
||||
};
|
||||
};
|
||||
|
||||
// 获取库位提示信息
|
||||
const getStorageTooltip = (location: StorageLocationInfo) => {
|
||||
const occupiedText = location.isOccupied ? '已占用' : '未占用';
|
||||
const lockedText = location.isLocked ? '已锁定' : '未锁定';
|
||||
return `${location.name} - 占用: ${occupiedText}, 锁定: ${lockedText}`;
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.storage-menu {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* 库位项样式 */
|
||||
.storage-item {
|
||||
padding: 12px;
|
||||
border-left: 3px solid transparent;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.storage-item:hover {
|
||||
background-color: #f5f5f5;
|
||||
border-left-color: #1890ff;
|
||||
}
|
||||
|
||||
.storage-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.storage-name {
|
||||
font-weight: 500;
|
||||
color: #000;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.storage-status {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.status-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.status-indicator {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
display: inline-block;
|
||||
background-color: #d9d9d9; /* 默认灰色 */
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.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-text {
|
||||
color: #666;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
/* 库位状态样式 */
|
||||
.storage-occupied {
|
||||
background-color: #fff2f0;
|
||||
border-left-color: #ff4d4f;
|
||||
}
|
||||
|
||||
.storage-locked {
|
||||
background-color: #fffbe6;
|
||||
border-left-color: #faad14;
|
||||
}
|
||||
|
||||
.storage-available {
|
||||
background-color: #f6ffed;
|
||||
border-left-color: #52c41a;
|
||||
}
|
||||
|
||||
/* 箭头图标 */
|
||||
.arrow-icon {
|
||||
margin-left: auto;
|
||||
color: #999;
|
||||
font-size: 12px;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
|
||||
.storage-item:hover .arrow-icon {
|
||||
color: #1890ff;
|
||||
}
|
||||
|
||||
/* 子菜单容器样式 */
|
||||
.sub-menu-container {
|
||||
position: fixed;
|
||||
z-index: 2;
|
||||
pointer-events: auto;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
/* 连接区域 - 确保鼠标移动流畅 */
|
||||
.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;
|
||||
border: 1px solid #d9d9d9;
|
||||
border-left: none; /* 移除左边框,与主菜单无缝连接 */
|
||||
border-radius: 0 6px 6px 0; /* 只圆角右侧,左侧与主菜单连接 */
|
||||
box-shadow: 2px 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;
|
||||
}
|
||||
|
||||
.sub-menu-header {
|
||||
padding: 8px 12px;
|
||||
background-color: #fafafa;
|
||||
border-radius: 0 6px 0 0; /* 只圆角右上角,与子菜单整体设计一致 */
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.sub-menu-title {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #000;
|
||||
}
|
||||
|
||||
.sub-menu-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 8px 12px;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s;
|
||||
font-size: 13px;
|
||||
color: #000;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.sub-menu-item:hover {
|
||||
background-color: #f5f5f5;
|
||||
}
|
||||
|
||||
.action-icon {
|
||||
font-size: 14px;
|
||||
width: 16px;
|
||||
text-align: center;
|
||||
}
|
||||
</style>
|
@ -365,7 +365,9 @@ const backToCards = () => {
|
||||
*/
|
||||
const handleEditorContextMenu = (penData: Record<string, unknown>) => {
|
||||
console.log('EditorService自定义右键菜单事件:', penData);
|
||||
handleContextMenuFromPenData(penData, contextMenuManager, storageLocationService.value);
|
||||
handleContextMenuFromPenData(penData, contextMenuManager, {
|
||||
storageLocationService: storageLocationService.value
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
@ -385,6 +387,16 @@ const handleCloseContextMenu = () => {
|
||||
contextMenuManager.close();
|
||||
};
|
||||
|
||||
/**
|
||||
* 处理右键菜单操作完成事件
|
||||
* @param data 操作数据
|
||||
*/
|
||||
const handleActionComplete = (data: any) => {
|
||||
console.log('右键菜单操作完成:', data);
|
||||
// 可以在这里添加操作完成后的处理逻辑
|
||||
// 比如刷新数据、显示消息等
|
||||
};
|
||||
|
||||
/**
|
||||
* 处理全局点击事件,用于关闭右键菜单
|
||||
* @param event 点击事件
|
||||
@ -470,7 +482,9 @@ const handleGlobalKeydown = (event: KeyboardEvent) => {
|
||||
:y="contextMenuState.y"
|
||||
:menu-type="contextMenuState.menuType"
|
||||
:storage-locations="contextMenuState.storageLocations"
|
||||
:robot-info="contextMenuState.robotInfo"
|
||||
@close="handleCloseContextMenu"
|
||||
@action-complete="handleActionComplete"
|
||||
/>
|
||||
</template>
|
||||
|
||||
|
@ -1,623 +1,19 @@
|
||||
/**
|
||||
* 右键菜单服务 - 组合式函数模式
|
||||
* 提供纯函数和状态管理工具,避免单例模式的问题
|
||||
* 右键菜单服务 - 向后兼容入口
|
||||
* 重新导出新的模块化服务,保持向后兼容
|
||||
*/
|
||||
|
||||
import { MapPointType } from '@api/map';
|
||||
// 重新导出所有新的模块化服务
|
||||
export * from './context-menu';
|
||||
|
||||
export interface ParsedEventData {
|
||||
type: 'robot' | 'point' | 'area' | 'storage' | 'storage-background' | 'default';
|
||||
id?: string;
|
||||
name?: string;
|
||||
pointId?: string; // 关联的点ID
|
||||
storageId?: string; // 库位ID
|
||||
tags?: string[]; // 标签信息
|
||||
target: HTMLElement;
|
||||
position: { x: number; y: number };
|
||||
pen?: Record<string, unknown>; // 原始pen对象数据
|
||||
}
|
||||
// 为了向后兼容,保持原有的导出名称
|
||||
export {
|
||||
createContextMenuManager as createContextMenuManager,
|
||||
defaultContextMenuManager as defaultContextMenuManager,
|
||||
parseEventData as parseEventData,
|
||||
parsePenData as parsePenData,
|
||||
isClickInsideMenu as isClickInsideMenu,
|
||||
handleContextMenu as handleContextMenu,
|
||||
handleContextMenuFromPenData as handleContextMenuFromPenData,
|
||||
} from './context-menu';
|
||||
|
||||
export interface StorageLocationInfo {
|
||||
id: string;
|
||||
name: string;
|
||||
isOccupied: boolean; // 是否占用
|
||||
isLocked: boolean; // 是否锁定
|
||||
status: 'available' | 'occupied' | 'locked' | 'unknown';
|
||||
}
|
||||
|
||||
export interface ContextMenuState {
|
||||
visible: boolean;
|
||||
x: number;
|
||||
y: number;
|
||||
menuType: 'default' | 'storage' | 'storage-background';
|
||||
storageLocations: StorageLocationInfo[];
|
||||
eventData?: ParsedEventData;
|
||||
}
|
||||
|
||||
/**
|
||||
* 右键菜单状态管理器
|
||||
* 使用组合式函数,避免单例模式
|
||||
*/
|
||||
export function createContextMenuManager() {
|
||||
let state: ContextMenuState = {
|
||||
visible: false,
|
||||
x: 0,
|
||||
y: 0,
|
||||
menuType: 'default',
|
||||
storageLocations: [],
|
||||
};
|
||||
|
||||
const listeners: Array<(state: ContextMenuState) => void> = [];
|
||||
|
||||
/**
|
||||
* 订阅状态变化
|
||||
*/
|
||||
function subscribe(listener: (state: ContextMenuState) => void) {
|
||||
listeners.push(listener);
|
||||
return () => {
|
||||
const index = listeners.indexOf(listener);
|
||||
if (index > -1) {
|
||||
listeners.splice(index, 1);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 通知状态变化
|
||||
*/
|
||||
function notify() {
|
||||
listeners.forEach(listener => listener({ ...state }));
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前状态
|
||||
*/
|
||||
function getState(): ContextMenuState {
|
||||
return { ...state };
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新状态
|
||||
*/
|
||||
function setState(newState: Partial<ContextMenuState>) {
|
||||
state = { ...state, ...newState };
|
||||
notify();
|
||||
}
|
||||
|
||||
/**
|
||||
* 关闭菜单
|
||||
*/
|
||||
function close() {
|
||||
setState({ visible: false });
|
||||
}
|
||||
|
||||
return {
|
||||
subscribe,
|
||||
getState,
|
||||
setState,
|
||||
close,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析penData - 纯函数,无副作用
|
||||
* @param penData EditorService传递的pen数据
|
||||
* @returns 解析后的事件数据
|
||||
*/
|
||||
export function parsePenData(penData: Record<string, unknown>): ParsedEventData {
|
||||
// 从penData中提取pen数据和事件信息
|
||||
const pen = penData.pen as Record<string, unknown>;
|
||||
const eventInfo = penData.e as { clientX: number; clientY: number };
|
||||
|
||||
const position = {
|
||||
x: eventInfo?.clientX || 0,
|
||||
y: eventInfo?.clientY || 0
|
||||
};
|
||||
|
||||
// 如果有pen数据,优先使用pen信息进行解析
|
||||
if (pen) {
|
||||
console.log('解析pen数据:', pen);
|
||||
const { id, name, tags = [], storageLocation } = pen as {
|
||||
id?: string;
|
||||
name?: string;
|
||||
tags?: string[];
|
||||
storageLocation?: {
|
||||
pointId: string;
|
||||
locationName: string;
|
||||
index: number;
|
||||
occupied: boolean;
|
||||
locked: boolean;
|
||||
};
|
||||
};
|
||||
|
||||
console.log('解析后的数据:', { id, name, tags, storageLocation });
|
||||
|
||||
// 根据tags判断类型 - 统一处理库位相关区域
|
||||
if (tags.includes('storage-background') || tags.includes('storage-location')) {
|
||||
const isBackground = tags.includes('storage-background');
|
||||
|
||||
console.log(`识别为库位相关类型: ${isBackground ? 'storage-background' : 'storage-location'}`);
|
||||
|
||||
// 库位背景区域或单个库位区域 - 都查找该点关联的所有库位
|
||||
const pointId = tags.find((tag: string) => tag.startsWith('point-'))?.replace('point-', '');
|
||||
return {
|
||||
type: 'storage-background', // 统一使用storage-background类型
|
||||
id,
|
||||
name,
|
||||
pointId,
|
||||
storageId: id,
|
||||
tags,
|
||||
target: document.elementFromPoint(position.x, position.y) as HTMLElement,
|
||||
position,
|
||||
pen,
|
||||
};
|
||||
}
|
||||
|
||||
if (tags.includes('point')) {
|
||||
// 点区域
|
||||
return {
|
||||
type: 'point',
|
||||
id,
|
||||
name,
|
||||
pointId: id,
|
||||
tags,
|
||||
target: document.elementFromPoint(position.x, position.y) as HTMLElement,
|
||||
position,
|
||||
pen,
|
||||
};
|
||||
}
|
||||
|
||||
if (tags.includes('robot')) {
|
||||
// 机器人区域
|
||||
return {
|
||||
type: 'robot',
|
||||
id,
|
||||
name,
|
||||
tags,
|
||||
target: document.elementFromPoint(position.x, position.y) as HTMLElement,
|
||||
position,
|
||||
pen,
|
||||
};
|
||||
}
|
||||
|
||||
if (tags.includes('area')) {
|
||||
// 区域
|
||||
return {
|
||||
type: 'area',
|
||||
id,
|
||||
name,
|
||||
tags,
|
||||
target: document.elementFromPoint(position.x, position.y) as HTMLElement,
|
||||
position,
|
||||
pen,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// 默认情况
|
||||
console.log('未识别到特定类型,使用默认类型');
|
||||
return {
|
||||
type: 'default',
|
||||
target: document.elementFromPoint(position.x, position.y) as HTMLElement,
|
||||
position,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析事件数据 - 纯函数,无副作用
|
||||
* @param event 鼠标事件或指针事件
|
||||
* @returns 解析后的事件数据
|
||||
*/
|
||||
export function parseEventData(event: MouseEvent | PointerEvent): ParsedEventData {
|
||||
const target = event.target as HTMLElement;
|
||||
const position = { x: event.clientX, y: event.clientY };
|
||||
// 从事件对象中获取pen数据(如果存在)
|
||||
const pen = (event as MouseEvent & { pen?: Record<string, unknown> }).pen;
|
||||
|
||||
// 如果有pen数据,优先使用pen信息进行解析
|
||||
if (pen) {
|
||||
console.log('解析pen数据:', pen);
|
||||
const { id, name, tags = [], storageLocation } = pen as {
|
||||
id?: string;
|
||||
name?: string;
|
||||
tags?: string[];
|
||||
storageLocation?: {
|
||||
pointId: string;
|
||||
locationName: string;
|
||||
index: number;
|
||||
occupied: boolean;
|
||||
locked: boolean;
|
||||
};
|
||||
};
|
||||
|
||||
console.log('解析后的数据:', { id, name, tags, storageLocation });
|
||||
// 根据tags判断类型
|
||||
if (tags.includes('storage-background')) {
|
||||
console.log('识别为storage-background类型');
|
||||
// 库位背景区域
|
||||
const pointId = tags.find((tag: string) => tag.startsWith('point-'))?.replace('point-', '');
|
||||
return {
|
||||
type: 'storage-background',
|
||||
id,
|
||||
name,
|
||||
pointId,
|
||||
storageId: id,
|
||||
tags,
|
||||
target,
|
||||
position,
|
||||
pen,
|
||||
};
|
||||
}
|
||||
|
||||
if (tags.includes('storage-location')) {
|
||||
console.log('识别为storage-location类型');
|
||||
// 库位区域 - 使用storageLocation中的详细信息
|
||||
const pointId = tags.find((tag: string) => tag.startsWith('point-'))?.replace('point-', '');
|
||||
return {
|
||||
type: 'storage',
|
||||
id,
|
||||
name: storageLocation?.locationName || name,
|
||||
pointId,
|
||||
storageId: id,
|
||||
tags,
|
||||
target,
|
||||
position,
|
||||
pen,
|
||||
};
|
||||
}
|
||||
|
||||
if (tags.includes('point')) {
|
||||
// 点区域
|
||||
return {
|
||||
type: 'point',
|
||||
id,
|
||||
name,
|
||||
pointId: id,
|
||||
tags,
|
||||
target,
|
||||
position,
|
||||
pen,
|
||||
};
|
||||
}
|
||||
|
||||
if (tags.includes('robot')) {
|
||||
// 机器人区域
|
||||
return {
|
||||
type: 'robot',
|
||||
id,
|
||||
name,
|
||||
tags,
|
||||
target,
|
||||
position,
|
||||
pen,
|
||||
};
|
||||
}
|
||||
|
||||
if (tags.includes('area')) {
|
||||
// 区域
|
||||
return {
|
||||
type: 'area',
|
||||
id,
|
||||
name,
|
||||
tags,
|
||||
target,
|
||||
position,
|
||||
pen,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// 回退到DOM元素检查
|
||||
if (target?.closest('.robot-item')) {
|
||||
return {
|
||||
type: 'robot',
|
||||
id: target.dataset.robotId || target.id,
|
||||
name: target.dataset.robotName || target.textContent?.trim(),
|
||||
target,
|
||||
position,
|
||||
};
|
||||
}
|
||||
|
||||
if (target?.closest('.point-item')) {
|
||||
return {
|
||||
type: 'point',
|
||||
id: target.dataset.pointId || target.id,
|
||||
name: target.dataset.pointName || target.textContent?.trim(),
|
||||
target,
|
||||
position,
|
||||
};
|
||||
}
|
||||
|
||||
if (target?.closest('.area-item')) {
|
||||
return {
|
||||
type: 'area',
|
||||
id: target.dataset.areaId || target.id,
|
||||
name: target.dataset.areaName || target.textContent?.trim(),
|
||||
target,
|
||||
position,
|
||||
};
|
||||
}
|
||||
|
||||
if (target?.closest('.storage-location')) {
|
||||
return {
|
||||
type: 'storage',
|
||||
id: target.dataset.storageId || target.id,
|
||||
name: target.dataset.storageName || target.textContent?.trim(),
|
||||
target,
|
||||
position,
|
||||
};
|
||||
}
|
||||
|
||||
// 默认情况
|
||||
console.log('未识别到特定类型,使用默认类型');
|
||||
return {
|
||||
type: 'default',
|
||||
target,
|
||||
position,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查点击是否在菜单区域内 - 纯函数
|
||||
* @param event 点击事件
|
||||
* @param isMenuVisible 菜单是否可见
|
||||
* @returns 是否在菜单区域内
|
||||
*/
|
||||
export function isClickInsideMenu(event: MouseEvent, isMenuVisible: boolean): boolean {
|
||||
if (!isMenuVisible) return false;
|
||||
|
||||
const menuElement = document.querySelector('.context-menu');
|
||||
if (!menuElement) return false;
|
||||
|
||||
const rect = menuElement.getBoundingClientRect();
|
||||
const x = event.clientX;
|
||||
const y = event.clientY;
|
||||
|
||||
return (
|
||||
x >= rect.left &&
|
||||
x <= rect.right &&
|
||||
y >= rect.top &&
|
||||
y <= rect.bottom
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取菜单配置 - 纯函数
|
||||
* @param type 事件类型
|
||||
* @param data 事件数据
|
||||
* @returns 菜单配置
|
||||
*/
|
||||
export function getMenuConfig(type: string, data: ParsedEventData, storageLocationService?: any) {
|
||||
switch (type) {
|
||||
case 'storage-background': {
|
||||
// 库位背景区域或单个库位区域:显示该点关联的所有库位信息
|
||||
console.log(`处理库位相关类型,点ID: ${data.pointId}`);
|
||||
const allStorageLocations = getStorageLocationsForPoint(data, storageLocationService);
|
||||
console.log(`找到 ${allStorageLocations.length} 个库位:`, allStorageLocations);
|
||||
return {
|
||||
menuType: 'storage-background' as const,
|
||||
storageLocations: allStorageLocations,
|
||||
};
|
||||
}
|
||||
case 'storage': {
|
||||
// 单个库位区域 - 也使用storage-background类型统一处理
|
||||
console.log(`处理单个库位类型,点ID: ${data.pointId}`);
|
||||
const singleStorageLocations = getStorageLocationsForPoint(data, storageLocationService);
|
||||
console.log(`找到 ${singleStorageLocations.length} 个库位:`, singleStorageLocations);
|
||||
return {
|
||||
menuType: 'storage-background' as const,
|
||||
storageLocations: singleStorageLocations,
|
||||
};
|
||||
}
|
||||
case 'point': {
|
||||
// 点区域:只有动作点且有库位信息时才显示库位菜单
|
||||
console.log(`处理点区域类型,点ID: ${data.pointId}`);
|
||||
|
||||
// 检查是否为动作点且有库位信息
|
||||
const isActionPointWithStorage = checkIfActionPointWithStorage(data);
|
||||
|
||||
if (isActionPointWithStorage) {
|
||||
const pointStorageLocations = getStorageLocationsForPoint(data, storageLocationService);
|
||||
console.log(`动作点找到 ${pointStorageLocations.length} 个库位:`, pointStorageLocations);
|
||||
return {
|
||||
menuType: 'storage-background' as const,
|
||||
storageLocations: pointStorageLocations,
|
||||
};
|
||||
} else {
|
||||
console.log('非动作点或无库位信息,显示默认菜单');
|
||||
return {
|
||||
menuType: 'default' as const,
|
||||
storageLocations: [],
|
||||
};
|
||||
}
|
||||
}
|
||||
default:
|
||||
return {
|
||||
menuType: 'default' as const,
|
||||
storageLocations: [],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 检查是否为动作点且有库位信息
|
||||
* @param data 事件数据
|
||||
* @returns 是否为动作点且有库位信息
|
||||
*/
|
||||
function checkIfActionPointWithStorage(data: ParsedEventData): boolean {
|
||||
// 从pen数据中获取点信息
|
||||
const pen = data.pen;
|
||||
if (!pen) {
|
||||
console.log('无pen数据,不是动作点');
|
||||
return false;
|
||||
}
|
||||
|
||||
const pointInfo = pen.point as any;
|
||||
if (!pointInfo) {
|
||||
console.log('无point信息,不是动作点');
|
||||
return false;
|
||||
}
|
||||
|
||||
// 检查是否为动作点
|
||||
const isActionPoint = pointInfo.type === MapPointType.动作点;
|
||||
if (!isActionPoint) {
|
||||
console.log(`点类型为 ${pointInfo.type},不是动作点`);
|
||||
return false;
|
||||
}
|
||||
|
||||
// 检查是否有库位信息
|
||||
const hasStorageLocations = pointInfo.associatedStorageLocations &&
|
||||
Array.isArray(pointInfo.associatedStorageLocations) &&
|
||||
pointInfo.associatedStorageLocations.length > 0;
|
||||
|
||||
console.log(`动作点检查结果: 是动作点=${isActionPoint}, 有库位信息=${hasStorageLocations}`);
|
||||
|
||||
return isActionPoint && hasStorageLocations;
|
||||
}
|
||||
|
||||
/**
|
||||
* 查找指定点ID的所有库位信息
|
||||
* @param pointId 点ID
|
||||
* @param storageLocationService StorageLocationService实例(可选)
|
||||
* @returns 库位信息列表
|
||||
*/
|
||||
function findStorageLocationsByPointId(pointId: string, storageLocationService?: any): StorageLocationInfo[] {
|
||||
console.log(`查找点 ${pointId} 关联的所有库位`);
|
||||
|
||||
// 如果提供了StorageLocationService,尝试使用它获取真实数据
|
||||
if (storageLocationService && typeof storageLocationService.getLocationsByPointId === 'function') {
|
||||
const locations = storageLocationService.getLocationsByPointId(pointId);
|
||||
if (locations && locations.length > 0) {
|
||||
console.log(`从StorageLocationService获取到 ${locations.length} 个库位:`, locations);
|
||||
// 转换StorageLocationService的数据格式到我们的格式
|
||||
const convertedLocations = locations.map((location: any) => {
|
||||
const converted = {
|
||||
id: location.id || `storage-${pointId}-${location.layer_index || 0}`,
|
||||
name: location.layer_name || location.locationName || location.name || '未知库位',
|
||||
isOccupied: location.is_occupied || location.occupied || false,
|
||||
isLocked: location.is_locked || location.locked || false,
|
||||
status: (location.is_locked || location.locked)
|
||||
? 'locked'
|
||||
: (location.is_occupied || location.occupied)
|
||||
? 'occupied'
|
||||
: 'available',
|
||||
};
|
||||
console.log(`转换库位数据: ${location.layer_name} -> ${converted.name}, 占用: ${converted.isOccupied}, 锁定: ${converted.isLocked}, 状态: ${converted.status}`);
|
||||
return converted;
|
||||
});
|
||||
return convertedLocations;
|
||||
}
|
||||
}
|
||||
|
||||
// 回退到模拟数据
|
||||
console.log('使用模拟数据');
|
||||
const mockStorageLocations: StorageLocationInfo[] = [
|
||||
{
|
||||
id: `storage-${pointId}-0`,
|
||||
name: 'GSA-1-1-1', // 模拟库位名称
|
||||
isOccupied: false,
|
||||
isLocked: false,
|
||||
status: 'available',
|
||||
},
|
||||
{
|
||||
id: `storage-${pointId}-1`,
|
||||
name: 'GSA-1-1-2',
|
||||
isOccupied: true,
|
||||
isLocked: false,
|
||||
status: 'occupied',
|
||||
},
|
||||
{
|
||||
id: `storage-${pointId}-2`,
|
||||
name: 'GSA-1-1-3',
|
||||
isOccupied: false,
|
||||
isLocked: true,
|
||||
status: 'locked',
|
||||
},
|
||||
];
|
||||
|
||||
return mockStorageLocations;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取点关联的库位信息列表
|
||||
* @param data 事件数据
|
||||
* @param storageLocationService StorageLocationService实例(可选)
|
||||
* @returns 库位信息列表
|
||||
*/
|
||||
function getStorageLocationsForPoint(data: ParsedEventData, storageLocationService?: any): StorageLocationInfo[] {
|
||||
const pointId = data.pointId || data.id;
|
||||
|
||||
if (!pointId) {
|
||||
console.warn('无法获取点ID,返回空库位列表');
|
||||
return [];
|
||||
}
|
||||
|
||||
return findStorageLocationsByPointId(pointId, storageLocationService);
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理右键事件 - 组合函数
|
||||
* @param event 鼠标事件或指针事件
|
||||
* @param manager 状态管理器
|
||||
*/
|
||||
export function handleContextMenu(
|
||||
event: MouseEvent | PointerEvent,
|
||||
manager: ReturnType<typeof createContextMenuManager>
|
||||
) {
|
||||
// 阻止默认右键菜单
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
// 1. 解析事件数据
|
||||
const parsedData = parseEventData(event);
|
||||
|
||||
// 2. 获取菜单配置
|
||||
const menuConfig = getMenuConfig(parsedData.type, parsedData);
|
||||
|
||||
// 3. 更新状态
|
||||
manager.setState({
|
||||
visible: true,
|
||||
x: parsedData.position.x,
|
||||
y: parsedData.position.y,
|
||||
menuType: menuConfig.menuType,
|
||||
storageLocations: menuConfig.storageLocations,
|
||||
eventData: parsedData,
|
||||
});
|
||||
|
||||
console.log('右键菜单事件数据:', parsedData);
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理EditorService的penData - 组合函数
|
||||
* @param penData EditorService传递的pen数据
|
||||
* @param manager 状态管理器
|
||||
* @param storageLocationService StorageLocationService实例(可选)
|
||||
*/
|
||||
export function handleContextMenuFromPenData(
|
||||
penData: Record<string, unknown>,
|
||||
manager: ReturnType<typeof createContextMenuManager>,
|
||||
storageLocationService?: any
|
||||
) {
|
||||
// 1. 解析penData
|
||||
const parsedData = parsePenData(penData);
|
||||
|
||||
// 2. 获取菜单配置
|
||||
const menuConfig = getMenuConfig(parsedData.type, parsedData, storageLocationService);
|
||||
|
||||
// 3. 更新状态
|
||||
manager.setState({
|
||||
visible: true,
|
||||
x: parsedData.position.x,
|
||||
y: parsedData.position.y,
|
||||
menuType: menuConfig.menuType,
|
||||
storageLocations: menuConfig.storageLocations,
|
||||
eventData: parsedData,
|
||||
});
|
||||
|
||||
console.log('右键菜单事件数据:', parsedData);
|
||||
}
|
||||
|
||||
// 为了向后兼容,提供一个默认的状态管理器实例
|
||||
// 但建议在组件中创建独立的状态管理器
|
||||
export const defaultContextMenuManager = createContextMenuManager();
|
||||
|
164
src/services/context-menu/README.md
Normal file
164
src/services/context-menu/README.md
Normal file
@ -0,0 +1,164 @@
|
||||
# 右键菜单服务 - 模块化架构
|
||||
|
||||
## 概述
|
||||
|
||||
右键菜单服务已重构为模块化架构,按业务逻辑分离到不同的文件中,提高了代码的可维护性和可扩展性。
|
||||
|
||||
## 文件夹结构
|
||||
|
||||
```
|
||||
src/services/context-menu/
|
||||
├── index.ts # 主入口文件,导出所有服务
|
||||
├── state-manager.ts # 核心状态管理
|
||||
├── event-parser.ts # 事件解析器
|
||||
├── storage-menu.service.ts # 库位相关业务逻辑
|
||||
├── robot-menu.service.ts # 机器人相关业务逻辑
|
||||
├── menu-config.service.ts # 菜单配置服务
|
||||
└── README.md # 说明文档
|
||||
```
|
||||
|
||||
## 组件结构
|
||||
|
||||
```
|
||||
src/components/context-menu/
|
||||
├── index.ts # 组件入口文件
|
||||
├── context-menu.vue # 主菜单组件
|
||||
├── storage-menu.vue # 库位菜单组件
|
||||
├── robot-menu.vue # 机器人菜单组件
|
||||
└── default-menu.vue # 默认菜单组件
|
||||
```
|
||||
|
||||
## 核心模块说明
|
||||
|
||||
### 1. 状态管理 (state-manager.ts)
|
||||
|
||||
- 提供纯函数和状态管理工具
|
||||
- 避免单例模式的问题
|
||||
- 支持订阅/通知机制
|
||||
|
||||
### 2. 事件解析 (event-parser.ts)
|
||||
|
||||
- 解析鼠标事件和pen数据
|
||||
- 提取事件信息
|
||||
- 纯函数,无副作用
|
||||
|
||||
### 3. 库位菜单服务 (storage-menu.service.ts)
|
||||
|
||||
- 处理库位相关的菜单逻辑
|
||||
- 库位状态检查
|
||||
- 库位操作处理
|
||||
|
||||
### 4. 机器人菜单服务 (robot-menu.service.ts)
|
||||
|
||||
- 处理机器人相关的菜单逻辑
|
||||
- 机器人状态管理
|
||||
- 机器人操作处理
|
||||
|
||||
### 5. 菜单配置服务 (menu-config.service.ts)
|
||||
|
||||
- 统一管理不同业务类型的菜单配置
|
||||
- 提供统一的菜单配置入口
|
||||
|
||||
## 使用方式
|
||||
|
||||
### 基本使用
|
||||
|
||||
```typescript
|
||||
import { createContextMenuManager, handleContextMenu, handleContextMenuFromPenData } from '@/services/context-menu';
|
||||
|
||||
// 创建状态管理器
|
||||
const manager = createContextMenuManager();
|
||||
|
||||
// 处理右键事件
|
||||
handleContextMenu(event, manager, {
|
||||
storageLocationService: storageService,
|
||||
robotService: robotService,
|
||||
});
|
||||
```
|
||||
|
||||
### 组件使用
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<ContextMenu
|
||||
:visible="menuVisible"
|
||||
:x="menuX"
|
||||
:y="menuY"
|
||||
:menu-type="menuType"
|
||||
:storage-locations="storageLocations"
|
||||
:robot-info="robotInfo"
|
||||
@close="handleClose"
|
||||
@action-complete="handleActionComplete"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ContextMenu } from '@/components/context-menu';
|
||||
</script>
|
||||
```
|
||||
|
||||
## 扩展新业务类型
|
||||
|
||||
### 1. 添加新的业务服务
|
||||
|
||||
在 `src/services/context-menu/` 下创建新的服务文件,例如 `point-menu.service.ts`:
|
||||
|
||||
```typescript
|
||||
export interface PointMenuConfig {
|
||||
menuType: 'point' | 'default';
|
||||
pointInfo?: PointInfo;
|
||||
}
|
||||
|
||||
export function getPointMenuConfig(data: ParsedEventData): PointMenuConfig {
|
||||
// 实现点位菜单逻辑
|
||||
}
|
||||
```
|
||||
|
||||
### 2. 添加新的组件
|
||||
|
||||
在 `src/components/context-menu/` 下创建新的组件文件,例如 `point-menu.vue`:
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<div class="point-menu">
|
||||
<!-- 点位菜单内容 -->
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
// 实现点位菜单组件
|
||||
</script>
|
||||
```
|
||||
|
||||
### 3. 更新菜单配置服务
|
||||
|
||||
在 `menu-config.service.ts` 中添加新的业务类型处理:
|
||||
|
||||
```typescript
|
||||
case 'point':
|
||||
return getPointMenuConfig(data);
|
||||
```
|
||||
|
||||
### 4. 更新主菜单组件
|
||||
|
||||
在 `context-menu.vue` 中添加新的组件:
|
||||
|
||||
```vue
|
||||
<PointMenu
|
||||
v-else-if="menuType === 'point' && pointInfo"
|
||||
:point-info="pointInfo"
|
||||
@action-complete="handleActionComplete"
|
||||
/>
|
||||
```
|
||||
|
||||
## 向后兼容
|
||||
|
||||
原有的 `context-menu.service.ts` 文件仍然保留,作为向后兼容的入口,重新导出新的模块化服务。现有代码无需修改即可继续使用。
|
||||
|
||||
## 优势
|
||||
|
||||
1. **模块化**: 按业务逻辑分离,代码更清晰
|
||||
2. **可维护性**: 每个模块职责单一,易于维护
|
||||
3. **可扩展性**: 新增业务类型只需添加对应模块
|
||||
4. **可测试性**: 每个模块可独立测试
|
||||
5. **向后兼容**: 保持现有API不变
|
298
src/services/context-menu/event-parser.ts
Normal file
298
src/services/context-menu/event-parser.ts
Normal file
@ -0,0 +1,298 @@
|
||||
/**
|
||||
* 事件解析器 - 纯函数,无副作用
|
||||
* 负责解析鼠标事件和pen数据,提取事件信息
|
||||
*/
|
||||
|
||||
export interface ParsedEventData {
|
||||
type: 'robot' | 'point' | 'area' | 'storage' | 'storage-background' | 'default';
|
||||
id?: string;
|
||||
name?: string;
|
||||
pointId?: string; // 关联的点ID
|
||||
storageId?: string; // 库位ID
|
||||
tags?: string[]; // 标签信息
|
||||
target: HTMLElement;
|
||||
position: { x: number; y: number };
|
||||
pen?: Record<string, unknown>; // 原始pen对象数据
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析penData - 纯函数,无副作用
|
||||
* @param penData EditorService传递的pen数据
|
||||
* @returns 解析后的事件数据
|
||||
*/
|
||||
export function parsePenData(penData: Record<string, unknown>): ParsedEventData {
|
||||
// 从penData中提取pen数据和事件信息
|
||||
const pen = penData.pen as Record<string, unknown>;
|
||||
const eventInfo = penData.e as { clientX: number; clientY: number };
|
||||
|
||||
const position = {
|
||||
x: eventInfo?.clientX || 0,
|
||||
y: eventInfo?.clientY || 0
|
||||
};
|
||||
|
||||
// 如果有pen数据,优先使用pen信息进行解析
|
||||
if (pen) {
|
||||
console.log('解析pen数据:', pen);
|
||||
const { id, name, tags = [], storageLocation } = pen as {
|
||||
id?: string;
|
||||
name?: string;
|
||||
tags?: string[];
|
||||
storageLocation?: {
|
||||
pointId: string;
|
||||
locationName: string;
|
||||
index: number;
|
||||
occupied: boolean;
|
||||
locked: boolean;
|
||||
};
|
||||
};
|
||||
|
||||
console.log('解析后的数据:', { id, name, tags, storageLocation });
|
||||
|
||||
// 根据tags判断类型 - 统一处理库位相关区域
|
||||
if (tags.includes('storage-background') || tags.includes('storage-location')) {
|
||||
const isBackground = tags.includes('storage-background');
|
||||
|
||||
console.log(`识别为库位相关类型: ${isBackground ? 'storage-background' : 'storage-location'}`);
|
||||
|
||||
// 库位背景区域或单个库位区域 - 都查找该点关联的所有库位
|
||||
const pointId = tags.find((tag: string) => tag.startsWith('point-'))?.replace('point-', '');
|
||||
return {
|
||||
type: 'storage-background', // 统一使用storage-background类型
|
||||
id,
|
||||
name,
|
||||
pointId,
|
||||
storageId: id,
|
||||
tags,
|
||||
target: document.elementFromPoint(position.x, position.y) as HTMLElement,
|
||||
position,
|
||||
pen,
|
||||
};
|
||||
}
|
||||
|
||||
if (tags.includes('point')) {
|
||||
// 点区域
|
||||
return {
|
||||
type: 'point',
|
||||
id,
|
||||
name,
|
||||
pointId: id,
|
||||
tags,
|
||||
target: document.elementFromPoint(position.x, position.y) as HTMLElement,
|
||||
position,
|
||||
pen,
|
||||
};
|
||||
}
|
||||
|
||||
if (tags.includes('robot')) {
|
||||
// 机器人区域
|
||||
return {
|
||||
type: 'robot',
|
||||
id,
|
||||
name,
|
||||
tags,
|
||||
target: document.elementFromPoint(position.x, position.y) as HTMLElement,
|
||||
position,
|
||||
pen,
|
||||
};
|
||||
}
|
||||
|
||||
if (tags.includes('area')) {
|
||||
// 区域
|
||||
return {
|
||||
type: 'area',
|
||||
id,
|
||||
name,
|
||||
tags,
|
||||
target: document.elementFromPoint(position.x, position.y) as HTMLElement,
|
||||
position,
|
||||
pen,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// 默认情况
|
||||
console.log('未识别到特定类型,使用默认类型');
|
||||
return {
|
||||
type: 'default',
|
||||
target: document.elementFromPoint(position.x, position.y) as HTMLElement,
|
||||
position,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析事件数据 - 纯函数,无副作用
|
||||
* @param event 鼠标事件或指针事件
|
||||
* @returns 解析后的事件数据
|
||||
*/
|
||||
export function parseEventData(event: MouseEvent | PointerEvent): ParsedEventData {
|
||||
const target = event.target as HTMLElement;
|
||||
const position = { x: event.clientX, y: event.clientY };
|
||||
// 从事件对象中获取pen数据(如果存在)
|
||||
const pen = (event as MouseEvent & { pen?: Record<string, unknown> }).pen;
|
||||
|
||||
// 如果有pen数据,优先使用pen信息进行解析
|
||||
if (pen) {
|
||||
console.log('解析pen数据:', pen);
|
||||
const { id, name, tags = [], storageLocation } = pen as {
|
||||
id?: string;
|
||||
name?: string;
|
||||
tags?: string[];
|
||||
storageLocation?: {
|
||||
pointId: string;
|
||||
locationName: string;
|
||||
index: number;
|
||||
occupied: boolean;
|
||||
locked: boolean;
|
||||
};
|
||||
};
|
||||
|
||||
console.log('解析后的数据:', { id, name, tags, storageLocation });
|
||||
// 根据tags判断类型
|
||||
if (tags.includes('storage-background')) {
|
||||
console.log('识别为storage-background类型');
|
||||
// 库位背景区域
|
||||
const pointId = tags.find((tag: string) => tag.startsWith('point-'))?.replace('point-', '');
|
||||
return {
|
||||
type: 'storage-background',
|
||||
id,
|
||||
name,
|
||||
pointId,
|
||||
storageId: id,
|
||||
tags,
|
||||
target,
|
||||
position,
|
||||
pen,
|
||||
};
|
||||
}
|
||||
|
||||
if (tags.includes('storage-location')) {
|
||||
console.log('识别为storage-location类型');
|
||||
// 库位区域 - 使用storageLocation中的详细信息
|
||||
const pointId = tags.find((tag: string) => tag.startsWith('point-'))?.replace('point-', '');
|
||||
return {
|
||||
type: 'storage',
|
||||
id,
|
||||
name: storageLocation?.locationName || name,
|
||||
pointId,
|
||||
storageId: id,
|
||||
tags,
|
||||
target,
|
||||
position,
|
||||
pen,
|
||||
};
|
||||
}
|
||||
|
||||
if (tags.includes('point')) {
|
||||
// 点区域
|
||||
return {
|
||||
type: 'point',
|
||||
id,
|
||||
name,
|
||||
pointId: id,
|
||||
tags,
|
||||
target,
|
||||
position,
|
||||
pen,
|
||||
};
|
||||
}
|
||||
|
||||
if (tags.includes('robot')) {
|
||||
// 机器人区域
|
||||
return {
|
||||
type: 'robot',
|
||||
id,
|
||||
name,
|
||||
tags,
|
||||
target,
|
||||
position,
|
||||
pen,
|
||||
};
|
||||
}
|
||||
|
||||
if (tags.includes('area')) {
|
||||
// 区域
|
||||
return {
|
||||
type: 'area',
|
||||
id,
|
||||
name,
|
||||
tags,
|
||||
target,
|
||||
position,
|
||||
pen,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// 回退到DOM元素检查
|
||||
if (target?.closest('.robot-item')) {
|
||||
return {
|
||||
type: 'robot',
|
||||
id: target.dataset.robotId || target.id,
|
||||
name: target.dataset.robotName || target.textContent?.trim(),
|
||||
target,
|
||||
position,
|
||||
};
|
||||
}
|
||||
|
||||
if (target?.closest('.point-item')) {
|
||||
return {
|
||||
type: 'point',
|
||||
id: target.dataset.pointId || target.id,
|
||||
name: target.dataset.pointName || target.textContent?.trim(),
|
||||
target,
|
||||
position,
|
||||
};
|
||||
}
|
||||
|
||||
if (target?.closest('.area-item')) {
|
||||
return {
|
||||
type: 'area',
|
||||
id: target.dataset.areaId || target.id,
|
||||
name: target.dataset.areaName || target.textContent?.trim(),
|
||||
target,
|
||||
position,
|
||||
};
|
||||
}
|
||||
|
||||
if (target?.closest('.storage-location')) {
|
||||
return {
|
||||
type: 'storage',
|
||||
id: target.dataset.storageId || target.id,
|
||||
name: target.dataset.storageName || target.textContent?.trim(),
|
||||
target,
|
||||
position,
|
||||
};
|
||||
}
|
||||
|
||||
// 默认情况
|
||||
console.log('未识别到特定类型,使用默认类型');
|
||||
return {
|
||||
type: 'default',
|
||||
target,
|
||||
position,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查点击是否在菜单区域内 - 纯函数
|
||||
* @param event 点击事件
|
||||
* @param isMenuVisible 菜单是否可见
|
||||
* @returns 是否在菜单区域内
|
||||
*/
|
||||
export function isClickInsideMenu(event: MouseEvent, isMenuVisible: boolean): boolean {
|
||||
if (!isMenuVisible) return false;
|
||||
|
||||
const menuElement = document.querySelector('.context-menu');
|
||||
if (!menuElement) return false;
|
||||
|
||||
const rect = menuElement.getBoundingClientRect();
|
||||
const x = event.clientX;
|
||||
const y = event.clientY;
|
||||
|
||||
return (
|
||||
x >= rect.left &&
|
||||
x <= rect.right &&
|
||||
y >= rect.top &&
|
||||
y <= rect.bottom
|
||||
);
|
||||
}
|
39
src/services/context-menu/index.ts
Normal file
39
src/services/context-menu/index.ts
Normal file
@ -0,0 +1,39 @@
|
||||
/**
|
||||
* 右键菜单服务 - 主入口
|
||||
* 导出所有相关的服务和类型
|
||||
*/
|
||||
|
||||
// 状态管理
|
||||
export type { ContextMenuState } from './state-manager';
|
||||
export { createContextMenuManager, defaultContextMenuManager } from './state-manager';
|
||||
|
||||
// 事件解析
|
||||
export type { ParsedEventData } from './event-parser';
|
||||
export { isClickInsideMenu, parseEventData, parsePenData } from './event-parser';
|
||||
|
||||
// 库位菜单服务
|
||||
export type { StorageLocationInfo, StorageMenuConfig } from './storage-menu.service';
|
||||
export {
|
||||
checkIfActionPointWithStorage,
|
||||
findStorageLocationsByPointId,
|
||||
getStorageLocationsForPoint,
|
||||
getStorageMenuConfig
|
||||
} from './storage-menu.service';
|
||||
|
||||
// 机器人菜单服务
|
||||
export type { RobotInfo, RobotMenuConfig } from './robot-menu.service';
|
||||
export {
|
||||
executeRobotAction,
|
||||
getRobotInfo,
|
||||
getRobotMenuConfig,
|
||||
getRobotStatusColor,
|
||||
getRobotStatusText
|
||||
} from './robot-menu.service';
|
||||
|
||||
// 菜单配置服务
|
||||
export type { AreaMenuConfig, MenuConfig, PointMenuConfig } from './menu-config.service';
|
||||
export {
|
||||
getMenuConfig,
|
||||
handleContextMenu,
|
||||
handleContextMenuFromPenData
|
||||
} from './menu-config.service';
|
159
src/services/context-menu/menu-config.service.ts
Normal file
159
src/services/context-menu/menu-config.service.ts
Normal file
@ -0,0 +1,159 @@
|
||||
/**
|
||||
* 菜单配置服务
|
||||
* 统一管理不同业务类型的菜单配置
|
||||
*/
|
||||
|
||||
import type { ParsedEventData } from './event-parser';
|
||||
import { parseEventData, parsePenData } from './event-parser';
|
||||
import { getRobotMenuConfig, type RobotMenuConfig } from './robot-menu.service';
|
||||
import { getStorageMenuConfig, type StorageMenuConfig } from './storage-menu.service';
|
||||
|
||||
export interface PointMenuConfig {
|
||||
menuType: 'point' | 'default';
|
||||
pointInfo?: {
|
||||
id: string;
|
||||
name: string;
|
||||
type: string;
|
||||
hasStorage: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
export interface AreaMenuConfig {
|
||||
menuType: 'area' | 'default';
|
||||
areaInfo?: {
|
||||
id: string;
|
||||
name: string;
|
||||
type: string;
|
||||
};
|
||||
}
|
||||
|
||||
export type MenuConfig = StorageMenuConfig | RobotMenuConfig | PointMenuConfig | AreaMenuConfig;
|
||||
|
||||
/**
|
||||
* 获取菜单配置 - 统一入口
|
||||
* @param type 事件类型
|
||||
* @param data 事件数据
|
||||
* @param services 服务实例集合(可选)
|
||||
* @returns 菜单配置
|
||||
*/
|
||||
export function getMenuConfig(
|
||||
type: string,
|
||||
data: ParsedEventData,
|
||||
services?: {
|
||||
storageLocationService?: any;
|
||||
robotService?: any;
|
||||
}
|
||||
): MenuConfig {
|
||||
switch (type) {
|
||||
case 'storage-background':
|
||||
case 'storage':
|
||||
case 'point':
|
||||
// 库位相关类型,包括点区域(如果是动作点且有库位信息)
|
||||
return getStorageMenuConfig(type, data, services?.storageLocationService);
|
||||
|
||||
case 'robot':
|
||||
// 机器人类型
|
||||
return getRobotMenuConfig(data, services?.robotService);
|
||||
|
||||
case 'area':
|
||||
// 区域类型
|
||||
return getAreaMenuConfig(data);
|
||||
|
||||
default:
|
||||
return {
|
||||
menuType: 'default' as const,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取区域菜单配置
|
||||
* @param data 事件数据
|
||||
* @returns 区域菜单配置
|
||||
*/
|
||||
function getAreaMenuConfig(data: ParsedEventData): AreaMenuConfig {
|
||||
console.log(`处理区域类型,区域ID: ${data.id}`);
|
||||
|
||||
if (data.id && data.name) {
|
||||
return {
|
||||
menuType: 'area' as const,
|
||||
areaInfo: {
|
||||
id: data.id,
|
||||
name: data.name,
|
||||
type: '区域',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
menuType: 'default' as const,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理右键事件 - 组合函数
|
||||
* @param event 鼠标事件或指针事件
|
||||
* @param manager 状态管理器
|
||||
* @param services 服务实例集合(可选)
|
||||
*/
|
||||
export function handleContextMenu(
|
||||
event: MouseEvent | PointerEvent,
|
||||
manager: any,
|
||||
services?: {
|
||||
storageLocationService?: any;
|
||||
robotService?: any;
|
||||
}
|
||||
) {
|
||||
// 阻止默认右键菜单
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
// 1. 解析事件数据
|
||||
const parsedData = parseEventData(event);
|
||||
|
||||
// 2. 获取菜单配置
|
||||
const menuConfig = getMenuConfig(parsedData.type, parsedData, services);
|
||||
|
||||
// 3. 更新状态
|
||||
manager.setState({
|
||||
visible: true,
|
||||
x: parsedData.position.x,
|
||||
y: parsedData.position.y,
|
||||
eventData: parsedData,
|
||||
...menuConfig, // 展开具体配置
|
||||
});
|
||||
|
||||
console.log('右键菜单事件数据:', parsedData);
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理EditorService的penData - 组合函数
|
||||
* @param penData EditorService传递的pen数据
|
||||
* @param manager 状态管理器
|
||||
* @param services 服务实例集合(可选)
|
||||
*/
|
||||
export function handleContextMenuFromPenData(
|
||||
penData: Record<string, unknown>,
|
||||
manager: any,
|
||||
services?: {
|
||||
storageLocationService?: any;
|
||||
robotService?: any;
|
||||
}
|
||||
) {
|
||||
// 1. 解析penData
|
||||
const parsedData = parsePenData(penData);
|
||||
|
||||
// 2. 获取菜单配置
|
||||
const menuConfig = getMenuConfig(parsedData.type, parsedData, services);
|
||||
|
||||
// 3. 更新状态
|
||||
manager.setState({
|
||||
visible: true,
|
||||
x: parsedData.position.x,
|
||||
y: parsedData.position.y,
|
||||
eventData: parsedData,
|
||||
...menuConfig, // 展开具体配置
|
||||
});
|
||||
|
||||
console.log('右键菜单事件数据:', parsedData);
|
||||
}
|
165
src/services/context-menu/robot-menu.service.ts
Normal file
165
src/services/context-menu/robot-menu.service.ts
Normal file
@ -0,0 +1,165 @@
|
||||
/**
|
||||
* 机器人右键菜单服务
|
||||
* 处理机器人相关的菜单逻辑和操作
|
||||
*/
|
||||
|
||||
import type { ParsedEventData } from './event-parser';
|
||||
|
||||
export interface RobotInfo {
|
||||
id: string;
|
||||
name: string;
|
||||
status: 'online' | 'offline' | 'busy' | 'idle' | 'error';
|
||||
batteryLevel?: number;
|
||||
currentTask?: string;
|
||||
position?: { x: number; y: number };
|
||||
}
|
||||
|
||||
export interface RobotMenuConfig {
|
||||
menuType: 'robot' | 'default';
|
||||
robotInfo?: RobotInfo;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取机器人信息
|
||||
* @param data 事件数据
|
||||
* @param robotService 机器人服务实例(可选)
|
||||
* @returns 机器人信息
|
||||
*/
|
||||
export function getRobotInfo(data: ParsedEventData, robotService?: any): RobotInfo | undefined {
|
||||
const robotId = data.id;
|
||||
|
||||
if (!robotId) {
|
||||
console.warn('无法获取机器人ID');
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// 如果提供了机器人服务,尝试使用它获取真实数据
|
||||
if (robotService && typeof robotService.getRobotById === 'function') {
|
||||
try {
|
||||
const robot = robotService.getRobotById(robotId);
|
||||
if (robot) {
|
||||
console.log('从RobotService获取到机器人信息:', robot);
|
||||
return {
|
||||
id: robot.id || robotId,
|
||||
name: robot.name || robot.robotName || '未知机器人',
|
||||
status: robot.status || 'offline',
|
||||
batteryLevel: robot.batteryLevel || robot.battery,
|
||||
currentTask: robot.currentTask || robot.task,
|
||||
position: robot.position || { x: 0, y: 0 },
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取机器人信息失败:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// 回退到模拟数据
|
||||
console.log('使用模拟机器人数据');
|
||||
return {
|
||||
id: robotId,
|
||||
name: data.name || `机器人-${robotId}`,
|
||||
status: 'online',
|
||||
batteryLevel: 85,
|
||||
currentTask: '空闲',
|
||||
position: { x: 0, y: 0 },
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取机器人菜单配置 - 纯函数
|
||||
* @param data 事件数据
|
||||
* @param robotService 机器人服务实例(可选)
|
||||
* @returns 菜单配置
|
||||
*/
|
||||
export function getRobotMenuConfig(data: ParsedEventData, robotService?: any): RobotMenuConfig {
|
||||
console.log(`处理机器人类型,机器人ID: ${data.id}`);
|
||||
|
||||
const robotInfo = getRobotInfo(data, robotService);
|
||||
|
||||
if (robotInfo) {
|
||||
console.log('找到机器人信息:', robotInfo);
|
||||
return {
|
||||
menuType: 'robot' as const,
|
||||
robotInfo,
|
||||
};
|
||||
} else {
|
||||
console.log('未找到机器人信息,显示默认菜单');
|
||||
return {
|
||||
menuType: 'default' as const,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行机器人操作
|
||||
* @param action 操作类型
|
||||
* @param robotInfo 机器人信息
|
||||
* @param robotService 机器人服务实例(可选)
|
||||
* @returns 操作结果
|
||||
*/
|
||||
export async function executeRobotAction(
|
||||
action: string,
|
||||
robotInfo: RobotInfo,
|
||||
robotService?: any
|
||||
): Promise<{ success: boolean; message: string }> {
|
||||
try {
|
||||
console.log(`执行机器人操作: ${action}`, robotInfo);
|
||||
|
||||
// 如果提供了机器人服务,使用真实API
|
||||
if (robotService && typeof robotService.executeAction === 'function') {
|
||||
const result = await robotService.executeAction(action, robotInfo.id);
|
||||
return {
|
||||
success: result.success || true,
|
||||
message: result.message || `${action}操作成功`,
|
||||
};
|
||||
}
|
||||
|
||||
// 模拟操作
|
||||
await new Promise(resolve => setTimeout(resolve, 500)); // 模拟网络延迟
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: `机器人${robotInfo.name}的${action}操作成功`,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error(`机器人${action}操作失败:`, error);
|
||||
return {
|
||||
success: false,
|
||||
message: `机器人${action}操作失败: ${error}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取机器人状态显示文本
|
||||
* @param status 机器人状态
|
||||
* @returns 状态显示文本
|
||||
*/
|
||||
export function getRobotStatusText(status: string): string {
|
||||
const statusMap: Record<string, string> = {
|
||||
'online': '在线',
|
||||
'offline': '离线',
|
||||
'busy': '忙碌',
|
||||
'idle': '空闲',
|
||||
'error': '错误',
|
||||
};
|
||||
|
||||
return statusMap[status] || '未知';
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取机器人状态颜色
|
||||
* @param status 机器人状态
|
||||
* @returns 状态颜色
|
||||
*/
|
||||
export function getRobotStatusColor(status: string): string {
|
||||
const colorMap: Record<string, string> = {
|
||||
'online': '#52c41a',
|
||||
'offline': '#d9d9d9',
|
||||
'busy': '#1890ff',
|
||||
'idle': '#faad14',
|
||||
'error': '#ff4d4f',
|
||||
};
|
||||
|
||||
return colorMap[status] || '#d9d9d9';
|
||||
}
|
80
src/services/context-menu/state-manager.ts
Normal file
80
src/services/context-menu/state-manager.ts
Normal file
@ -0,0 +1,80 @@
|
||||
/**
|
||||
* 右键菜单状态管理器
|
||||
* 提供纯函数和状态管理工具,避免单例模式的问题
|
||||
*/
|
||||
|
||||
export interface ContextMenuState {
|
||||
visible: boolean;
|
||||
x: number;
|
||||
y: number;
|
||||
menuType: 'default' | 'storage' | 'storage-background' | 'robot' | 'point' | 'area';
|
||||
eventData?: any;
|
||||
}
|
||||
|
||||
/**
|
||||
* 右键菜单状态管理器
|
||||
* 使用组合式函数,避免单例模式
|
||||
*/
|
||||
export function createContextMenuManager() {
|
||||
let state: ContextMenuState = {
|
||||
visible: false,
|
||||
x: 0,
|
||||
y: 0,
|
||||
menuType: 'default',
|
||||
};
|
||||
|
||||
const listeners: Array<(state: ContextMenuState) => void> = [];
|
||||
|
||||
/**
|
||||
* 订阅状态变化
|
||||
*/
|
||||
function subscribe(listener: (state: ContextMenuState) => void) {
|
||||
listeners.push(listener);
|
||||
return () => {
|
||||
const index = listeners.indexOf(listener);
|
||||
if (index > -1) {
|
||||
listeners.splice(index, 1);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 通知状态变化
|
||||
*/
|
||||
function notify() {
|
||||
listeners.forEach(listener => listener({ ...state }));
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前状态
|
||||
*/
|
||||
function getState(): ContextMenuState {
|
||||
return { ...state };
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新状态
|
||||
*/
|
||||
function setState(newState: Partial<ContextMenuState>) {
|
||||
state = { ...state, ...newState };
|
||||
notify();
|
||||
}
|
||||
|
||||
/**
|
||||
* 关闭菜单
|
||||
*/
|
||||
function close() {
|
||||
setState({ visible: false });
|
||||
}
|
||||
|
||||
return {
|
||||
subscribe,
|
||||
getState,
|
||||
setState,
|
||||
close,
|
||||
};
|
||||
}
|
||||
|
||||
// 为了向后兼容,提供一个默认的状态管理器实例
|
||||
// 但建议在组件中创建独立的状态管理器
|
||||
export const defaultContextMenuManager = createContextMenuManager();
|
196
src/services/context-menu/storage-menu.service.ts
Normal file
196
src/services/context-menu/storage-menu.service.ts
Normal file
@ -0,0 +1,196 @@
|
||||
/**
|
||||
* 库位右键菜单服务
|
||||
* 处理库位相关的菜单逻辑和操作
|
||||
*/
|
||||
|
||||
import { MapPointType } from '@api/map';
|
||||
|
||||
import type { ParsedEventData } from './event-parser';
|
||||
|
||||
export interface StorageLocationInfo {
|
||||
id: string;
|
||||
name: string;
|
||||
isOccupied: boolean; // 是否占用
|
||||
isLocked: boolean; // 是否锁定
|
||||
status: 'available' | 'occupied' | 'locked' | 'unknown';
|
||||
}
|
||||
|
||||
export interface StorageMenuConfig {
|
||||
menuType: 'storage-background' | 'default';
|
||||
storageLocations: StorageLocationInfo[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否为动作点且有库位信息
|
||||
* @param data 事件数据
|
||||
* @returns 是否为动作点且有库位信息
|
||||
*/
|
||||
export function checkIfActionPointWithStorage(data: ParsedEventData): boolean {
|
||||
// 从pen数据中获取点信息
|
||||
const pen = data.pen;
|
||||
if (!pen) {
|
||||
console.log('无pen数据,不是动作点');
|
||||
return false;
|
||||
}
|
||||
|
||||
const pointInfo = pen.point as any;
|
||||
if (!pointInfo) {
|
||||
console.log('无point信息,不是动作点');
|
||||
return false;
|
||||
}
|
||||
|
||||
// 检查是否为动作点
|
||||
const isActionPoint = pointInfo.type === MapPointType.动作点;
|
||||
if (!isActionPoint) {
|
||||
console.log(`点类型为 ${pointInfo.type},不是动作点`);
|
||||
return false;
|
||||
}
|
||||
|
||||
// 检查是否有库位信息
|
||||
const hasStorageLocations = pointInfo.associatedStorageLocations &&
|
||||
Array.isArray(pointInfo.associatedStorageLocations) &&
|
||||
pointInfo.associatedStorageLocations.length > 0;
|
||||
|
||||
console.log(`动作点检查结果: 是动作点=${isActionPoint}, 有库位信息=${hasStorageLocations}`);
|
||||
|
||||
return isActionPoint && hasStorageLocations;
|
||||
}
|
||||
|
||||
/**
|
||||
* 查找指定点ID的所有库位信息
|
||||
* @param pointId 点ID
|
||||
* @param storageLocationService StorageLocationService实例(可选)
|
||||
* @returns 库位信息列表
|
||||
*/
|
||||
export function findStorageLocationsByPointId(pointId: string, storageLocationService?: any): StorageLocationInfo[] {
|
||||
console.log(`查找点 ${pointId} 关联的所有库位`);
|
||||
|
||||
// 如果提供了StorageLocationService,尝试使用它获取真实数据
|
||||
if (storageLocationService && typeof storageLocationService.getLocationsByPointId === 'function') {
|
||||
const locations = storageLocationService.getLocationsByPointId(pointId);
|
||||
if (locations && locations.length > 0) {
|
||||
console.log(`从StorageLocationService获取到 ${locations.length} 个库位:`, locations);
|
||||
// 转换StorageLocationService的数据格式到我们的格式
|
||||
const convertedLocations = locations.map((location: any) => {
|
||||
const converted = {
|
||||
id: location.id || `storage-${pointId}-${location.layer_index || 0}`,
|
||||
name: location.layer_name || location.locationName || location.name || '未知库位',
|
||||
isOccupied: location.is_occupied || location.occupied || false,
|
||||
isLocked: location.is_locked || location.locked || false,
|
||||
status: (location.is_locked || location.locked)
|
||||
? 'locked'
|
||||
: (location.is_occupied || location.occupied)
|
||||
? 'occupied'
|
||||
: 'available',
|
||||
};
|
||||
console.log(`转换库位数据: ${location.layer_name} -> ${converted.name}, 占用: ${converted.isOccupied}, 锁定: ${converted.isLocked}, 状态: ${converted.status}`);
|
||||
return converted;
|
||||
});
|
||||
return convertedLocations;
|
||||
}
|
||||
}
|
||||
|
||||
// 回退到模拟数据
|
||||
console.log('使用模拟数据');
|
||||
const mockStorageLocations: StorageLocationInfo[] = [
|
||||
{
|
||||
id: `storage-${pointId}-0`,
|
||||
name: 'GSA-1-1-1', // 模拟库位名称
|
||||
isOccupied: false,
|
||||
isLocked: false,
|
||||
status: 'available',
|
||||
},
|
||||
{
|
||||
id: `storage-${pointId}-1`,
|
||||
name: 'GSA-1-1-2',
|
||||
isOccupied: true,
|
||||
isLocked: false,
|
||||
status: 'occupied',
|
||||
},
|
||||
{
|
||||
id: `storage-${pointId}-2`,
|
||||
name: 'GSA-1-1-3',
|
||||
isOccupied: false,
|
||||
isLocked: true,
|
||||
status: 'locked',
|
||||
},
|
||||
];
|
||||
|
||||
return mockStorageLocations;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取点关联的库位信息列表
|
||||
* @param data 事件数据
|
||||
* @param storageLocationService StorageLocationService实例(可选)
|
||||
* @returns 库位信息列表
|
||||
*/
|
||||
export function getStorageLocationsForPoint(data: ParsedEventData, storageLocationService?: any): StorageLocationInfo[] {
|
||||
const pointId = data.pointId || data.id;
|
||||
|
||||
if (!pointId) {
|
||||
console.warn('无法获取点ID,返回空库位列表');
|
||||
return [];
|
||||
}
|
||||
|
||||
return findStorageLocationsByPointId(pointId, storageLocationService);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取库位菜单配置 - 纯函数
|
||||
* @param type 事件类型
|
||||
* @param data 事件数据
|
||||
* @param storageLocationService StorageLocationService实例(可选)
|
||||
* @returns 菜单配置
|
||||
*/
|
||||
export function getStorageMenuConfig(type: string, data: ParsedEventData, storageLocationService?: any): StorageMenuConfig {
|
||||
switch (type) {
|
||||
case 'storage-background': {
|
||||
// 库位背景区域或单个库位区域:显示该点关联的所有库位信息
|
||||
console.log(`处理库位相关类型,点ID: ${data.pointId}`);
|
||||
const allStorageLocations = getStorageLocationsForPoint(data, storageLocationService);
|
||||
console.log(`找到 ${allStorageLocations.length} 个库位:`, allStorageLocations);
|
||||
return {
|
||||
menuType: 'storage-background' as const,
|
||||
storageLocations: allStorageLocations,
|
||||
};
|
||||
}
|
||||
case 'storage': {
|
||||
// 单个库位区域 - 也使用storage-background类型统一处理
|
||||
console.log(`处理单个库位类型,点ID: ${data.pointId}`);
|
||||
const singleStorageLocations = getStorageLocationsForPoint(data, storageLocationService);
|
||||
console.log(`找到 ${singleStorageLocations.length} 个库位:`, singleStorageLocations);
|
||||
return {
|
||||
menuType: 'storage-background' as const,
|
||||
storageLocations: singleStorageLocations,
|
||||
};
|
||||
}
|
||||
case 'point': {
|
||||
// 点区域:只有动作点且有库位信息时才显示库位菜单
|
||||
console.log(`处理点区域类型,点ID: ${data.pointId}`);
|
||||
|
||||
// 检查是否为动作点且有库位信息
|
||||
const isActionPointWithStorage = checkIfActionPointWithStorage(data);
|
||||
|
||||
if (isActionPointWithStorage) {
|
||||
const pointStorageLocations = getStorageLocationsForPoint(data, storageLocationService);
|
||||
console.log(`动作点找到 ${pointStorageLocations.length} 个库位:`, pointStorageLocations);
|
||||
return {
|
||||
menuType: 'storage-background' as const,
|
||||
storageLocations: pointStorageLocations,
|
||||
};
|
||||
} else {
|
||||
console.log('非动作点或无库位信息,显示默认菜单');
|
||||
return {
|
||||
menuType: 'default' as const,
|
||||
storageLocations: [],
|
||||
};
|
||||
}
|
||||
}
|
||||
default:
|
||||
return {
|
||||
menuType: 'default' as const,
|
||||
storageLocations: [],
|
||||
};
|
||||
}
|
||||
}
|
6
src/vite-env.d.ts
vendored
6
src/vite-env.d.ts
vendored
@ -1 +1,7 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
declare module '*.vue' {
|
||||
import type { DefineComponent } from 'vue'
|
||||
const component: DefineComponent<{}, {}, any>
|
||||
export default component
|
||||
}
|
||||
|
Loading…
x
Reference in New Issue
Block a user