feat: 添加历史回放功能,支持选择回放日期并集成播放控制器,优化实时监控与回放模式切换
This commit is contained in:
parent
a87909a736
commit
cf2a2f97f5
105
src/components/PlaybackController.vue
Normal file
105
src/components/PlaybackController.vue
Normal file
@ -0,0 +1,105 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { CaretRightOutlined, PauseOutlined } from '@ant-design/icons-vue';
|
||||||
|
import { computed } from 'vue';
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
isPlaying: boolean;
|
||||||
|
currentTime: number; // in milliseconds
|
||||||
|
totalDuration: number; // in milliseconds
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: 'play'): void;
|
||||||
|
(e: 'pause'): void;
|
||||||
|
(e: 'seek', time: number): void;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
// --- Computed properties for display ---
|
||||||
|
const formatTime = (ms: number): string => {
|
||||||
|
if (isNaN(ms) || ms < 0) {
|
||||||
|
return '00:00:00';
|
||||||
|
}
|
||||||
|
const totalSeconds = Math.floor(ms / 1000);
|
||||||
|
const hours = Math.floor(totalSeconds / 3600);
|
||||||
|
const minutes = Math.floor((totalSeconds % 3600) / 60);
|
||||||
|
const seconds = totalSeconds % 60;
|
||||||
|
return `${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const currentTimeFormatted = computed(() => formatTime(props.currentTime));
|
||||||
|
const totalDurationFormatted = computed(() => formatTime(props.totalDuration));
|
||||||
|
|
||||||
|
const sliderValue = computed({
|
||||||
|
get: () => props.currentTime,
|
||||||
|
set: (val: number) => {
|
||||||
|
emit('seek', val);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const handlePlayPause = () => {
|
||||||
|
if (props.isPlaying) {
|
||||||
|
emit('pause');
|
||||||
|
} else {
|
||||||
|
emit('play');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="playback-controller">
|
||||||
|
<a-row align="middle" :gutter="16">
|
||||||
|
<!-- Play/Pause Button -->
|
||||||
|
<a-col>
|
||||||
|
<a-button shape="circle" @click="handlePlayPause">
|
||||||
|
<template #icon>
|
||||||
|
<PauseOutlined v-if="isPlaying" />
|
||||||
|
<CaretRightOutlined v-else />
|
||||||
|
</template>
|
||||||
|
</a-button>
|
||||||
|
</a-col>
|
||||||
|
|
||||||
|
<!-- Timestamp -->
|
||||||
|
<a-col>
|
||||||
|
<a-typography-text class="time-display">
|
||||||
|
{{ currentTimeFormatted }} / {{ totalDurationFormatted }}
|
||||||
|
</a-typography-text>
|
||||||
|
</a-col>
|
||||||
|
|
||||||
|
<!-- Timeline Slider -->
|
||||||
|
<a-col flex="1">
|
||||||
|
<a-slider
|
||||||
|
v-model:value="sliderValue"
|
||||||
|
:max="totalDuration"
|
||||||
|
:tip-formatter="formatTime as any"
|
||||||
|
:tooltip-open="false"
|
||||||
|
/>
|
||||||
|
</a-col>
|
||||||
|
</a-row>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.playback-controller {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
padding: 16px 24px;
|
||||||
|
background-color: rgba(255, 255, 255, 0.9);
|
||||||
|
box-shadow: 0 -2px 8px rgba(0, 0, 0, 0.1);
|
||||||
|
z-index: 1000;
|
||||||
|
backdrop-filter: blur(5px);
|
||||||
|
border-top: 1px solid #f0f0f0;
|
||||||
|
|
||||||
|
body[data-theme='dark'] & {
|
||||||
|
background-color: rgba(20, 20, 20, 0.9);
|
||||||
|
border-top: 1px solid #303030;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.time-display {
|
||||||
|
font-family: 'Courier New', Courier, monospace;
|
||||||
|
font-size: 14px;
|
||||||
|
min-width: 180px; // Prevents layout shift
|
||||||
|
}
|
||||||
|
</style>
|
||||||
207
src/hooks/usePlaybackWebSocket.ts
Normal file
207
src/hooks/usePlaybackWebSocket.ts
Normal file
@ -0,0 +1,207 @@
|
|||||||
|
|
||||||
|
import { message } from 'ant-design-vue';
|
||||||
|
import { type Ref, ref, type ShallowRef,shallowRef } from 'vue';
|
||||||
|
|
||||||
|
import type { RobotRealtimeInfo } from '../apis/robot';
|
||||||
|
import type { EditorService } from '../services/editor.service';
|
||||||
|
|
||||||
|
// Define the structure of WebSocket messages for playback
|
||||||
|
type PlaybackMessage = {
|
||||||
|
type: 'AMR' | 'SCENE';
|
||||||
|
timestamp: number;
|
||||||
|
data: any;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Hook's return type definition
|
||||||
|
export interface UsePlaybackWebSocketReturn {
|
||||||
|
// Connection status
|
||||||
|
isConnected: Ref<boolean>;
|
||||||
|
|
||||||
|
// Playback state
|
||||||
|
isPlaying: Ref<boolean>;
|
||||||
|
currentTime: Ref<number>;
|
||||||
|
totalDuration: Ref<number>;
|
||||||
|
|
||||||
|
// Data for rendering
|
||||||
|
currentSceneId: Ref<string | null>;
|
||||||
|
sceneJson: Ref<string | null>;
|
||||||
|
|
||||||
|
// Methods
|
||||||
|
connect: (historySceneId: string) => void;
|
||||||
|
disconnect: () => void;
|
||||||
|
play: () => void;
|
||||||
|
pause: () => void;
|
||||||
|
seek: (timestamp: number) => void;
|
||||||
|
changeSpeed: (speed: number) => void; // Placeholder for speed control
|
||||||
|
}
|
||||||
|
|
||||||
|
export function usePlaybackWebSocket(editorService: ShallowRef<EditorService | undefined>): UsePlaybackWebSocketReturn {
|
||||||
|
const client = shallowRef<WebSocket | null>(null);
|
||||||
|
const isConnected = ref(false);
|
||||||
|
const isPlaying = ref(false);
|
||||||
|
|
||||||
|
const currentTime = ref(0);
|
||||||
|
const totalDuration = ref(0);
|
||||||
|
const currentSceneId = ref<string | null>(null);
|
||||||
|
const sceneJson = ref<string | null>(null);
|
||||||
|
|
||||||
|
const latestRobotData = new Map<string, RobotRealtimeInfo>();
|
||||||
|
let animationFrameId: number;
|
||||||
|
|
||||||
|
const connect = (historySceneId: string) => {
|
||||||
|
if (client.value) {
|
||||||
|
disconnect();
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: Replace with the actual WebSocket host from environment variables or config.
|
||||||
|
const wsHost = import.meta.env.VITE_WEBSOCKET_URL || `ws://${window.location.host}`;
|
||||||
|
const wsUrl = `${wsHost}/history/scene/logplayback/${historySceneId}`;
|
||||||
|
|
||||||
|
const ws = new WebSocket(wsUrl);
|
||||||
|
client.value = ws;
|
||||||
|
|
||||||
|
ws.onopen = () => {
|
||||||
|
isConnected.value = true;
|
||||||
|
message.success('回放连接已建立');
|
||||||
|
startRenderLoop();
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onmessage = (event) => {
|
||||||
|
const msg = JSON.parse(event.data) as PlaybackMessage;
|
||||||
|
|
||||||
|
if (msg.timestamp) {
|
||||||
|
currentTime.value = msg.timestamp;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (msg.type === 'SCENE') {
|
||||||
|
// As per user feedback, data is the full scene JSON string
|
||||||
|
sceneJson.value = msg.data;
|
||||||
|
latestRobotData.clear(); // Clear robot data when scene changes
|
||||||
|
} else if (msg.type === 'AMR') {
|
||||||
|
(msg.data as RobotRealtimeInfo[]).forEach(robot => {
|
||||||
|
latestRobotData.set(robot.id, robot);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onclose = () => {
|
||||||
|
isConnected.value = false;
|
||||||
|
isPlaying.value = false;
|
||||||
|
stopRenderLoop();
|
||||||
|
message.info('回放连接已断开');
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onerror = (error) => {
|
||||||
|
console.error('回放 WebSocket 发生错误:', error);
|
||||||
|
message.error('回放连接发生错误');
|
||||||
|
disconnect();
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const disconnect = () => {
|
||||||
|
if (client.value) {
|
||||||
|
client.value.close();
|
||||||
|
client.value = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const sendCommand = (command: string) => {
|
||||||
|
if (client.value && client.value.readyState === WebSocket.OPEN) {
|
||||||
|
client.value.send(command);
|
||||||
|
} else {
|
||||||
|
message.warn('回放连接未建立或已断开,无法发送指令');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const play = () => {
|
||||||
|
sendCommand('PLAY');
|
||||||
|
isPlaying.value = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const pause = () => {
|
||||||
|
// The API doc says PAUSE:{timestamp}, using current time.
|
||||||
|
sendCommand(`PAUSE:${currentTime.value}`);
|
||||||
|
isPlaying.value = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const seek = (timestamp: number) => {
|
||||||
|
sendCommand(`SEEK:${timestamp}`);
|
||||||
|
// If not playing, seeking should not start playing.
|
||||||
|
};
|
||||||
|
|
||||||
|
const changeSpeed = (speed: number) => {
|
||||||
|
// The provided API doc does not include speed control.
|
||||||
|
// This is a placeholder for future implementation if the backend supports it.
|
||||||
|
console.warn(`播放速度控制 (${speed}x) 尚未实现`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const batchUpdateRobots = (updates: Array<{ id: string; data: RobotRealtimeInfo }>) => {
|
||||||
|
const editor = editorService.value;
|
||||||
|
if (!editor || updates.length === 0) return;
|
||||||
|
|
||||||
|
// This logic is adapted from movement-supervision.vue for consistency.
|
||||||
|
updates.forEach(({ id, data }) => {
|
||||||
|
if (editor.checkRobotById(id)) {
|
||||||
|
const { x, y, angle, ...rest } = data;
|
||||||
|
editor.updateRobot(id, rest);
|
||||||
|
editor.setValue({
|
||||||
|
id,
|
||||||
|
x: x - 60,
|
||||||
|
y: y - 60,
|
||||||
|
rotate: -angle! + 180,
|
||||||
|
visible: true,
|
||||||
|
}, { render: false });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderLoop = () => {
|
||||||
|
const updates: Array<{ id: string; data: RobotRealtimeInfo }> = [];
|
||||||
|
|
||||||
|
// Naive implementation: process all buffered data in one frame.
|
||||||
|
// Can be optimized with time slicing like in movement-supervision.vue if needed.
|
||||||
|
for (const [id, data] of latestRobotData.entries()) {
|
||||||
|
updates.push({ id, data });
|
||||||
|
}
|
||||||
|
latestRobotData.clear();
|
||||||
|
|
||||||
|
if (updates.length > 0) {
|
||||||
|
batchUpdateRobots(updates);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for playback end
|
||||||
|
if (isPlaying.value && currentTime.value >= totalDuration.value && totalDuration.value > 0) {
|
||||||
|
pause();
|
||||||
|
// Optionally, set current time to total duration to prevent overflow on slider
|
||||||
|
currentTime.value = totalDuration.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
editorService.value?.render();
|
||||||
|
|
||||||
|
animationFrameId = requestAnimationFrame(renderLoop);
|
||||||
|
};
|
||||||
|
|
||||||
|
const startRenderLoop = () => {
|
||||||
|
stopRenderLoop(); // Ensure no multiple loops are running
|
||||||
|
renderLoop();
|
||||||
|
};
|
||||||
|
|
||||||
|
const stopRenderLoop = () => {
|
||||||
|
cancelAnimationFrame(animationFrameId);
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
isConnected,
|
||||||
|
isPlaying,
|
||||||
|
currentTime,
|
||||||
|
totalDuration,
|
||||||
|
currentSceneId,
|
||||||
|
sceneJson,
|
||||||
|
connect,
|
||||||
|
disconnect,
|
||||||
|
play,
|
||||||
|
pause,
|
||||||
|
seek,
|
||||||
|
changeSpeed,
|
||||||
|
};
|
||||||
|
}
|
||||||
@ -1,15 +1,17 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { LockState } from '@meta2d/core';
|
import { LockState } from '@meta2d/core';
|
||||||
import { message } from 'ant-design-vue';
|
import { message } from 'ant-design-vue';
|
||||||
|
import type { Dayjs } from 'dayjs';
|
||||||
import { isNil } from 'lodash-es';
|
import { isNil } from 'lodash-es';
|
||||||
import { computed, onMounted, onUnmounted, provide, ref, type ShallowRef, shallowRef, watch } from 'vue';
|
import { computed, onMounted, onUnmounted, provide, ref, type ShallowRef, shallowRef, watch } from 'vue';
|
||||||
import { useRoute } from 'vue-router';
|
import { useRoute } from 'vue-router';
|
||||||
|
|
||||||
import type { RobotRealtimeInfo } from '../apis/robot';
|
|
||||||
import { getSceneByGroupId, getSceneById, monitorRealSceneById, monitorSceneById } from '../apis/scene';
|
import { getSceneByGroupId, getSceneById, monitorRealSceneById, monitorSceneById } from '../apis/scene';
|
||||||
import expandIcon from '../assets/icons/png/expand.png';
|
import expandIcon from '../assets/icons/png/expand.png';
|
||||||
import foldIcon from '../assets/icons/png/fold.png';
|
import foldIcon from '../assets/icons/png/fold.png';
|
||||||
import FollowViewNotification from '../components/follow-view-notification.vue';
|
import FollowViewNotification from '../components/follow-view-notification.vue';
|
||||||
|
import PlaybackController from '../components/PlaybackController.vue';
|
||||||
|
import { usePlaybackWebSocket } from '../hooks/usePlaybackWebSocket';
|
||||||
import { autoDoorSimulationService, type AutoDoorWebSocketData } from '../services/auto-door-simulation.service';
|
import { autoDoorSimulationService, type AutoDoorWebSocketData } from '../services/auto-door-simulation.service';
|
||||||
import {
|
import {
|
||||||
type ContextMenuState,
|
type ContextMenuState,
|
||||||
@ -58,6 +60,44 @@ const client = shallowRef<WebSocket>();
|
|||||||
// 左侧边栏元素引用(用于 Ctrl/Cmd+F 聚焦搜索框)
|
// 左侧边栏元素引用(用于 Ctrl/Cmd+F 聚焦搜索框)
|
||||||
const leftSiderEl = shallowRef<HTMLElement>();
|
const leftSiderEl = shallowRef<HTMLElement>();
|
||||||
|
|
||||||
|
//#region Playback Mode
|
||||||
|
const mode = ref<'live' | 'playback'>('live');
|
||||||
|
const isPlaybackMode = computed(() => mode.value === 'playback');
|
||||||
|
const isPlaybackControllerVisible = ref<boolean>(true);
|
||||||
|
const selectedDate = ref<Dayjs | null>(null);
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleDateChange = (date: Dayjs) => {
|
||||||
|
if (date) {
|
||||||
|
selectedDate.value = 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;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
watch(playback.sceneJson, (newJson) => {
|
||||||
|
if (newJson) {
|
||||||
|
editor.value?.load(newJson);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
//#endregion
|
||||||
|
|
||||||
// 依赖注入
|
// 依赖注入
|
||||||
provide(EDITOR_KEY, editor);
|
provide(EDITOR_KEY, editor);
|
||||||
//#endregion
|
//#endregion
|
||||||
@ -310,6 +350,7 @@ onMounted(async () => {
|
|||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
client.value?.close();
|
client.value?.close();
|
||||||
|
playback.disconnect(); // Also disconnect playback WS on unmount
|
||||||
storageLocationService.value?.destroy();
|
storageLocationService.value?.destroy();
|
||||||
// 清理自动门点服务(清空缓冲数据)
|
// 清理自动门点服务(清空缓冲数据)
|
||||||
autoDoorSimulationService.clearBufferedData();
|
autoDoorSimulationService.clearBufferedData();
|
||||||
@ -499,6 +540,19 @@ const handleGlobalKeydown = (event: KeyboardEvent) => {
|
|||||||
<a-space align="center">
|
<a-space align="center">
|
||||||
<a-button @click="backToCards"> 返回 </a-button>
|
<a-button @click="backToCards"> 返回 </a-button>
|
||||||
<a-button type="primary" :loading="isSaving" @click="handleSaveViewState"> 保存比例 </a-button>
|
<a-button type="primary" :loading="isSaving" @click="handleSaveViewState"> 保存比例 </a-button>
|
||||||
|
<a-divider type="vertical" />
|
||||||
|
|
||||||
|
<a-radio-group v-model:value="mode" button-style="solid">
|
||||||
|
<a-radio-button value="live">实时监控</a-radio-button>
|
||||||
|
<a-radio-button value="playback">历史回放</a-radio-button>
|
||||||
|
</a-radio-group>
|
||||||
|
|
||||||
|
<a-date-picker
|
||||||
|
v-if="isPlaybackMode"
|
||||||
|
:value="selectedDate"
|
||||||
|
@change="handleDateChange"
|
||||||
|
placeholder="请选择回放日期"
|
||||||
|
/>
|
||||||
</a-space>
|
</a-space>
|
||||||
</a-flex>
|
</a-flex>
|
||||||
</a-layout-header>
|
</a-layout-header>
|
||||||
@ -568,6 +622,29 @@ const handleGlobalKeydown = (event: KeyboardEvent) => {
|
|||||||
@close="handleCloseContextMenu"
|
@close="handleCloseContextMenu"
|
||||||
@action-complete="handleActionComplete"
|
@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"
|
||||||
|
@play="playback.play"
|
||||||
|
@pause="playback.pause"
|
||||||
|
@seek="(time) => playback.seek(time + (selectedDate?.startOf('day').valueOf() ?? 0))"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped lang="scss">
|
<style scoped lang="scss">
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user