feat(monitor): add playback management flow
This commit is contained in:
@@ -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.startPlayback,SimpleLite 随即在该 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>
|
||||
|
||||
Reference in New Issue
Block a user