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

858 lines
28 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 dayjs, { type Dayjs } from 'dayjs';
import { isNil } from 'lodash-es';
import { computed, onMounted, onUnmounted, provide, ref, type ShallowRef, shallowRef, watch } from 'vue';
import { useRoute } from 'vue-router';
import type { RobotRealtimeInfo } from '../apis/robot';
import { getSceneByGroupId, getSceneById, monitorRealSceneById, monitorSceneById } from '../apis/scene';
import expandIcon from '../assets/icons/png/expand.png';
import foldIcon from '../assets/icons/png/fold.png';
import FollowViewNotification from '../components/follow-view-notification.vue';
import PlaybackController from '../components/PlaybackController.vue';
import RobotLabels from '../components/robot-labels.vue';
import { usePlaybackWebSocket } from '../hooks/usePlaybackWebSocket';
import { autoDoorSimulationService, type AutoDoorWebSocketData } from '../services/auto-door-simulation.service';
import {
type ContextMenuState,
createContextMenuManager,
handleContextMenuFromPenData,
} from '../services/context-menu.service';
import { EditorService } from '../services/editor.service';
import { StorageLocationService } from '../services/storage-location.service';
import { useViewState } from '../services/useViewState';
import { editorStore } from '../stores/editor.store';
const EDITOR_KEY = Symbol('editor-key');
type Props = {
sid: string;
id?: string;
};
const props = defineProps<Props>();
//#region 响应式状态定义
// 获取路由信息以判断当前模式
const route = useRoute();
//#region Playback Mode
const mode = computed(() => (route.path.includes('/playback') ? 'playback' : 'live'));
const isPlaybackMode = computed(() => mode.value === 'playback');
const isMonitorMode = computed(() => route.path.includes('/monitor'));
// 场景标题
const title = ref<string>('');
// 新增:用于存储多楼层场景数据的数组
const floorScenes = ref<any[]>([]);
// 新增:当前楼层的索引
const currentFloorIndex = ref(0);
// 新增:判断是否为多楼层模式
const isMultiFloor = computed(() => floorScenes.value.length > 1);
const modeTitle = computed(() => {
if (isPlaybackMode.value) {
return '场景回放';
}
return isMonitorMode.value ? '场景监控' : '场景仿真';
});
// 左侧侧边栏折叠状态
const leftCollapsed = ref<boolean>(false);
// 监听标题变化,动态更新页面标题
watch(
modeTitle,
(newTitle) => {
document.title = newTitle;
},
{ immediate: true },
);
// 服务实例
const container = shallowRef<HTMLDivElement>();
const editor = shallowRef<EditorService>();
const storageLocationService = shallowRef<StorageLocationService>();
const client = shallowRef<WebSocket>();
// 左侧边栏元素引用(用于 Ctrl/Cmd+F 聚焦搜索框)
const leftSiderEl = shallowRef<HTMLElement>();
const isPlaybackControllerVisible = ref<boolean>(true);
const selectedDate = ref<Dayjs>();
const playback = usePlaybackWebSocket(editor);
watch(mode, async (newMode) => {
if (newMode === 'live') {
playback.disconnect();
await monitorScene();
} else {
client.value?.close();
playback.connect(props.sid); // Connect once using props.sid
}
});
watch(playback.sceneJson, async (newJson) => {
if (newJson) {
await editor.value?.load(newJson);
// [关键修复] 场景加载后,立即重新初始化机器人
await editor.value?.initRobots();
}
});
watch(selectedDate, (date) => {
if (date) {
const startOfDayTimestamp = date.startOf('day').valueOf();
playback.seek(startOfDayTimestamp);
// The total duration is now relative to the start of the day
playback.totalDuration.value = 24 * 60 * 60 * 1000;
// Set current time to the beginning of the selected day for the slider
playback.currentTime.value = startOfDayTimestamp;
}
});
const handleSeek = (relativeTime: number) => {
if (selectedDate.value) {
const absoluteTimestamp = relativeTime + selectedDate.value.startOf('day').valueOf();
// 乐观更新,立即将前端时间状态同步到用户选择的时间点
playback.currentTime.value = absoluteTimestamp;
playback.seek(absoluteTimestamp);
}
};
const handleDateChange = (date: Dayjs) => {
selectedDate.value = date;
};
// 监听后台推送的绝对时间戳如果跨天则自动更新UI上的日期选择器
watch(playback.currentTime, (newTimestamp) => {
if (newTimestamp > 0 && selectedDate.value) {
const newDate = dayjs(newTimestamp);
if (!newDate.isSame(selectedDate.value, 'day')) {
selectedDate.value = newDate;
}
}
});
watch(playback.sceneJson, async (newJson) => {
if (newJson) {
await editor.value?.load(newJson);
// [关键修复] 场景加载后,立即重新初始化机器人
await editor.value?.initRobots();
}
});
//#endregion
// 依赖注入
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 ?? '';
// 检查返回的json是数组多楼层还是对象单楼层
if (Array.isArray(res?.json)) {
if (res.json.length > 0) {
// 多楼层
floorScenes.value = res.json;
currentFloorIndex.value = 0; // 默认显示第一层
editor.value?.load(floorScenes.value[0]);
} else {
// 空数组,当作空场景处理
floorScenes.value = [];
editor.value?.load('{}'); // 加载空场景
message.warn('场景文件为空');
}
} else if (res?.json && Object.keys(res.json).length > 0) {
// 单楼层,包装成数组以统一处理
floorScenes.value = [res.json];
currentFloorIndex.value = 0;
editor.value?.load(res.json);
} else {
// 空对象或null/undefined也当作空场景处理
floorScenes.value = [];
editor.value?.load('{}'); // 加载空场景
message.warn('场景文件为空或格式不正确');
}
};
/**
* 监控场景中的机器人状态
* 采用 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;
// 用于收集所有图元pen的更新数据
const allPenUpdates: any[] = [];
updates.forEach(({ id, data }) => {
const {
x,
y,
active,
angle,
path: points,
isWaring,
isFault,
isCharging = 0,
isCarrying = 0,
canOrder,
...rest
} = data;
// 1. 更新机器人缓存的业务数据
editor.value?.updateRobot(id, {
...rest,
isCharging: isCharging as any,
isCarrying: isCarrying as any,
canOrder: canOrder as any,
});
// 2. 准备图元pen的更新负载对象将多个更新合并
const penUpdatePayload: any = { id };
const robotState: any = {};
// 2.1 处理路径并将其放入 robotState
// 处理路径坐标转换参考refreshRobot方法的逻辑
if (points?.length && !isMonitorMode.value) {
// 新路径:相对于机器人中心的坐标
const cx = x || 37; // 机器人中心X坐标默认37
const cy = y || 37; // 机器人中心Y坐标默认37
robotState.path = points.map((p) => ({ x: p.x - cx, y: p.y - cy }));
}
// 2.2 合并其他机器人状态
if (active !== undefined) robotState.active = active;
if (isWaring !== undefined) robotState.isWaring = isWaring;
if (isFault !== undefined) robotState.isFault = isFault;
if (isCharging !== undefined) robotState.isCharging = isCharging;
if (isCarrying !== undefined) robotState.isCarrying = isCarrying;
if (canOrder !== undefined) robotState.canOrder = canOrder;
// 将合并后的状态赋给 payload
if (Object.keys(robotState).length > 0) {
penUpdatePayload.robot = robotState;
}
// 2.3 合并位置、可见性和角度
if (!isNil(x) && !isNil(y)) {
penUpdatePayload.x = x - 60;
penUpdatePayload.y = y - 60;
penUpdatePayload.visible = true;
penUpdatePayload.locked = LockState.None;
}
if (angle != null) {
penUpdatePayload.rotate = -angle + 180;
}
// 只有当有实际更新时才推入数组
if (Object.keys(penUpdatePayload).length > 1) {
allPenUpdates.push(penUpdatePayload);
}
// 3. 更新状态覆盖图标 (此API调用如果不能合并则保留在循环内)
// 使用刚刚为机器人计算出的新位置来更新覆盖物,以确保同步
const newPositionForOverlay =
penUpdatePayload.x !== undefined && penUpdatePayload.y !== undefined
? { x: penUpdatePayload.x, y: penUpdatePayload.y, rotate: penUpdatePayload.rotate }
: undefined;
editor.value?.updateRobotStatusOverlay?.(id, false, newPositionForOverlay);
});
// 4. 使用Meta2D的批量更新方法一次性提交所有更改
if (allPenUpdates.length > 0) {
allPenUpdates.forEach((update) => {
editor.value?.setValue(update, { render: false, history: false, doEvent: false });
});
}
// 5. 批量更新完成后,统一渲染一次
/*
为了让机器人在地图上最基本地被绘制出来并能够移动,后台推送的 WebSocket 数据最少需要 id, x, y 这 3
个字段。
为了达到一个功能上比较完整的视觉效果(带光圈、能旋转),则最少需要 id, x, y, angle, active 这 5
个字段。
*/
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!, props.sid);
// 将 editor 存储到 store 中
editorStore.setEditor(editor as ShallowRef<EditorService>);
storageLocationService.value = new StorageLocationService(editor.value, props.sid);
container.value?.addEventListener('pointerdown', handleCanvasPointerDown, true);
});
//#endregion
//#region 生命周期管理
onMounted(async () => {
console.log('Current route in movement-supervision.vue:', window.location.href);
if (mode.value === 'live') {
await readScene();
await monitorScene();
} else {
playback.connect(props.sid);
// [调试代码] 自动设置回放日期和时间
const debugDate = dayjs('2025-09-28 09:00:00');
selectedDate.value = debugDate;
playback.seek(debugDate.valueOf());
}
await editor.value?.initRobots();
storageLocationService.value?.startMonitoring({ interval: 1 });
// 自动保存和恢复视图状态
await handleAutoSaveAndRestoreViewState();
// 设置编辑器服务
if (editor.value) {
autoDoorSimulationService.setEditorService(editor.value);
}
// 订阅右键菜单状态变化
contextMenuManager.subscribe((state) => {
contextMenuState.value = state;
});
// 添加全局点击事件监听器,用于关闭右键菜单
document.addEventListener('click', handleGlobalClick);
document.addEventListener('keydown', handleGlobalKeydown);
// 监听 Ctrl/Cmd+F 聚焦左侧搜索框
document.addEventListener('keydown', focusFindKeydownHandler);
});
onUnmounted(() => {
client.value?.close();
playback.disconnect(); // Also disconnect playback WS on unmount
storageLocationService.value?.destroy();
// 清理自动门点服务(清空缓冲数据)
autoDoorSimulationService.clearBufferedData();
// 移除EditorService事件监听器
if (editor.value) {
(editor.value as any).off('customContextMenu', (event: Record<string, unknown>) => {
handleEditorContextMenu(event);
});
}
container.value?.removeEventListener('pointerdown', handleCanvasPointerDown, true);
if (rightClickGuardTimer) {
clearTimeout(rightClickGuardTimer);
rightClickGuardTimer = undefined;
}
// 移除全局事件监听器
document.removeEventListener('click', handleGlobalClick);
document.removeEventListener('keydown', handleGlobalKeydown);
document.removeEventListener('keydown', focusFindKeydownHandler);
});
//#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();
// [FIX] In playback mode, robot pens might not be correctly set as visible or unlocked.
// This ensures that gotoById can focus on the robot.
if (isPlaybackMode.value) {
editor.value?.setValue(
{
id,
visible: true,
locked: LockState.None,
},
{ render: false, history: false },
);
}
// 聚焦到机器人位置
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 楼层切换
const handleFloorChange = async (value: any) => {
const newFloorIndex = value as number;
if (editor.value && floorScenes.value[newFloorIndex]) {
await editor.value.load(floorScenes.value[newFloorIndex]);
// 切换楼层后,重新初始化机器人以确保状态正确
await editor.value.initRobots();
}
};
//#endregion
//#region UI状态管理
const show = ref<boolean>(true);
// 右键菜单状态管理 - 使用组合式函数
const contextMenuManager = createContextMenuManager();
const contextMenuState = ref<ContextMenuState>(contextMenuManager.getState());
let rightClickGuardTimer: ReturnType<typeof setTimeout> | undefined;
/**
* Guard selection state before Meta2D processes the contextmenu event.
*/
const handleCanvasPointerDown = (event: PointerEvent) => {
if (event.button !== 2) return;
console.log('[排查] 1. handleCanvasPointerDown 触发,直接处理右键逻辑');
// 阻止事件继续传播,防止 Meta2d 库的默认行为抑制 contextmenu 事件
event.preventDefault();
event.stopPropagation();
// 手动构建事件对象,模拟从 EditorService 接收到的数据
const penData = {
e: event,
clientRect: {
x: event.clientX,
y: event.clientY,
width: 0,
height: 0,
top: event.clientY,
right: event.clientX,
bottom: event.clientY,
left: event.clientX,
},
pen: editor.value?.store.hover, // 从 editor store 获取当前悬停的元素
};
// 直接调用右键菜单处理函数
handleEditorContextMenu(penData);
};
//#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('[排查] 3. handleEditorContextMenu 开始处理事件', penData);
handleContextMenuFromPenData(penData, contextMenuManager, {
storageLocationService: storageLocationService.value,
robotService: editor.value, // 传递EditorService作为机器人服务
});
};
/**
* 关闭右键菜单
*/
const handleCloseContextMenu = () => {
contextMenuManager.close();
contextMenuManager.setState({ isRightClickActive: false });
};
/**
* 处理右键菜单操作完成事件
* @param data 操作数据
*/
const handleActionComplete = (data: any) => {
console.log('右键菜单操作完成:', data);
// 操作完成后,关闭菜单,以便下次打开时刷新数据
handleCloseContextMenu();
// 如果成功,可以触发一个全局事件或调用一个方法来刷新整个场景的数据
if (data.success) {
// 例如: sceneStore.refreshData();
// 目前,关闭菜单后,用户再次右键会看到最新状态,这已经满足了基本需求。
}
};
/**
* 处理全局点击事件,用于关闭右键菜单
* @param event 点击事件
*/
const handleGlobalClick = (event: MouseEvent) => {
// 检查是否点击了关闭按钮
const closeButton = (event.target as Element)?.closest('.close-button');
if (closeButton) {
// 如果点击了关闭按钮,让按钮自己的点击事件处理关闭
return;
}
};
// 判断事件目标是否在可编辑区域(输入框/文本域/可编辑元素)
const isTypingElement = (el: EventTarget | null) => {
const node = el as HTMLElement | null;
if (!node) return false;
const tag = node.tagName?.toLowerCase();
return tag === 'input' || tag === 'textarea' || node.isContentEditable === true;
};
// Ctrl/Cmd + F 快捷键聚焦左侧搜索输入框
const focusFindKeydownHandler = (event: KeyboardEvent) => {
const isFindKey = event.key === 'f' || event.key === 'F';
if ((event.ctrlKey || event.metaKey) && isFindKey && !isTypingElement(event.target)) {
const raw: any = leftSiderEl.value as any;
const sider: HTMLElement | null = raw && raw.$el ? (raw.$el as HTMLElement) : (raw as HTMLElement | null);
if (sider) {
const input = sider.querySelector('.search input, .search .ant-input') as HTMLInputElement | null;
if (input) {
event.preventDefault();
input.focus();
input.select?.();
}
}
}
};
/**
* 处理全局键盘事件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 }}--{{ modeTitle }}</a-typography-text>
<a-space align="center">
<a-button @click="backToCards"> 返回 </a-button>
<a-button type="primary" :loading="isSaving" @click="handleSaveViewState"> 保存比例 </a-button>
<a-divider type="vertical" />
<a-select
v-if="isMultiFloor"
v-model:value="currentFloorIndex"
style="width: 120px"
@change="handleFloorChange"
>
<a-select-option v-for="(scene, index) in floorScenes" :key="index" :value="index">
楼层 {{ index + 1 }}
</a-select-option>
</a-select>
</a-space>
</a-flex>
</a-layout-header>
<a-layout class="p-16 main-layout">
<img
:src="leftCollapsed ? expandIcon : foldIcon"
class="sider-toggle"
alt="toggle-sider"
@click="leftCollapsed = !leftCollapsed"
/>
<a-layout-sider
class="left-sider"
:width="leftCollapsed ? 0 : 320"
:style="{ minWidth: leftCollapsed ? '0px' : '320px', overflow: 'hidden' }"
ref="leftSiderEl"
>
<a-tabs type="card" v-show="!leftCollapsed">
<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="1.5" :tab="$t('机器人标签')">
<RobotLabels 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"></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" :key="current.id" :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>
<!-- 视角跟随提示 -->
<FollowViewNotification />
<!-- 右键菜单 -->
<ContextMenu
:visible="contextMenuState.visible"
:x="contextMenuState.x"
:y="contextMenuState.y"
:menu-type="contextMenuState.menuType"
:storage-locations="contextMenuState.storageLocations"
:robot-id="contextMenuState.robotInfo?.id"
:token="EDITOR_KEY"
@close="handleCloseContextMenu"
@action-complete="handleActionComplete"
/>
<template v-if="isPlaybackMode && selectedDate">
<a-float-button
style="right: 24px; bottom: 120px"
shape="square"
@click="isPlaybackControllerVisible = !isPlaybackControllerVisible"
>
<template #icon>
<i class="icon" :class="isPlaybackControllerVisible ? 'fold' : 'expand'" />
</template>
</a-float-button>
<!-- Playback Controller -->
<PlaybackController
v-if="isPlaybackControllerVisible"
:is-playing="playback.isPlaying.value"
:current-time="playback.currentTime.value - (selectedDate?.startOf('day').valueOf() ?? 0)"
:total-duration="playback.totalDuration.value"
:selected-date="selectedDate"
@play="playback.play"
@pause="playback.pause"
@seek="handleSeek"
@date-change="handleDateChange"
/>
</template>
</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;
}
}
.main-layout {
position: relative;
}
.left-sider {
position: relative;
left: 36px;
}
.sider-toggle {
position: absolute;
top: 20px;
left: 8px;
width: 36px;
height: 36px;
padding: 8px;
cursor: pointer;
z-index: 10;
background-color: #fff;
border-radius: 4px;
body[data-theme='dark'] & {
background-color: #000;
}
}
:deep(.ant-tabs-tab) {
padding: 14px 12px !important;
}
</style>