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

394 lines
13 KiB
Vue
Raw Normal View History

2025-05-22 18:06:30 +08:00
<script setup lang="ts">
2025-05-25 00:07:22 +08:00
import type { RobotRealtimeInfo } from '@api/robot';
import { getSceneByGroupId, getSceneById, monitorRealSceneById, monitorSceneById } from '@api/scene';
2025-05-22 18:06:30 +08:00
import { EditorService } from '@core/editor.service';
import { StorageLocationService } from '@core/storage-location.service';
import { useViewState } from '@core/useViewState';
import { message } from 'ant-design-vue';
2025-05-25 00:07:22 +08:00
import { isNil } from 'lodash-es';
import { computed, onMounted, onUnmounted, provide, ref, shallowRef, watch } from 'vue';
import { useRoute } from 'vue-router';
2025-05-22 18:06:30 +08:00
import { autoDoorSimulationService, type AutoDoorWebSocketData } from '../services/auto-door-simulation.service';
2025-05-22 18:06:30 +08:00
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 核心服务接口
/**
* 读取场景数据
*/
2025-05-22 18:06:30 +08:00
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);
};
2025-05-25 00:07:22 +08:00
/**
* 监控场景中的机器人状态
* 采用 requestAnimationFrame 优化高频数据渲染确保动画流畅且不阻塞UI线程
*/
2025-05-25 00:07:22 +08:00
const monitorScene = async () => {
console.log(current.value?.id);
2025-05-25 00:07:22 +08:00
client.value?.close();
// 根据路由路径是否是真实场景监控决定使用哪个监控接口
const ws = isMonitorMode.value ? await monitorRealSceneById(props.sid) : await monitorSceneById(props.sid);
2025-05-25 00:07:22 +08:00
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;
2025-09-02 15:12:13 +08:00
// 后端 angle 为逆时针,把转换改为“先取反再加偏移”:
2025-09-01 19:24:22 +08:00
const rotate = angle == null ? undefined : -angle + 180;
return { id, x: newX, y: newY, rotate, visible: true };
}
});
// 使用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 });
}
2025-05-25 00:07:22 +08:00
}
// 批量更新机器人,减少渲染调用次数
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);
}
2025-05-25 00:07:22 +08:00
};
2025-05-25 00:07:22 +08:00
client.value = ws;
// WebSocket 连接成功后,立即启动渲染循环。
renderLoop();
// 增强 WebSocket 的 onclose 事件,以确保在连接关闭时清理资源。
const originalOnClose = ws.onclose;
ws.onclose = (event) => {
// 停止渲染循环,防止不必要的计算和内存泄漏。
cancelAnimationFrame(animationFrameId);
// 如果原始的 onclose 处理函数存在,则继续执行它。
if (originalOnClose) {
originalOnClose.call(ws, event);
}
};
2025-05-25 00:07:22 +08:00
};
2025-05-22 18:06:30 +08:00
//#endregion
//#region 服务初始化
2025-05-22 18:06:30 +08:00
onMounted(() => {
editor.value = new EditorService(container.value!);
storageLocationService.value = new StorageLocationService(editor.value, props.sid);
2025-05-22 18:06:30 +08:00
});
//#endregion
2025-05-22 18:06:30 +08:00
//#region 生命周期管理
2025-05-25 00:07:22 +08:00
onMounted(async () => {
await readScene();
await editor.value?.initRobots();
2025-06-06 22:36:16 +08:00
await monitorScene();
storageLocationService.value?.startMonitoring({ interval: 1 });
// 自动保存和恢复视图状态
await handleAutoSaveAndRestoreViewState();
// 设置编辑器服务
if (editor.value) {
autoDoorSimulationService.setEditorService(editor.value);
// 注释掉模拟逻辑使用真实WebSocket数据
// autoDoorSimulationService.startSimulation({
// deviceId: 'test01',
// label: 'TestAutoDoor01',
// interval: 3000,
// initialStatus: 0,
// enableLogging: true,
// });
}
2025-05-25 00:07:22 +08:00
});
2025-05-25 00:07:22 +08:00
onUnmounted(() => {
client.value?.close();
storageLocationService.value?.destroy();
// 清理自动门点服务(清空缓冲数据)
autoDoorSimulationService.clearBufferedData();
// 注释掉模拟相关的清理只保留WebSocket数据处理的清理
// autoDoorSimulationService.stopAllSimulations();
2025-05-25 00:07:22 +08:00
});
//#endregion
2025-05-25 00:07:22 +08:00
//#region 选择状态管理
2025-05-22 18:06:30 +08:00
const current = ref<{ type: 'robot' | 'point' | 'line' | 'area'; id: string }>();
// 监听编辑器选择状态变化
2025-05-22 18:06:30 +08:00
watch(
() => editor.value?.selected.value[0],
(v) => {
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;
},
);
// 计算当前选择的对象类型
2025-05-22 18:06:30 +08:00
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
*/
2025-05-22 18:06:30 +08:00
const selectRobot = (id: string) => {
current.value = { type: 'robot', id };
editor.value?.inactive();
// 聚焦到机器人位置
editor.value?.gotoById(id);
2025-05-22 18:06:30 +08:00
};
//#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);
//#endregion
// 返回到父级 iframe 的场景卡片
const backToCards = () => {
window.parent?.postMessage({ type: 'scene_return_to_cards' }, '*');
};
2025-05-22 18:06:30 +08:00
</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>
2025-05-22 18:06:30 +08:00
</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('库区')">
2025-05-22 18:06:30 +08:00
<PenGroups v-if="editor" :token="EDITOR_KEY" :current="current?.id" only-area1 />
</a-tab-pane>
2025-05-22 18:06:30 +08:00
<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"></div>
</a-layout-content>
</a-layout>
</a-layout>
2025-05-25 00:07:22 +08:00
<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)"
/>
2025-05-25 00:07:22 +08:00
<RouteDetailCard v-if="isRoute" :token="EDITOR_KEY" :current="current.id" />
<AreaDetailCard v-if="isArea" :token="EDITOR_KEY" :current="current.id" />
</div>
</template>
2025-05-22 18:06:30 +08:00
</template>
<style scoped lang="scss">
.editor-container {
background-color: transparent !important;
}
.card-container {
position: fixed;
top: 80px;
right: 64px;
z-index: 100;
width: 320px;
height: calc(100% - 96px);
overflow: visible;
overflow-y: auto;
2025-05-25 16:45:45 +08:00
pointer-events: none;
& > * {
pointer-events: all;
}
2025-05-22 18:06:30 +08:00
}
</style>