feat(monitor): add playback management flow

This commit is contained in:
zhaowei.huang
2026-06-08 18:16:43 +08:00
parent 3dd08e28e5
commit 57b97a0bfe
3 changed files with 326 additions and 42 deletions
@@ -19,6 +19,8 @@ export type DisplayKey = 'labels' | 'primitives' | 'cars'
export interface ToolbarRecordingEntry {
fileName: string
/** 用户可编辑的显示名(旁挂 .meta.json,决策 A-方案2);为空时回退到 fileName。 */
displayName: string
fileSizeBytes: number
fileWriteTime: string
note: string
@@ -78,7 +78,8 @@
</template>
<script setup lang="ts">
import { computed, onMounted, onUnmounted, ref } from 'vue'
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import Workspace3D from '@/components/Workspace3D.vue'
import VehicleMonitorPanel from '@/components/workbench/VehicleMonitorPanel.vue'
import MissionListPanel from '@/components/workbench/MissionListPanel.vue'
@@ -98,6 +99,7 @@ import type { Mission } from '@/types/mission'
import type { DeliveryTask } from '@/types/delivery'
import type { SelectedObjectRef } from '@/types/workbench'
import { fetchMonitorConfigCached, invalidateMonitorConfigCache } from '@/utils/monitorConfigCache'
import type { MapFocusKind } from '@/utils/mapObjectFocus'
defineProps<{
/** 只读模式(运营端复用 MapMonitorView 时传 true):3D 不可编辑,选中信息面板动作改用运维白名单。 */
@@ -105,6 +107,8 @@ defineProps<{
}>()
const auth = useAuthStore()
const route = useRoute()
const router = useRouter()
const vrHost = (import.meta.env.VITE_VRENDER_HOST as string | undefined) ?? 'localhost:8223'
const cars = ref<Car[]>([])
@@ -117,6 +121,10 @@ const refreshing = ref(false)
const workbenchTab = ref<'vehicle' | 'mission'>('vehicle')
const selectedDeliveryId = ref<number | null>(null)
const workspaceRef = ref<InstanceType<typeof Workspace3D> | null>(null)
const workspaceReady = ref(false)
const pendingMapFocus = ref<{ kind: MapFocusKind; id: number } | null>(null)
// 调度回放:PlaybackView「查看」跳转携带 ?playback=<fileName>,待 webVRender 就绪后触发回放。
const pendingPlayback = ref<string | null>(null)
/**
* 浮动报警:订阅后端 AlarmStreamService 推送的 `alarm` 事件,
@@ -323,8 +331,97 @@ function onSelect(names: string[]) {
void fallbackSelectionFromBackend()
}
function pullFocusFromRoute(): boolean {
const kind = route.query.focusKind
const idRaw = route.query.focusId
if (typeof kind !== 'string' || typeof idRaw !== 'string') return false
if (kind !== 'car' && kind !== 'site' && kind !== 'track' && kind !== 'special') return false
const id = Number(idRaw)
if (!Number.isFinite(id)) return false
pendingMapFocus.value = { kind, id }
const q = { ...route.query }
delete q.focusKind
delete q.focusId
void router.replace({ path: route.path, query: q })
return true
}
async function applyPendingMapFocus() {
const focus = pendingMapFocus.value
if (!focus || !workspaceReady.value) return
pendingMapFocus.value = null
const { kind, id } = focus
if (kind === 'car') {
const car = cars.value.find((c) => detailIdForCar(c) === String(id) || c.rawId === id)
selection.value = car
? { kind: 'vehicle', id: detailIdForCar(car), name: car.name }
: { kind: 'vehicle', id: String(id), name: `Vehicle ${id}` }
await selectAndLocateVehicle(id)
return
}
if (kind === 'site' || kind === 'track') {
applySelectionFromKindId(kind, id)
try {
await reflectionApi.setSelection(kind, id)
} catch (err) {
ElMessage.error({ message: `定位对象失败:${(err as Error).message}`, grouping: true })
}
return
}
try {
await reflectionApi.setSelection('special', id)
selection.value = { kind: 'special', id: String(id), name: `装饰物 ${id}` }
} catch (err) {
ElMessage.error({ message: `定位对象失败:${(err as Error).message}`, grouping: true })
}
}
function scheduleMapFocusFromRoute() {
if (!pullFocusFromRoute()) return
void applyPendingMapFocus()
}
/**
* 读取 ?playback=<fileName>:暂存待回放文件名并从 URL 移除(避免刷新重复触发),
* 真正的 startPlayback 在 webVRender iframe 就绪后由 applyPendingPlayback 执行。
*/
function pullPlaybackFromRoute(): boolean {
const fileName = route.query.playback
if (typeof fileName !== 'string' || !fileName) return false
pendingPlayback.value = fileName
const q = { ...route.query }
delete q.playback
void router.replace({ path: route.path, query: q })
return true
}
async function applyPendingPlayback() {
const fileName = pendingPlayback.value
if (!fileName || !workspaceReady.value) return
pendingPlayback.value = null
try {
// iframe 终端就绪后再启动:SimpleLite StartPlayback 会把回放控制条投影到该 webVRender 终端。
await workspaceToolbarApi.startPlayback(fileName)
ElMessage.success({ message: `已开始回放:${fileName}`, grouping: true })
} catch (err) {
ElMessage.error({ message: `启动回放失败:${(err as Error).message}`, grouping: true })
}
}
function schedulePlaybackFromRoute() {
if (!pullPlaybackFromRoute()) return
void applyPendingPlayback()
}
function onWorkspaceReady() {
workspaceReady.value = true
void fallbackSelectionFromBackend()
void applyPendingMapFocus()
void applyPendingPlayback()
}
async function fallbackSelectionFromBackend() {
@@ -479,9 +576,22 @@ function onStreamEvent(e: StreamEvent) {
}
stream.on(onStreamEvent)
watch(
() => [route.query.focusKind, route.query.focusId] as const,
() => scheduleMapFocusFromRoute()
)
watch(
() => route.query.playback,
() => schedulePlaybackFromRoute()
)
onMounted(async () => {
scheduleMapFocusFromRoute()
schedulePlaybackFromRoute()
void fetchMonitorConfigCached()
await refreshAll()
void applyPendingMapFocus()
pollTimer = setInterval(() => {
// SSE 已连但 projection/cars 曾 502 时 connected 仍为 true,需在车列表为空时继续轮询
if (!stream.connected.value || cars.value.length === 0) {
@@ -1,38 +1,70 @@
<template>
<el-card shadow="never">
<template #header><span>调度回放PlaybackPolicy.retentionDays={{ retention }} </span></template>
<el-row :gutter="12">
<el-col :span="6">
<el-input v-model="filter.kw" placeholder="按任务 / 车辆检索" clearable />
</el-col>
<el-col :span="8">
<el-date-picker
v-model="filter.range"
type="datetimerange"
range-separator=""
start-placeholder="开始"
end-placeholder="结束"
style="width: 100%" />
</el-col>
<el-col :span="10">
<el-button type="primary">检索快照</el-button>
<el-button>下载日志</el-button>
<el-button :icon="VideoPlay" plain>回放选中</el-button>
</el-col>
</el-row>
<el-card shadow="never" class="playback-card">
<template #header>
<div class="pb-header">
<div class="pb-title">
<span>调度回放</span>
<el-tag v-if="isPlaying" type="success" size="small" effect="dark" class="pb-playing">回放中</el-tag>
</div>
<div class="pb-actions">
<el-input
v-model="keyword"
:prefix-icon="Search"
placeholder="按名称 / 文件名 / 备注检索"
clearable
size="small"
class="pb-search" />
<el-button
v-if="isPlaying"
size="small"
type="warning"
plain
:loading="stopping"
@click="onStopPlayback">
停止回放
</el-button>
<el-button size="small" :icon="Refresh" :loading="loading" @click="load">刷新</el-button>
</div>
</div>
</template>
<el-divider />
<p v-if="recordingsDir" class="pb-dir">录像目录{{ recordingsDir }}</p>
<el-table :data="rows" stripe>
<el-table-column prop="id" label="ID" width="100" />
<el-table-column prop="ts" label="时间" width="180" />
<el-table-column prop="type" label="类型" width="140" />
<el-table-column prop="summary" label="概要" />
<el-table-column prop="size" label="大小" width="100" />
<el-table-column label="操作" width="160">
<template #default>
<el-button text size="small">回放</el-button>
<el-button text size="small">下载</el-button>
<el-table :data="rows" v-loading="loading" stripe empty-text="暂无录像。在地图监控画布底栏「录制」后即可在此管理回放。">
<el-table-column label="名称" min-width="220">
<template #default="{ row }">
<div class="pb-name">
<span class="pb-name-text">{{ nameOf(row) }}</span>
<el-tag v-if="!row.headerReadable" type="danger" size="small" effect="plain">头部不可读</el-tag>
</div>
<div v-if="row.displayName && row.displayName.trim()" class="pb-filename">{{ row.fileName }}</div>
</template>
</el-table-column>
<el-table-column label="录制时间" width="180">
<template #default="{ row }">{{ formatTime(row.fileWriteTime) }}</template>
</el-table-column>
<el-table-column label="时长" width="110" align="right">
<template #default="{ row }">{{ formatDuration(row.durationMs) }}</template>
</el-table-column>
<el-table-column label="帧数" width="90" align="right">
<template #default="{ row }">{{ row.frameCount || '-' }}</template>
</el-table-column>
<el-table-column label="大小" width="100" align="right">
<template #default="{ row }">{{ formatSize(row.fileSizeBytes) }}</template>
</el-table-column>
<el-table-column prop="note" label="备注" min-width="140" show-overflow-tooltip />
<el-table-column label="操作" width="220" fixed="right">
<template #default="{ row }">
<el-button
text
size="small"
type="primary"
:icon="VideoPlay"
:disabled="!row.headerReadable"
@click="onView(row)">
查看
</el-button>
<el-button text size="small" type="danger" :icon="Delete" @click="onDelete(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
@@ -40,15 +72,155 @@
</template>
<script setup lang="ts">
import { reactive, ref } from 'vue'
import { VideoPlay } from '@element-plus/icons-vue'
import { computed, onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import { VideoPlay, Delete, Refresh, Search } from '@element-plus/icons-vue'
import { workspaceToolbarApi, type ToolbarRecordingEntry } from '@/api/workspaceToolbar'
const retention = 30
const filter = reactive<{ kw: string; range: [Date, Date] | null }>({ kw: '', range: null })
const router = useRouter()
const rows = ref([
{ id: 'SNAP-001', ts: '2026-05-19 10:21:33', type: '调度异常', summary: 'AGV-005 上线超时 → 故障', size: '128 KB' },
{ id: 'SNAP-002', ts: '2026-05-19 14:05:17', type: '路口死锁', summary: '路口-N 等待链 3 节点 30s', size: '64 KB' },
{ id: 'SNAP-003', ts: '2026-05-20 09:11:02', type: '手动快照', summary: '用户 admin 触发 SnapshotExport', size: '420 KB' }
])
const loading = ref(false)
const stopping = ref(false)
const entries = ref<ToolbarRecordingEntry[]>([])
const recordingsDir = ref('')
const isPlaying = ref(false)
const keyword = ref('')
const rows = computed(() => {
const kw = keyword.value.trim().toLowerCase()
if (!kw) return entries.value
return entries.value.filter(
(e) =>
(e.displayName || '').toLowerCase().includes(kw) ||
e.fileName.toLowerCase().includes(kw) ||
(e.note || '').toLowerCase().includes(kw)
)
})
function nameOf(e: ToolbarRecordingEntry): string {
return e.displayName && e.displayName.trim() ? e.displayName.trim() : e.fileName
}
function formatTime(s: string): string {
if (!s) return '-'
const d = new Date(s)
return Number.isNaN(d.getTime()) ? s : d.toLocaleString()
}
function formatDuration(ms: number): string {
if (!ms || ms < 0) return '-'
const total = Math.round(ms / 1000)
const h = Math.floor(total / 3600)
const m = Math.floor((total % 3600) / 60)
const s = total % 60
const pad = (n: number) => String(n).padStart(2, '0')
return h > 0 ? `${pad(h)}:${pad(m)}:${pad(s)}` : `${pad(m)}:${pad(s)}`
}
function formatSize(bytes: number): string {
if (!bytes || bytes < 0) return '-'
if (bytes < 1024) return `${bytes} B`
const kb = bytes / 1024
if (kb < 1024) return `${kb < 10 ? kb.toFixed(1) : kb.toFixed(0)} KB`
return `${(kb / 1024).toFixed(1)} MB`
}
async function load() {
loading.value = true
try {
const state = await workspaceToolbarApi.getState()
entries.value = state.recording?.entries ?? []
recordingsDir.value = state.recording?.recordingsDirectory ?? ''
isPlaying.value = !!state.recording?.isPlaying
} catch (e) {
entries.value = []
ElMessage.error({ message: (e as Error).message || '加载录像列表失败', grouping: true })
} finally {
loading.value = false
}
}
function onView(row: ToolbarRecordingEntry) {
// 跳转地图监控并通过 ?playback= 触发回放;地图监控页在 webVRender iframe 就绪后调用
// workspaceToolbarApi.startPlaybackSimpleLite 随即在该 iframe 终端弹出回放控制条。
router.push({ path: '/admin/map-monitor', query: { playback: row.fileName } })
}
async function onDelete(row: ToolbarRecordingEntry) {
try {
await ElMessageBox.confirm(`确认删除录像「${nameOf(row)}」?此操作不可恢复。`, '删除录像', {
type: 'warning',
confirmButtonText: '删除',
cancelButtonText: '取消'
})
await workspaceToolbarApi.deleteRecording(row.fileName)
ElMessage.success('已删除')
await load()
} catch (e) {
if (e === 'cancel' || e === 'close') return
ElMessage.error(`删除失败:${(e as Error).message}`)
}
}
async function onStopPlayback() {
stopping.value = true
try {
await workspaceToolbarApi.stopPlayback()
ElMessage.success('已停止回放')
await load()
} catch (e) {
ElMessage.error(`停止回放失败:${(e as Error).message}`)
} finally {
stopping.value = false
}
}
onMounted(load)
</script>
<style scoped>
.playback-card {
height: 100%;
}
.pb-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
}
.pb-title {
display: flex;
align-items: center;
gap: 8px;
}
.pb-playing {
font-weight: normal;
}
.pb-actions {
display: flex;
align-items: center;
gap: 8px;
}
.pb-search {
width: 240px;
}
.pb-dir {
margin: 0 0 12px;
font-size: 12px;
color: var(--mg-text-muted, #909399);
}
.pb-name {
display: flex;
align-items: center;
gap: 8px;
}
.pb-name-text {
font-weight: 500;
}
.pb-filename {
font-size: 12px;
color: var(--mg-text-muted, #909399);
}
</style>