diff --git a/frontends/apps/simple-platform-vue/src/api/workspaceToolbar.ts b/frontends/apps/simple-platform-vue/src/api/workspaceToolbar.ts index 97e5935..9ead917 100644 --- a/frontends/apps/simple-platform-vue/src/api/workspaceToolbar.ts +++ b/frontends/apps/simple-platform-vue/src/api/workspaceToolbar.ts @@ -45,12 +45,21 @@ export interface ToolbarLayer { visible: boolean } +export interface ToolbarViewState { + map2D: boolean + follow: { + enabled: boolean + carId: number | null + } +} + export interface ToolbarState { align: { sites: boolean; cars: boolean; tracks: boolean } select: { tracks: boolean; cars: boolean; decor: boolean; sites: boolean } display: { labels: boolean; primitives: boolean; cars: boolean } layers: ToolbarLayer[] recording: ToolbarRecordingState + view?: ToolbarViewState } interface Envelope { @@ -109,5 +118,17 @@ export const workspaceToolbarApi = { startPlayback: (fileName: string) => unwrap(http.post(`${BASE}/playback/start`, compatFilePayload(fileName))), - stopPlayback: () => unwrap(http.post(`${BASE}/playback/stop`)) + stopPlayback: () => unwrap(http.post(`${BASE}/playback/stop`)), + + toggleViewMode: () => unwrap(http.post(`${BASE}/view/toggle`)), + + setCameraFollow: (carId: number | null, enabled: boolean) => + unwrap( + http.post(`${BASE}/camera/follow`, { + carId: carId ?? undefined, + enabled, + CarId: carId ?? undefined, + Enabled: enabled + }) + ) } diff --git a/frontends/apps/simple-platform-vue/src/components/workspace/WorkspaceCanvasToolbar.vue b/frontends/apps/simple-platform-vue/src/components/workspace/WorkspaceCanvasToolbar.vue index 4702e77..f1b61de 100644 --- a/frontends/apps/simple-platform-vue/src/components/workspace/WorkspaceCanvasToolbar.vue +++ b/frontends/apps/simple-platform-vue/src/components/workspace/WorkspaceCanvasToolbar.vue @@ -123,6 +123,29 @@ 录像管理 + + + + {{ view2D ? '2D' : '3D' }} + + + + + + {{ cameraFollowing ? '停止跟随' : '自动跟随' }} + + (), + { followCarId: null } +) + /** * 平台 iframe 画布工具栏(对应 SimpleLite `Panel_4` / `WorkspaceBottomBar.DefineForTerminalEmbedMinimal`)。 * @@ -253,7 +285,9 @@ const busy = reactive({ display: false, layer: false, recording: false, - playback: false + playback: false, + view: false, + follow: false }) const playDialogVisible = ref(false) @@ -270,6 +304,17 @@ const recordLabel = computed(() => { return `停止 ${mm}:${ss}` }) +const view2D = computed(() => state.value?.view?.map2D ?? true) + +const cameraFollowing = computed(() => state.value?.view?.follow?.enabled ?? false) + +const followCarId = computed(() => { + const fromProp = props.followCarId + if (fromProp != null && Number.isFinite(fromProp)) return fromProp + const fromState = state.value?.view?.follow?.carId + return fromState != null && Number.isFinite(fromState) ? fromState : null +}) + async function refresh() { try { state.value = await workspaceToolbarApi.getState() @@ -401,6 +446,39 @@ async function openManageDialog() { manageDialogVisible.value = true } +async function toggleViewMode() { + busy.view = true + try { + state.value = await workspaceToolbarApi.toggleViewMode() + } catch (err) { + ElMessage.error(`切换视图失败:${(err as Error).message}`) + } finally { + busy.view = false + } +} + +async function toggleCameraFollow() { + const carId = followCarId.value + if (!cameraFollowing.value && (carId == null || !Number.isFinite(carId))) { + ElMessage.warning('请先在车辆列表或 3D 画布中选中一辆车') + return + } + busy.follow = true + try { + const enable = !cameraFollowing.value + state.value = await workspaceToolbarApi.setCameraFollow(carId, enable) + if (enable) { + ElMessage.success({ message: `已开始跟随车辆 #${carId}`, duration: 1500, grouping: true }) + } else { + ElMessage.info('已停止自动跟随') + } + } catch (err) { + ElMessage.error(`自动跟随失败:${(err as Error).message}`) + } finally { + busy.follow = false + } +} + function formatSize(bytes: number): string { if (!Number.isFinite(bytes) || bytes <= 0) return '0 B' if (bytes < 1024) return `${bytes} B` @@ -430,12 +508,15 @@ onMounted(() => { void refresh() pollTimer = setInterval(() => { const r = state.value?.recording - if (r?.isRecording || r?.isPlaying) void refresh() + if (r?.isRecording || r?.isPlaying || state.value?.view?.follow?.enabled) void refresh() }, 1000) }) onBeforeUnmount(() => { if (pollTimer) clearInterval(pollTimer) + if (cameraFollowing.value) { + void workspaceToolbarApi.setCameraFollow(null, false).catch(() => {}) + } }) diff --git a/frontends/apps/simple-platform-vue/src/views/admin/MapMonitorView.vue b/frontends/apps/simple-platform-vue/src/views/admin/MapMonitorView.vue index 709e1a3..31cb674 100644 --- a/frontends/apps/simple-platform-vue/src/views/admin/MapMonitorView.vue +++ b/frontends/apps/simple-platform-vue/src/views/admin/MapMonitorView.vue @@ -29,7 +29,7 @@ @ready="onWorkspaceReady" /> - + @@ -95,6 +95,7 @@ import type { Car } from '@/types/car' import type { Mission } from '@/types/mission' import type { DeliveryTask } from '@/types/delivery' import type { SelectedObjectRef } from '@/types/workbench' +import { fetchMonitorConfigCached, invalidateMonitorConfigCache } from '@/utils/monitorConfigCache' defineProps<{ /** 只读模式(运营端复用 MapMonitorView 时传 true):3D 不可编辑,选中信息面板动作改用运维白名单。 */ @@ -167,6 +168,21 @@ const selectedVehicleId = computed(() => { return match?.id ?? null }) +/** 供底栏「自动跟随」使用的车辆 numeric id(与 reflection setSelection 一致)。 */ +const selectedFollowCarId = computed(() => { + if (!selection.value || selection.value.kind !== 'vehicle') return null + const sid = selection.value.id + const n = Number(sid) + if (Number.isFinite(n)) return n + const match = cars.value.find((c) => c.id === sid || detailIdForCar(c) === sid) + if (match?.rawId != null) return match.rawId + if (match) { + const parsed = Number(detailIdForCar(match)) + if (Number.isFinite(parsed)) return parsed + } + return null +}) + function onPick(_p: { x: number; y: number }) { /* 坐标由 3D 选中对象驱动 */ } // SimpleLite 3D 端发送的对象命名规则(见 SimpleUI.cs / UISite.cs / TrackUiHelper): @@ -220,7 +236,6 @@ function applyParsedSelection(parsed: { kind: 'vehicle' | 'site' | 'track'; id: } if (sameSelection(selection.value, next)) return selection.value = next - tick.value++ } function applySelectionFromKindId(kind: 'site' | 'track' | 'car', id: number, names?: readonly string[]) { @@ -234,7 +249,6 @@ function applySelectionFromKindId(kind: 'site' | 'track' | 'car', id: number, na : { kind: 'vehicle' as const, id: sid, name: `Vehicle ${sid}` } if (!sameSelection(selection.value, next)) { selection.value = next - tick.value++ } return } @@ -250,7 +264,6 @@ function applySelectionFromKindId(kind: 'site' | 'track' | 'car', id: number, na } if (!sameSelection(selection.value, next)) { selection.value = next - tick.value++ } } @@ -265,7 +278,6 @@ function onSelectionDetailFromStream(e: SelectionDetailEvent) { const nameN = Array.isArray(e.names) ? e.names.length : 0 if (siteN + trackN + carN + nameN === 0) { selection.value = null - tick.value++ return } @@ -333,7 +345,6 @@ async function onVehicleSelect(ref: SelectedObjectRef) { selectedDeliveryId.value = null if (!sameSelection(selection.value, ref)) { selection.value = ref - tick.value++ } const id = Number(ref.id) if (!Number.isFinite(id)) return @@ -359,7 +370,6 @@ async function onDeliverySelect(task: DeliveryTask) { id: String(task.carId), name: task.carName ?? `Vehicle ${task.carId}` } - tick.value++ } catch (err) { ElMessage.error(`同步 3D 选中失败:${(err as Error).message}`) } @@ -370,7 +380,6 @@ async function onDeliverySelect(task: DeliveryTask) { id: String(task.id), name: `${task.srcLabel} → ${task.dstLabel}` } - tick.value++ } function onDeliveryListRefresh(opts: { includeFinished: boolean; includeAborted: boolean }) { @@ -416,7 +425,6 @@ async function refreshAll() { } catch (err) { console.warn('[MapMonitor] refreshAll failed', err) } finally { - tick.value++ refreshing.value = false } } @@ -456,7 +464,8 @@ function onStreamEvent(e: StreamEvent) { } break case 'monitor-config-updated': - // 运营维护保存后 SimpleLite 广播,刷新选中面板动作列表 + // 运营维护保存后刷新动作白名单(勿在 refreshAll 里 tick++,避免轮询导致选中面板反复重载) + invalidateMonitorConfigCache() tick.value++ break // 'snapshot-tick' 故意不触发 refreshAll —— 见上面注释。 @@ -465,6 +474,7 @@ function onStreamEvent(e: StreamEvent) { stream.on(onStreamEvent) onMounted(async () => { + void fetchMonitorConfigCached() await refreshAll() pollTimer = setInterval(() => { // SSE 已连但 projection/cars 曾 502 时 connected 仍为 true,需在车列表为空时继续轮询 @@ -492,14 +502,29 @@ onUnmounted(() => { .kpi-row { flex: none; } .main-row { flex: 1; min-height: 0; } .main-row > .el-col { display: flex; flex-direction: column; min-height: 0; } -.side-col { gap: 12px; } +.side-col { + display: flex; + flex-direction: column; + gap: 12px; + min-height: 0; + height: 100%; +} .side-card { display: flex; flex-direction: column; min-height: 0; + overflow: hidden; +} +/* 上下各占约一半,与任务列表 tab 时一致,保证「选中信息」始终可见 */ +.workbench-card { + flex: 1 1 0; + min-height: 0; + max-height: 52%; +} +.detail-card { + flex: 1 1 0; + min-height: 200px; } -.workbench-card { flex: 1; min-height: 0; } -.detail-card { flex: 1; min-height: 0; } .workbench-card :deep(.el-card__body), .detail-card :deep(.el-card__body) { flex: 1;