feat(context-menu): 新增站点右键菜单功能,支持通过机器人选择弹窗为站点下发导航任务

This commit is contained in:
xudan 2025-12-08 11:06:22 +08:00
parent 18d4205b74
commit a6c642fef4
9 changed files with 747 additions and 44 deletions

View File

@ -8,6 +8,7 @@ const enum API {
= '/amr/manuallSetCharging', = '/amr/manuallSetCharging',
AMR = '/amr', AMR = '/amr',
AMR = '/amr', AMR = '/amr',
= '/task/manual/move',
} }
// 只保留右键菜单需要的API函数 // 只保留右键菜单需要的API函数
@ -127,3 +128,58 @@ export async function resumeAmr(amrId: string, sceneId: string): Promise<boolean
return false; return false;
} }
} }
/**
*
* @param params
* @returns
*/
export async function manualMoveTask(params: {
sceneId: string;
amrId: string;
taskName: string;
targetStationName: string;
description?: string;
operator?: string;
priority: number;
}): Promise<{ success: boolean; message: string; data?: any }> {
type B = {
sceneId: string;
amrId: string;
taskName: string;
targetStationName: string;
description?: string;
operator?: string;
priority: number;
};
type D = {
success: boolean;
message: string;
code: number;
result?: {
taskId: string;
taskBlockId: string;
vwedTaskId: string;
amrId: string;
amrName: string;
currentStationName: string;
targetStationName: string;
};
timestamp: number;
};
try {
const response = await http.post<D, B>(API., params);
return {
success: (response as any).success,
message: (response as any).message,
data: (response as any).result,
};
} catch (error) {
console.error('手动下发移动任务失败:', error);
return {
success: false,
message: '', // 让全局错误处理显示具体错误
};
}
}

View File

@ -26,6 +26,14 @@
@custom-image="handleCustomImage" @custom-image="handleCustomImage"
/> />
<!-- 站点菜单 -->
<PointMenu
v-else-if="menuType === 'point'"
:menu-type="menuType"
:point-info="pointInfo"
@action-complete="handleActionComplete"
/>
<!-- 默认菜单 --> <!-- 默认菜单 -->
<DefaultMenu v-else @action-complete="handleActionComplete" /> <DefaultMenu v-else @action-complete="handleActionComplete" />
</div> </div>
@ -38,6 +46,7 @@ import { computed, ref, watch } from 'vue';
import type { StorageLocationInfo } from '../../services/context-menu'; import type { StorageLocationInfo } from '../../services/context-menu';
import DefaultMenu from './default-menu.vue'; import DefaultMenu from './default-menu.vue';
import PointMenu from './point-menu.vue';
import RobotMenu from './robot-menu.vue'; import RobotMenu from './robot-menu.vue';
import StorageMenu from './storage-menu.vue'; import StorageMenu from './storage-menu.vue';
@ -48,6 +57,11 @@ interface Props {
menuType?: 'default' | 'storage' | 'storage-background' | 'robot' | 'point' | 'area'; menuType?: 'default' | 'storage' | 'storage-background' | 'robot' | 'point' | 'area';
storageLocations?: StorageLocationInfo[]; storageLocations?: StorageLocationInfo[];
robotId?: string; // ID robotId?: string; // ID
pointInfo?: { //
id: string;
name: string;
type: string;
};
apiBaseUrl?: string; apiBaseUrl?: string;
isPlaybackMode?: boolean; isPlaybackMode?: boolean;
} }
@ -67,6 +81,8 @@ const props = withDefaults(defineProps<Props>(), {
}); });
const emit = defineEmits<Emits>(); const emit = defineEmits<Emits>();
// //
const menuRef = ref<HTMLElement | null>(null); const menuRef = ref<HTMLElement | null>(null);
const handleRef = ref<HTMLElement | null>(null); const handleRef = ref<HTMLElement | null>(null);
@ -125,27 +141,8 @@ defineOptions({
name: 'ContextMenu', name: 'ContextMenu',
}); });
//
const triggerRef = ref<HTMLElement | null>(null);
const mainMenuWidth = ref(200); // const mainMenuWidth = ref(200); //
// -
const triggerStyle = computed(() => ({
position: 'fixed' as const,
left: `${props.x}px`,
top: `${props.y}px`,
width: '1px',
height: '1px',
pointerEvents: 'none' as const,
zIndex: 9999,
}));
// 使 Ant Design
const dropdownPlacement = 'bottomLeft' as const;
//
const getPopupContainer = () => document.body;
// //
const headerTitle = computed(() => { const headerTitle = computed(() => {
if (props.menuType === 'storage-background') return '库位状态'; if (props.menuType === 'storage-background') return '库位状态';
@ -156,12 +153,6 @@ const headerTitle = computed(() => {
return '右键菜单'; return '右键菜单';
}); });
//
const handleOpenChange = (open: boolean) => {
if (!open) {
emit('close');
}
};
// //
const handleActionComplete = (data: { success: boolean; action: string; message?: string }) => { const handleActionComplete = (data: { success: boolean; action: string; message?: string }) => {

View File

@ -0,0 +1,256 @@
<template>
<div class="point-menu">
<!-- 菜单头部显示站点信息 -->
<div class="menu-header">
<div class="point-info">
<div class="point-icon">📍</div>
<div class="point-details">
<div class="point-name">{{ pointInfo?.name || '未知站点' }}</div>
<div class="point-id">ID: {{ pointInfo?.id }}</div>
</div>
</div>
</div>
<!-- 菜单分割线 -->
<a-divider style="margin: 12px 0;" />
<!-- 菜单选项 -->
<div class="menu-options">
<!-- 导航至此站点 -->
<div
class="menu-item"
:class="{ disabled: !canNavigate }"
@click="handleNavigateToPoint"
>
<div class="menu-item-content">
<span class="menu-icon">🧭</span>
<span class="menu-text">导航至此站点</span>
</div>
<div class="menu-description">
选择机器人并下发移动任务到当前站点
</div>
</div>
</div>
<!-- 机器人选择弹窗 -->
<RobotSelectorModal
v-model:open="showRobotSelector"
:target-point-name="pointInfo?.name"
@confirm="handleRobotSelected"
@cancel="handleRobotSelectorCancel"
/>
</div>
</template>
<script setup lang="ts">
import { message } from 'ant-design-vue';
import { computed, ref } from 'vue';
import type { RobotInfo } from '../../apis/robot';
import { executeNavigateToPoint } from '../../services/context-menu/point-menu.service';
import RobotSelectorModal from '../modal/robot-selector-modal.vue';
interface Props {
menuType?: 'point' | 'default';
pointInfo?: {
id: string;
name: string;
type: string;
};
}
interface Emits {
(e: 'actionComplete', data: {
action: string;
point: any;
success: boolean;
message?: string;
}): void;
}
const props = defineProps<Props>();
const emit = defineEmits<Emits>();
//
const showRobotSelector = ref(false);
const loading = ref(false);
//
const pointInfo = computed(() => props.pointInfo);
//
const canNavigate = computed(() => {
return pointInfo.value?.name && pointInfo.value?.id;
});
//
const handleNavigateToPoint = async () => {
if (!canNavigate.value) {
message.warning('站点信息不完整,无法导航');
return;
}
//
showRobotSelector.value = true;
};
//
const handleRobotSelected = async (data: { robot: RobotInfo; targetPointName: string }) => {
if (!pointInfo.value) {
message.error('站点信息丢失,无法执行导航');
return;
}
loading.value = true;
try {
console.log('选择机器人进行导航:', {
robot: data.robot.label,
targetPoint: data.targetPointName,
});
// API
const result = await executeNavigateToPoint(
pointInfo.value.id,
pointInfo.value.name,
data.robot.id,
data.robot.label
);
//
emit('actionComplete', {
action: 'navigateToPoint',
point: pointInfo.value,
success: result.success,
message: result.message,
});
//
if (result.success) {
message.success(`已为机器人 ${data.robot.label} 下发导航任务到 ${data.targetPointName}`);
} else {
// API
if (!result.message) {
message.error('导航任务下发失败');
}
}
} catch (error) {
console.error('导航操作失败:', error);
//
emit('actionComplete', {
action: 'navigateToPoint',
point: pointInfo.value,
success: false,
message: undefined, //
});
message.error('导航操作失败');
} finally {
loading.value = false;
}
};
//
const handleRobotSelectorCancel = () => {
showRobotSelector.value = false;
console.log('用户取消了机器人选择');
};
</script>
<style scoped>
.point-menu {
width: 100%;
min-width: 320px;
max-width: 400px;
padding: 8px;
}
/* 菜单头部 */
.menu-header {
padding: 8px 12px;
}
.point-info {
display: flex;
align-items: center;
gap: 12px;
}
.point-icon {
font-size: 20px;
display: flex;
align-items: center;
justify-content: center;
}
.point-details {
flex: 1;
}
.point-name {
font-size: 14px;
font-weight: 500;
color: #262626;
margin-bottom: 4px;
}
.point-id {
font-size: 12px;
color: #8c8c8c;
}
/* 菜单选项 */
.menu-options {
padding: 0 4px;
}
.menu-item {
padding: 12px;
margin: 4px 0;
border-radius: 6px;
cursor: pointer;
transition: all 0.3s;
}
.menu-item:hover:not(.disabled) {
background-color: #f5f5f5;
}
.menu-item.disabled {
opacity: 0.6;
cursor: not-allowed;
}
.menu-item-content {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 4px;
}
.menu-icon {
font-size: 16px;
width: 20px;
text-align: center;
}
.menu-text {
font-size: 14px;
font-weight: 500;
color: #262626;
}
.menu-description {
font-size: 12px;
color: #8c8c8c;
margin-left: 32px;
}
/* 分割线样式 */
:deep(.ant-divider) {
border-color: #f0f0f0;
}
</style>

View File

@ -0,0 +1,280 @@
<template>
<a-modal
v-model:open="visible"
title="选择机器人"
:width="600"
:footer="null"
@cancel="handleCancel"
>
<div class="robot-selector">
<!-- 机器人列表 -->
<div class="robot-list">
<a-radio-group v-model:value="selectedRobotId" class="radio-group">
<div
v-for="robot in robotList"
:key="robot.id"
class="robot-item"
:class="{ disabled: !isRobotSelectable(robot) }"
>
<a-radio :value="robot.id" :disabled="!isRobotSelectable(robot)">
<div class="robot-info">
<div class="robot-name">{{ robot.label }}</div>
<div class="robot-details">
<a-tag :color="getRobotStatusColor(robot.state)">
{{ getRobotStatusText(robot.state) }}
</a-tag>
<span class="robot-id">ID: {{ robot.id }}</span>
<span v-if="robot.targetPoint" class="current-station">
目标位置: {{ robot.targetPoint }}
</span>
</div>
</div>
</a-radio>
</div>
</a-radio-group>
</div>
<!-- 操作按钮 -->
<div class="action-buttons">
<a-button @click="handleCancel">取消</a-button>
<a-button
type="primary"
:disabled="!selectedRobotId"
:loading="loading"
@click="handleConfirm"
>
确定
</a-button>
</div>
</div>
</a-modal>
</template>
<script setup lang="ts">
import { message } from 'ant-design-vue';
import { computed, onMounted, ref, watch } from 'vue';
import type { RobotInfo } from '../../apis/robot';
import { RobotState } from '../../apis/robot';
import { getAllRobots } from '../../apis/robot';
interface Props {
open?: boolean;
targetPointName?: string;
}
interface Emits {
(e: 'update:open', value: boolean): void;
(e: 'confirm', data: { robot: RobotInfo; targetPointName: string }): void;
(e: 'cancel'): void;
}
const props = withDefaults(defineProps<Props>(), {
open: false,
targetPointName: '',
});
const emit = defineEmits<Emits>();
//
const visible = ref(false);
const robotList = ref<RobotInfo[]>([]);
const selectedRobotId = ref<string>('');
const loading = ref(false);
//
const selectedRobot = computed(() =>
robotList.value.find(robot => robot.id === selectedRobotId.value)
);
// props.open
watch(() => props.open, (newVal) => {
visible.value = newVal;
if (newVal) {
loadRobotList();
selectedRobotId.value = '';
}
});
// visible
watch(visible, (newVal) => {
emit('update:open', newVal);
});
//
const loadRobotList = async () => {
try {
const robots = await getAllRobots();
robotList.value = robots || [];
} catch (error) {
console.error('加载机器人列表失败:', error);
robotList.value = [];
message.error('加载机器人列表失败');
}
};
//
const isRobotSelectable = (robot: RobotInfo): boolean => {
//
// 1. 线 (isConnected === true)
// 2. (canOrder === true)
// 3. (state === RobotState.)
// 4.
return (
robot.isConnected === true &&
robot.canOrder === true &&
robot.state === RobotState.空闲中 &&
!robot.targetPoint //
);
};
//
const getRobotStatusText = (state?: RobotState): string => {
const stateMap: Record<RobotState, string> = {
[RobotState.未知]: '未知',
[RobotState.任务执行中]: '任务执行中',
[RobotState.充电中]: '充电中',
[RobotState.停靠中]: '停靠中',
[RobotState.空闲中]: '空闲中',
};
return stateMap[state || RobotState.空闲中] || '未知';
};
//
const getRobotStatusColor = (state?: RobotState): string => {
const colorMap: Record<RobotState, string> = {
[RobotState.未知]: '#d9d9d9', // -
[RobotState.任务执行中]: '#1890ff', // -
[RobotState.充电中]: '#faad14', // -
[RobotState.停靠中]: '#52c41a', // 绿 -
[RobotState.空闲中]: '#52c41a', // 绿 -
};
return colorMap[state || RobotState.空闲中] || '#d9d9d9';
};
//
const handleConfirm = async () => {
if (!selectedRobot.value) {
message.warning('请选择一个机器人');
return;
}
if (!props.targetPointName) {
message.error('目标站点名称不能为空');
return;
}
loading.value = true;
try {
//
emit('confirm', {
robot: selectedRobot.value,
targetPointName: props.targetPointName,
});
//
visible.value = false;
} catch (error) {
console.error('确认操作失败:', error);
message.error('确认操作失败');
} finally {
loading.value = false;
}
};
//
const handleCancel = () => {
visible.value = false;
emit('cancel');
};
//
onMounted(() => {
if (props.open) {
loadRobotList();
}
});
</script>
<style scoped>
.robot-selector {
padding: 16px 0;
}
.robot-list {
max-height: 400px;
overflow-y: auto;
}
.radio-group {
width: 100%;
}
.robot-item {
display: block;
width: 100%;
padding: 12px;
margin-bottom: 8px;
border: 1px solid #d9d9d9;
border-radius: 6px;
transition: all 0.3s;
}
.robot-item:hover:not(.disabled) {
border-color: #1890ff;
background-color: #f5f5f5;
}
.robot-item.disabled {
opacity: 0.6;
cursor: not-allowed;
}
.robot-info {
width: 100%;
}
.robot-name {
font-size: 16px;
font-weight: 500;
margin-bottom: 8px;
color: #262626;
}
.robot-details {
display: flex;
align-items: center;
gap: 8px;
font-size: 12px;
color: #8c8c8c;
}
.robot-id {
color: #595959;
}
.current-station {
color: #595959;
}
.action-buttons {
display: flex;
justify-content: flex-end;
gap: 8px;
margin-top: 24px;
padding-top: 16px;
border-top: 1px solid #f0f0f0;
}
/* 自定义单选框样式 */
:deep(.ant-radio-wrapper) {
width: 100%;
margin-right: 0;
}
:deep(.ant-radio-wrapper span:last-child) {
width: 100%;
}
</style>

View File

@ -855,6 +855,7 @@ const handleGlobalKeydown = (event: KeyboardEvent) => {
:menu-type="contextMenuState.menuType" :menu-type="contextMenuState.menuType"
:storage-locations="contextMenuState.storageLocations" :storage-locations="contextMenuState.storageLocations"
:robot-id="contextMenuState.robotInfo?.id" :robot-id="contextMenuState.robotInfo?.id"
:point-info="contextMenuState.pointInfo"
:token="EDITOR_KEY" :token="EDITOR_KEY"
:is-playback-mode="isPlaybackMode" :is-playback-mode="isPlaybackMode"
@close="handleCloseContextMenu" @close="handleCloseContextMenu"

View File

@ -64,9 +64,7 @@ function parsePenObject(pen: Record<string, unknown>, position: { x: number; y:
locked: boolean; locked: boolean;
}; };
}; };
console.log('解析后的数据:', { id, name, tags, storageLocation });
const target = document.elementFromPoint(position.x, position.y) as HTMLElement; const target = document.elementFromPoint(position.x, position.y) as HTMLElement;
// 根据tags判断类型 - 统一处理库位相关区域 // 根据tags判断类型 - 统一处理库位相关区域

View File

@ -5,19 +5,10 @@
import type { ParsedEventData } from './event-parser'; import type { ParsedEventData } from './event-parser';
import { parseEventData, parsePenData } from './event-parser'; import { parseEventData, parsePenData } from './event-parser';
import { getPointMenuConfig, isStationPoint, type PointMenuConfig } from './point-menu.service';
import type { RobotMenuConfig } from './robot-menu.service'; import type { RobotMenuConfig } from './robot-menu.service';
import { getStorageMenuConfig, type StorageMenuConfig } from './storage-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 { export interface AreaMenuConfig {
menuType: 'area' | 'default'; menuType: 'area' | 'default';
areaInfo?: { areaInfo?: {
@ -27,7 +18,7 @@ export interface AreaMenuConfig {
}; };
} }
export type MenuConfig = StorageMenuConfig | RobotMenuConfig | PointMenuConfig | AreaMenuConfig; export type MenuConfig = StorageMenuConfig | RobotMenuConfig | AreaMenuConfig | PointMenuConfig;
/** /**
* - * -
@ -47,10 +38,27 @@ export function getMenuConfig(
switch (type) { switch (type) {
case 'storage-background': case 'storage-background':
case 'storage': case 'storage':
case 'point': // 库位相关类型
// 库位相关类型,包括点区域(如果是动作点且有库位信息)
return getStorageMenuConfig(type, data, services?.storageLocationService); return getStorageMenuConfig(type, data, services?.storageLocationService);
case 'point':
// 检查是否为站点(动作点)而不是库位点
if (isStationPoint(data.tags || [], data.pen)) {
// 是站点,显示站点菜单
// 尝试从 pen 对象中获取真实的站点名称
const pen = data.pen as any;
const stationName = pen?.label || pen?.text || pen?.title || data.name || `站点-${data.id}`;
return getPointMenuConfig({
id: data.id || '',
name: stationName,
type: data.type || 'point',
});
} else {
// 是库位点,显示库位菜单
return getStorageMenuConfig(type, data, services?.storageLocationService);
}
case 'robot': { case 'robot': {
// 机器人类型 - 直接获取机器人信息 // 机器人类型 - 直接获取机器人信息
const robotInfo = services?.robotService?.getRobotById?.(data.id); const robotInfo = services?.robotService?.getRobotById?.(data.id);
@ -104,16 +112,16 @@ function processContextMenu(
} }
// 更新状态 // 更新状态
manager.setState({ const stateToSet = {
visible: true, visible: true,
x: parsedData.position.x, x: parsedData.position.x,
y: parsedData.position.y, y: parsedData.position.y,
eventData: parsedData, eventData: parsedData,
isRightClickActive: true, // 标记右键菜单正在显示 isRightClickActive: true, // 标记右键菜单正在显示
...menuConfig, // 展开具体配置 ...menuConfig, // 展开具体配置
}); };
console.log('右键菜单事件数据:', parsedData); manager.setState(stateToSet);
} }
/** /**

View File

@ -0,0 +1,108 @@
/**
*
*
*/
import * as AmrApi from '../../apis/amr';
import { editorStore } from '../../stores/editor.store';
export interface PointMenuConfig {
menuType: 'point' | 'default';
pointInfo?: {
id: string;
name: string;
type: string;
};
}
/**
*
* @param pointId ID
* @param pointName
* @param amrId ID
* @param amrName
* @returns
*/
export async function executeNavigateToPoint(
pointId: string,
pointName: string,
amrId: string,
amrName: string
): Promise<{ success: boolean; message: string; result?: any }> {
try {
console.log(`执行路径导航: 机器人 ${amrName} 到站点 ${pointName}`);
// 获取场景ID
const sceneId = editorStore.getEditorValue()?.getSceneId();
if (!sceneId) {
console.error('路径导航失败: 场景ID未提供');
return {
success: false,
message: '路径导航失败: 场景ID未提供',
};
}
// 调用手动下发小车移动任务接口
const result = await AmrApi.manualMoveTask({
sceneId,
amrId, // 注意根据接口文档这里应该传递AMR的ID
taskName: '手动移动任务',
targetStationName: pointName, // 使用站点名称作为目标站点
description: `手动下发的小车移动任务:从当前位置到 ${pointName}`,
operator: 'system',
priority: 500,
});
if (result.success) {
return {
success: true,
message: '路径导航任务下发成功',
result: result.data,
};
} else {
return {
success: false,
message: result.message || '路径导航任务下发失败',
};
}
} catch (error) {
console.error('路径导航失败:', error);
return {
success: false,
message: '', // 让全局错误处理显示具体错误
};
}
}
/**
*
* @param pointInfo
* @returns
*/
export function getPointMenuConfig(pointInfo: {
id: string;
name: string;
type: string;
}): PointMenuConfig {
return {
menuType: 'point',
pointInfo,
};
}
/**
*
* @param tags
* @param pen
* @returns
*/
export function isStationPoint(tags: string[], pen?: Record<string, unknown>): boolean {
// 如果标签包含 point 且不包含 storage 相关标签,则认为是站点
// 注意:不依赖 name 字段,因为 name 可能是 'point'
return tags.includes('point') &&
!tags.includes('storage') &&
!tags.includes('storage-background') &&
!tags.includes('storage-location') &&
!(pen as any)?.storageLocation; // 检查是否有库位关联
}

View File

@ -11,6 +11,11 @@ export interface ContextMenuState {
eventData?: any; eventData?: any;
storageLocations?: any[]; storageLocations?: any[];
robotInfo?: any; robotInfo?: any;
pointInfo?: {
id: string;
name: string;
type: string;
};
isRightClickActive?: boolean; // 新增:标记右键菜单是否正在显示,用于阻止选中状态更新 isRightClickActive?: boolean; // 新增:标记右键菜单是否正在显示,用于阻止选中状态更新
} }