feat: 添加右键菜单管理功能,支持自定义事件处理和全局点击/键盘事件监听,添加库位右键菜单渲染

This commit is contained in:
xudan 2025-09-04 16:44:19 +08:00
parent 14278291c6
commit e39da1cde5
3 changed files with 1000 additions and 2 deletions

View File

@ -0,0 +1,263 @@
<template>
<div v-if="visible" 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)"
:title="getStorageTooltip(location)"
>
<div class="storage-info">
<div class="storage-name">{{ location.name }}</div>
<div class="storage-status">
<span class="status-indicator" :class="location.status"></span>
<span class="status-text">{{ getStatusText(location) }}</span>
</div>
</div>
</div>
</template>
<!-- 默认菜单仅刷新 -->
<template v-else>
<div class="context-menu-item" @click="handleRefresh">
<span>刷新</span>
</div>
</template>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue';
interface StorageLocationInfo {
id: string;
name: string;
isOccupied: boolean;
isLocked: boolean;
status: 'available' | 'occupied' | 'locked' | 'unknown';
}
interface Props {
visible: boolean;
x?: number;
y?: number;
menuType?: 'default' | 'storage' | 'storage-background';
storageLocations?: StorageLocationInfo[];
}
interface Emits {
(e: 'close'): void;
}
const props = withDefaults(defineProps<Props>(), {
x: 0,
y: 0,
menuType: 'default',
storageLocations: () => [],
});
const emit = defineEmits<Emits>();
//
defineOptions({
name: 'ContextMenu',
});
//
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 '库位信息';
return '右键菜单';
});
//
const handleRefresh = () => {
console.log('刷新操作');
emit('close');
};
//
const handleSelectStorage = (location: StorageLocationInfo) => {
console.log('选择库位:', location);
emit('close');
};
//
const getStorageItemClass = (location: StorageLocationInfo) => {
return {
'storage-occupied': location.isOccupied,
'storage-locked': location.isLocked,
'storage-available': !location.isOccupied && !location.isLocked,
};
};
//
const getStorageTooltip = (location: StorageLocationInfo) => {
const status = getStatusText(location);
return `${location.name} - ${status}`;
};
//
const getStatusText = (location: StorageLocationInfo) => {
switch (location.status) {
case 'occupied':
return '已占用';
case 'locked':
return '已锁定';
case 'available':
return '可用';
default:
return '未知';
}
};
</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;
align-items: center;
gap: 6px;
font-size: 12px;
}
.status-indicator {
width: 8px;
height: 8px;
border-radius: 50%;
display: inline-block;
}
.status-indicator.available {
background-color: #52c41a;
}
.status-indicator.occupied {
background-color: #ff4d4f;
}
.status-indicator.locked {
background-color: #faad14;
}
.status-indicator.unknown {
background-color: #d9d9d9;
}
.status-text {
color: #666;
}
/* 库位状态样式 */
.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;
}
</style>

View File

@ -9,8 +9,12 @@ import { isNil } from 'lodash-es';
import { computed, onMounted, onUnmounted, provide, ref, shallowRef, watch } from 'vue';
import { useRoute } from 'vue-router';
import MapToolbar from '../components/map-toolbar.vue';
import { autoDoorSimulationService, type AutoDoorWebSocketData } from '../services/auto-door-simulation.service';
import {
type ContextMenuState,
createContextMenuManager,
handleContextMenuFromPenData,
isClickInsideMenu} from '../services/context-menu.service';
const EDITOR_KEY = Symbol('editor-key');
@ -237,6 +241,22 @@ onMounted(async () => {
// enableLogging: true,
// });
}
//
contextMenuManager.subscribe((state) => {
contextMenuState.value = state;
});
// EditorService
if (editor.value) {
(editor.value as any).on('customContextMenu', (event: Record<string, unknown>) => {
handleEditorContextMenu(event);
});
}
//
document.addEventListener('click', handleGlobalClick);
document.addEventListener('keydown', handleGlobalKeydown);
});
onUnmounted(() => {
@ -246,6 +266,17 @@ onUnmounted(() => {
autoDoorSimulationService.clearBufferedData();
// WebSocket
// autoDoorSimulationService.stopAllSimulations();
// EditorService
if (editor.value) {
(editor.value as any).off('customContextMenu', (event: Record<string, unknown>) => {
handleEditorContextMenu(event);
});
}
//
document.removeEventListener('click', handleGlobalClick);
document.removeEventListener('keydown', handleGlobalKeydown);
});
//#endregion
@ -313,12 +344,69 @@ const handleAutoSaveAndRestoreViewState = async () => {
//#region UI
const show = ref<boolean>(true);
// - 使
const contextMenuManager = createContextMenuManager();
const contextMenuState = ref<ContextMenuState>(contextMenuManager.getState());
//#endregion
// iframe
const backToCards = () => {
window.parent?.postMessage({ type: 'scene_return_to_cards' }, '*');
};
//#region
/**
* 处理EditorService的自定义右键菜单事件
* @param penData EditorService传递的pen数据
*/
const handleEditorContextMenu = (penData: Record<string, unknown>) => {
console.log('EditorService自定义右键菜单事件:', penData);
handleContextMenuFromPenData(penData, contextMenuManager, storageLocationService.value);
};
/**
* 处理原生右键菜单事件作为备用
* @param event 鼠标事件
*/
const handleContextMenuEvent = (event: MouseEvent) => {
// handleEditorContextMenu
console.log('原生右键菜单事件(备用):', event);
};
/**
* 关闭右键菜单
*/
const handleCloseContextMenu = () => {
contextMenuManager.close();
};
/**
* 处理全局点击事件用于关闭右键菜单
* @param event 点击事件
*/
const handleGlobalClick = (event: MouseEvent) => {
//
if (contextMenuState.value.visible) {
//
if (!isClickInsideMenu(event, contextMenuState.value.visible)) {
contextMenuManager.close();
}
}
};
/**
* 处理全局键盘事件ESC键关闭右键菜单
* @param event 键盘事件
*/
const handleGlobalKeydown = (event: KeyboardEvent) => {
// ESC
if (event.key === 'Escape' && contextMenuState.value.visible) {
contextMenuManager.close();
}
};
//#endregion
</script>
<template>
@ -348,7 +436,7 @@ const backToCards = () => {
</a-tabs>
</a-layout-sider>
<a-layout-content>
<div ref="container" class="editor-container full"></div>
<div ref="container" class="editor-container full" @contextmenu="handleContextMenuEvent"></div>
<!-- 自定义地图工具栏固定右下角最小侵入 -->
<MapToolbar :token="EDITOR_KEY" :container-el="container" />
</a-layout-content>
@ -371,11 +459,23 @@ const backToCards = () => {
<AreaDetailCard v-if="isArea" :token="EDITOR_KEY" :current="current.id" />
</div>
</template>
<!-- 右键菜单 -->
<ContextMenu
:visible="contextMenuState.visible"
:x="contextMenuState.x"
:y="contextMenuState.y"
:menu-type="contextMenuState.menuType"
:storage-locations="contextMenuState.storageLocations"
@close="handleCloseContextMenu"
/>
</template>
<style scoped lang="scss">
.editor-container {
background-color: transparent !important;
position: relative;
z-index: 1;
}
.card-container {

View File

@ -0,0 +1,635 @@
/**
* -
*
*/
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 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')) {
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: document.elementFromPoint(position.x, position.y) as HTMLElement,
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: 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(`处理storage-background类型点ID: ${data.pointId}`);
const allStorageLocations = getStorageLocationsForPoint(data, storageLocationService);
console.log(`找到 ${allStorageLocations.length} 个库位:`, allStorageLocations);
return {
menuType: 'storage-background' as const,
storageLocations: allStorageLocations,
};
}
case 'storage':
// 单个库位区域
return {
menuType: 'storage' as const,
storageLocations: [getStorageLocationInfo(data)],
};
case 'point':
// 点区域:显示该点关联的库位信息
return {
menuType: 'storage' as const,
storageLocations: getStorageLocationsForPoint(data, storageLocationService),
};
default:
return {
menuType: 'default' as const,
storageLocations: [],
};
}
}
/**
*
* @param data
* @returns
*/
function getStorageLocationInfo(data: ParsedEventData): StorageLocationInfo {
const pen = data.pen as Record<string, unknown>;
// 优先使用storageLocation中的数据
if (pen?.storageLocation) {
const storageLocation = pen.storageLocation as {
locationName: string;
occupied: boolean;
locked: boolean;
};
const { locationName, occupied, locked } = storageLocation;
let status: StorageLocationInfo['status'] = 'unknown';
if (locked) {
status = 'locked';
} else if (occupied) {
status = 'occupied';
} else {
status = 'available';
}
return {
id: data.id || data.storageId || 'unknown',
name: locationName || data.name || '未知库位',
isOccupied: occupied,
isLocked: locked,
status,
};
}
// 回退到pen的其他属性
const isLocked = pen?.locked === 1;
const isOccupied = pen?.occupied === 1 || pen?.status === 'occupied';
let status: StorageLocationInfo['status'] = 'unknown';
if (isLocked) {
status = 'locked';
} else if (isOccupied) {
status = 'occupied';
} else {
status = 'available';
}
return {
id: data.id || data.storageId || 'unknown',
name: data.name || '未知库位',
isOccupied,
isLocked,
status,
};
}
/**
* 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();