web-map/src/pages/movement-supervision.vue

522 lines
17 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<script setup lang="ts">
import { LockState } from '@meta2d/core';
import { message } from 'ant-design-vue';
import { isNil } from 'lodash-es';
import { computed, onMounted, onUnmounted, provide, ref, shallowRef, watch } from 'vue';
import { useRoute } from 'vue-router';
import type { RobotRealtimeInfo } from '../apis/robot';
import { getSceneByGroupId, getSceneById, monitorRealSceneById, monitorSceneById } from '../apis/scene';
import { autoDoorSimulationService, type AutoDoorWebSocketData } from '../services/auto-door-simulation.service';
import {
type ContextMenuState,
createContextMenuManager,
handleContextMenuFromPenData,
isClickInsideMenu} from '../services/context-menu.service';
import { EditorService } from '../services/editor.service';
import { StorageLocationService } from '../services/storage-location.service';
import { useViewState } from '../services/useViewState';
const EDITOR_KEY = Symbol('editor-key');
type Props = {
sid: string;
id?: string;
};
const props = defineProps<Props>();
//#region 响应式状态定义
// 获取路由信息以判断当前模式
const route = useRoute();
const isMonitorMode = computed(() => route.path.includes('/monitor'));
// 场景标题
const title = ref<string>('');
// 监听标题变化,动态更新页面标题
watch(
isMonitorMode,
(isMonitor) => {
const mode = isMonitor ? '场景监控' : '场景仿真';
document.title = mode;
},
{ immediate: true },
);
// 服务实例
const container = shallowRef<HTMLDivElement>();
const editor = shallowRef<EditorService>();
const storageLocationService = shallowRef<StorageLocationService>();
const client = shallowRef<WebSocket>();
// 依赖注入
provide(EDITOR_KEY, editor);
//#endregion
//#region 核心服务接口
/**
* 读取场景数据
*/
const readScene = async () => {
const res = props.id ? await getSceneByGroupId(props.id, props.sid) : await getSceneById(props.sid);
title.value = res?.label ?? '';
editor.value?.load(res?.json);
};
/**
* 监控场景中的机器人状态
* 采用 requestAnimationFrame 优化高频数据渲染确保动画流畅且不阻塞UI线程。
*/
const monitorScene = async () => {
console.log(current.value?.id);
client.value?.close();
// 根据路由路径是否是真实场景监控决定使用哪个监控接口
const ws = isMonitorMode.value ? await monitorRealSceneById(props.sid) : await monitorSceneById(props.sid);
if (isNil(ws)) return;
// 使用 Map 来缓冲每个机器人最新的实时数据。
// Key 是机器人 IDValue 是该机器人最新的完整信息。
// 这样做可以确保我们总是有每个机器人最新的状态,并且可以快速更新,避免重复渲染。
const latestRobotData = new Map<string, RobotRealtimeInfo>();
// 用于存储 requestAnimationFrame 的 ID方便在组件卸载或 WebSocket 关闭时取消动画循环。
let animationFrameId: number;
/**
* 批量更新机器人数据,减少渲染调用次数
* @param updates 需要更新的机器人数据数组
*/
const batchUpdateRobots = (updates: Array<{ id: string; data: RobotRealtimeInfo }>) => {
if (!editor.value || updates.length === 0) return;
// 先更新所有机器人的状态信息,包括光圈渲染所需的状态
updates.forEach(({ id, data }) => {
const { x, y, active, angle, path: points, isWaring, isFault, ...rest } = data;
// 更新机器人基本信息
editor.value?.updateRobot(id, rest);
// 处理路径坐标转换参考refreshRobot方法的逻辑
let processedPath: Array<{ x: number; y: number }> | undefined;
if (points?.length) {
// 新路径:相对于机器人中心的坐标
const cx = x || 37; // 机器人中心X坐标默认37
const cy = y || 37; // 机器人中心Y坐标默认37
processedPath = points.map((p) => ({ x: p.x - cx, y: p.y - cy }));
}
// 更新机器人状态,触发光圈渲染
const robotState = { active, isWaring, isFault, path: processedPath, angle };
if (Object.values(robotState).some((v) => v !== undefined)) {
editor.value?.setValue({ id, robot: robotState }, { render: false, history: false, doEvent: false });
}
});
// 批量设置位置和可见性,只渲染一次
const positionUpdates = updates.map(({ id, data }) => {
const { x, y, angle } = data;
if (isNil(x) || isNil(y)) {
return { id, visible: false };
} else {
const newX = x - 60;
const newY = y - 60;
// 后端 angle 为逆时针,把转换改为“先取反再加偏移”:
const rotate = angle == null ? undefined : -angle + 180;
return { id, x: newX, y: newY, rotate, visible: true ,locked: LockState.None,};
}
});
// 使用Meta2D的批量更新方法减少渲染调用
positionUpdates.forEach((update) => {
editor.value?.setValue(update, { render: false, history: false, doEvent: false });
});
// 批量更新完成后,统一渲染一次
editor.value?.render();
};
/**
* 渲染循环函数。
* 通过 requestAnimationFrame 以浏览器的刷新频率被调用。
* 采用时间分片Time Slicing策略为每一帧的渲染操作设定一个时间预算frameBudget
* 避免单帧处理过多数据导致UI阻塞。
* 新增批量渲染优化,减少渲染调用次数。
*/
const renderLoop = () => {
const frameBudget = 8; // 每一帧的渲染预算单位毫秒。保守设置为8ms为其他任务留出时间。
const startTime = performance.now();
// 收集所有需要更新的机器人数据,但不立即渲染
const updates: Array<{ id: string; data: RobotRealtimeInfo }> = [];
// 在时间预算内,持续收集机器人数据
while (performance.now() - startTime < frameBudget && latestRobotData.size > 0) {
// 获取并移除 Map 中的第一条数据
const entry = latestRobotData.entries().next().value;
if (!entry) break;
const [id, data] = entry;
latestRobotData.delete(id);
// 确保机器人仍然存在于编辑器中
if (editor.value?.checkRobotById(id)) {
updates.push({ id, data });
}
}
// 批量更新机器人,减少渲染调用次数
if (updates.length > 0) {
batchUpdateRobots(updates);
}
// 处理缓冲的自动门点数据
autoDoorSimulationService.processBufferedData(frameBudget, startTime);
// 请求浏览器在下一次重绘之前再次调用 renderLoop形成动画循环。
// 即使本帧没有处理完所有数据,下一帧也会继续处理剩余的数据。
animationFrameId = requestAnimationFrame(renderLoop);
};
/**
* WebSocket 的 onmessage 事件处理器。
* 这个函数只做一件事:接收数据并将其快速存入缓冲区。
* 这种"数据与渲染分离"的设计是避免UI阻塞的关键。
*/
ws.onmessage = (e) => {
const data = JSON.parse(e.data || '{}');
// 判断数据类型type=99为自动门点其他为机器人
if (data.type === 99) {
// 自动门点数据处理
autoDoorSimulationService.handleWebSocketData(data as AutoDoorWebSocketData);
} else {
// 机器人数据处理
const robotData = data as RobotRealtimeInfo;
latestRobotData.set(robotData.id, robotData);
}
};
client.value = ws;
// WebSocket 连接成功后,立即启动渲染循环。
renderLoop();
// 增强 WebSocket 的 onclose 事件,以确保在连接关闭时清理资源。
const originalOnClose = ws.onclose;
ws.onclose = (event) => {
// 停止渲染循环,防止不必要的计算和内存泄漏。
cancelAnimationFrame(animationFrameId);
// 如果原始的 onclose 处理函数存在,则继续执行它。
if (originalOnClose) {
originalOnClose.call(ws, event);
}
};
};
//#endregion
//#region 服务初始化
onMounted(() => {
editor.value = new EditorService(container.value!);
storageLocationService.value = new StorageLocationService(editor.value, props.sid);
});
//#endregion
//#region 生命周期管理
onMounted(async () => {
await readScene();
await editor.value?.initRobots();
await monitorScene();
storageLocationService.value?.startMonitoring({ interval: 1 });
// 自动保存和恢复视图状态
await handleAutoSaveAndRestoreViewState();
// 设置编辑器服务
if (editor.value) {
autoDoorSimulationService.setEditorService(editor.value);
// 设置颜色配置服务的编辑器实例
// colorConfig.setEditorService(editor.value);
// 注释掉模拟逻辑使用真实WebSocket数据
// autoDoorSimulationService.startSimulation({
// deviceId: 'test01',
// label: 'TestAutoDoor01',
// interval: 3000,
// initialStatus: 0,
// 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(() => {
client.value?.close();
storageLocationService.value?.destroy();
// 清理自动门点服务(清空缓冲数据)
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
//#region 选择状态管理
const current = ref<{ type: 'robot' | 'point' | 'line' | 'area'; id: string }>();
// 监听编辑器选择状态变化
watch(
() => editor.value?.selected.value[0],
(v) => {
// 如果右键菜单正在显示,则不更新选中状态
if (contextMenuState.value.isRightClickActive) {
return;
}
const pen = editor.value?.getPenById(v);
if (pen?.id) {
current.value = { type: <'point' | 'line' | 'area'>pen.name, id: pen.id };
return;
}
if (current.value?.type === 'robot') return;
current.value = undefined;
},
);
// 计算当前选择的对象类型
const isRobot = computed(() => current.value?.type === 'robot');
const isPoint = computed(() => current.value?.type === 'point');
const isRoute = computed(() => current.value?.type === 'line');
const isArea = computed(() => current.value?.type === 'area');
/**
* 选择机器人
* @param id 机器人ID
*/
const selectRobot = (id: string) => {
current.value = { type: 'robot', id };
editor.value?.inactive();
// 聚焦到机器人位置
editor.value?.gotoById(id);
};
//#endregion
//#region 视图状态管理
const { saveViewState, autoSaveAndRestoreViewState, isSaving } = useViewState();
/**
* 保存当前视图状态
*/
const handleSaveViewState = async () => {
if (!editor.value) return;
try {
await saveViewState(editor.value, props.sid, props.id);
message.success('视图状态保存成功');
} catch {
message.error('视图状态保存失败');
}
};
/**
* 自动保存和恢复视图状态
*/
const handleAutoSaveAndRestoreViewState = async () => {
if (!editor.value) return;
await autoSaveAndRestoreViewState(editor.value, props.sid, props.id);
};
//#endregion
//#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: storageLocationService.value
});
};
/**
* 处理原生右键菜单事件(作为备用)
* @param event 鼠标事件
*/
const handleContextMenuEvent = (event: MouseEvent) => {
// 这个函数现在作为备用因为主要逻辑在handleEditorContextMenu中
console.log('原生右键菜单事件(备用):', event);
};
/**
* 关闭右键菜单
*/
const handleCloseContextMenu = () => {
contextMenuManager.close();
};
/**
* 处理右键菜单操作完成事件
* @param data 操作数据
*/
const handleActionComplete = (data: any) => {
console.log('右键菜单操作完成:', data);
// 可以在这里添加操作完成后的处理逻辑
// 比如刷新数据、显示消息等
};
/**
* 处理全局点击事件,用于关闭右键菜单
* @param event 点击事件
*/
const handleGlobalClick = (event: MouseEvent) => {
// 检查是否点击了关闭按钮
const closeButton = (event.target as Element)?.closest('.close-button');
if (closeButton) {
// 如果点击了关闭按钮,让按钮自己的点击事件处理关闭
return;
}
};
/**
* 处理全局键盘事件ESC键关闭右键菜单
* @param event 键盘事件
*/
const handleGlobalKeydown = (event: KeyboardEvent) => {
// ESC键关闭右键菜单
if (event.key === 'Escape' && contextMenuState.value.visible) {
contextMenuManager.close();
}
};
//#endregion
</script>
<template>
<a-layout class="full">
<a-layout-header class="p-16" style="height: 64px">
<a-flex justify="space-between" align="center">
<a-typography-text class="title">{{ title }}--{{ isMonitorMode ? '场景监控' : '场景仿真' }}</a-typography-text>
<a-space align="center">
<!-- <a-button @click="backToCards"> 返回 </a-button> -->
<a-button type="primary" :loading="isSaving" @click="handleSaveViewState"> 保存比例 </a-button>
</a-space>
</a-flex>
</a-layout-header>
<a-layout class="p-16">
<a-layout-sider :width="320">
<a-tabs type="card">
<a-tab-pane key="1" :tab="$t('机器人')">
<RobotGroups v-if="editor" :token="EDITOR_KEY" :sid="sid" :current="current?.id" @change="selectRobot" />
</a-tab-pane>
<a-tab-pane key="2" :tab="$t('库位')">
<PenGroups v-if="editor" :token="EDITOR_KEY" :current="current?.id" only-storage />
</a-tab-pane>
<a-tab-pane key="3" :tab="$t('高级组')">
<PenGroups v-if="editor" :token="EDITOR_KEY" :current="current?.id" />
</a-tab-pane>
</a-tabs>
</a-layout-sider>
<a-layout-content>
<div ref="container" class="editor-container full" @contextmenu="handleContextMenuEvent"></div>
<!-- 自定义地图工具栏固定右下角最小侵入 -->
<MapToolbar :token="EDITOR_KEY" :container-el="container" />
</a-layout-content>
</a-layout>
</a-layout>
<template v-if="current?.id">
<a-float-button style="top: 80px; right: 16px" shape="square" @click="show = !show">
<template #icon><i class="icon detail" /></template>
</a-float-button>
<div v-if="show" class="card-container">
<RobotDetailCard v-if="isRobot" :token="EDITOR_KEY" :current="current.id" />
<PointDetailCard
v-if="isPoint"
:token="EDITOR_KEY"
:current="current.id"
:storage-locations="storageLocationService?.getLocationsByPointId(current.id)"
/>
<RouteDetailCard v-if="isRoute" :token="EDITOR_KEY" :current="current.id" />
<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"
:robot-info="contextMenuState.robotInfo"
@close="handleCloseContextMenu"
@action-complete="handleActionComplete"
/>
</template>
<style scoped lang="scss">
.editor-container {
background-color: transparent !important;
position: relative;
z-index: 1;
}
.card-container {
position: fixed;
top: 80px;
right: 64px;
z-index: 100;
width: 320px;
height: calc(100% - 96px);
overflow: visible;
overflow-y: auto;
pointer-events: none;
& > * {
pointer-events: all;
}
}
</style>