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

286 lines
9.2 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 type { RobotRealtimeInfo } from '@api/robot';
import { getSceneByGroupId, getSceneById, monitorRealSceneById, monitorSceneById } from '@api/scene';
import { EditorService } from '@core/editor.service';
import { StorageLocationService } from '@core/storage-location.service';
import { useViewState } from '@core/useViewState';
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';
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>('');
// 服务实例
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;
/**
* 渲染循环函数。
* 这个函数会通过 requestAnimationFrame 以浏览器的刷新频率通常是60fps被反复调用。
* 这是实现流畅动画的核心。
*/
const renderLoop = () => {
// 检查是否有新的数据需要渲染。如果没有,则本次循环不执行任何操作,节省性能。
if (latestRobotData.size > 0) {
// 遍历所有待更新的机器人数据
latestRobotData.forEach((data, id) => {
const { x, y, active, angle, path, isWaring, isFault, ...rest } = data;
// 确保机器人仍然存在于编辑器中
if (!editor.value?.checkRobotById(id)) return;
// 更新机器人的非位置属性(如状态等)
editor.value?.updateRobot(id, rest);
// 更新机器人的位置和可见性
if (isNil(x) || isNil(y)) {
// 如果坐标为空,则隐藏机器人
editor.value.updatePen(id, { visible: false });
} else {
// 如果坐标有效,则刷新机器人在画布上的位置、角度等
editor.value.refreshRobot(id, { x, y, active, angle, path, isWaring, isFault });
}
});
// 本次渲染完成后,清空数据缓冲区,等待下一批新数据的到来。
latestRobotData.clear();
}
// 请求浏览器在下一次重绘之前再次调用 renderLoop形成动画循环。
animationFrameId = requestAnimationFrame(renderLoop);
};
/**
* WebSocket 的 onmessage 事件处理器。
* 这个函数只做一件事:接收数据并将其快速存入缓冲区。
* 这种“数据与渲染分离”的设计是避免UI阻塞的关键。
*/
ws.onmessage = (e) => {
const data = <RobotRealtimeInfo>JSON.parse(e.data || '{}');
// 将最新数据存入 Map如果已有相同ID的数据则会被自动覆盖。
latestRobotData.set(data.id, data);
};
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();
await storageLocationService.value?.startMonitoring({ interval: 3 });
// 自动保存和恢复视图状态
await handleAutoSaveAndRestoreViewState();
});
onUnmounted(() => {
client.value?.close();
storageLocationService.value?.destroy();
});
//#endregion
//#region 选择状态管理
const current = ref<{ type: 'robot' | 'point' | 'line' | 'area'; id: string }>();
// 监听编辑器选择状态变化
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;
},
);
// 计算当前选择的对象类型
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);
//#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-button type="primary" :loading="isSaving" @click="handleSaveViewState"> 保存比例 </a-button>
</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-area1 />
</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"></div>
</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>
</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;
pointer-events: none;
& > * {
pointer-events: all;
}
}
</style>