feat(workspace): 画布 2D/3D 切换与车辆相机跟随
- workspaceToolbar API 新增 view/toggle 与 camera/follow - 画布底栏新增 2D/3D 切换、自动跟随按钮,组件卸载时自动停止跟随 - 地图监控页将选中车辆 id 透传给底栏,并接入监控配置缓存 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -45,12 +45,21 @@ export interface ToolbarLayer {
|
|||||||
visible: boolean
|
visible: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ToolbarViewState {
|
||||||
|
map2D: boolean
|
||||||
|
follow: {
|
||||||
|
enabled: boolean
|
||||||
|
carId: number | null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export interface ToolbarState {
|
export interface ToolbarState {
|
||||||
align: { sites: boolean; cars: boolean; tracks: boolean }
|
align: { sites: boolean; cars: boolean; tracks: boolean }
|
||||||
select: { tracks: boolean; cars: boolean; decor: boolean; sites: boolean }
|
select: { tracks: boolean; cars: boolean; decor: boolean; sites: boolean }
|
||||||
display: { labels: boolean; primitives: boolean; cars: boolean }
|
display: { labels: boolean; primitives: boolean; cars: boolean }
|
||||||
layers: ToolbarLayer[]
|
layers: ToolbarLayer[]
|
||||||
recording: ToolbarRecordingState
|
recording: ToolbarRecordingState
|
||||||
|
view?: ToolbarViewState
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Envelope<T> {
|
interface Envelope<T> {
|
||||||
@@ -109,5 +118,17 @@ export const workspaceToolbarApi = {
|
|||||||
startPlayback: (fileName: string) =>
|
startPlayback: (fileName: string) =>
|
||||||
unwrap<ToolbarState>(http.post(`${BASE}/playback/start`, compatFilePayload(fileName))),
|
unwrap<ToolbarState>(http.post(`${BASE}/playback/start`, compatFilePayload(fileName))),
|
||||||
|
|
||||||
stopPlayback: () => unwrap<ToolbarState>(http.post(`${BASE}/playback/stop`))
|
stopPlayback: () => unwrap<ToolbarState>(http.post(`${BASE}/playback/stop`)),
|
||||||
|
|
||||||
|
toggleViewMode: () => unwrap<ToolbarState>(http.post(`${BASE}/view/toggle`)),
|
||||||
|
|
||||||
|
setCameraFollow: (carId: number | null, enabled: boolean) =>
|
||||||
|
unwrap<ToolbarState>(
|
||||||
|
http.post(`${BASE}/camera/follow`, {
|
||||||
|
carId: carId ?? undefined,
|
||||||
|
enabled,
|
||||||
|
CarId: carId ?? undefined,
|
||||||
|
Enabled: enabled
|
||||||
|
})
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
+84
-3
@@ -123,6 +123,29 @@
|
|||||||
<span class="btn-label">录像管理</span>
|
<span class="btn-label">录像管理</span>
|
||||||
</el-button>
|
</el-button>
|
||||||
|
|
||||||
|
<!-- 2D / 3D 视图切换 -->
|
||||||
|
<el-button
|
||||||
|
size="small"
|
||||||
|
:type="view2D ? 'primary' : 'default'"
|
||||||
|
:loading="busy.view"
|
||||||
|
@click="toggleViewMode"
|
||||||
|
>
|
||||||
|
<el-icon><View /></el-icon>
|
||||||
|
<span class="btn-label">{{ view2D ? '2D' : '3D' }}</span>
|
||||||
|
</el-button>
|
||||||
|
|
||||||
|
<!-- 车辆自动跟随(需先选中车辆) -->
|
||||||
|
<el-button
|
||||||
|
size="small"
|
||||||
|
:type="cameraFollowing ? 'warning' : 'default'"
|
||||||
|
:disabled="!cameraFollowing && followCarId == null"
|
||||||
|
:loading="busy.follow"
|
||||||
|
@click="toggleCameraFollow"
|
||||||
|
>
|
||||||
|
<el-icon><Aim /></el-icon>
|
||||||
|
<span class="btn-label">{{ cameraFollowing ? '停止跟随' : '自动跟随' }}</span>
|
||||||
|
</el-button>
|
||||||
|
|
||||||
<!-- 选择录像(回放)对话框 -->
|
<!-- 选择录像(回放)对话框 -->
|
||||||
<el-dialog
|
<el-dialog
|
||||||
v-model="playDialogVisible"
|
v-model="playDialogVisible"
|
||||||
@@ -207,7 +230,8 @@ import {
|
|||||||
CircleClose,
|
CircleClose,
|
||||||
Document,
|
Document,
|
||||||
ArrowUp,
|
ArrowUp,
|
||||||
FirstAidKit
|
FirstAidKit,
|
||||||
|
Aim
|
||||||
} from '@element-plus/icons-vue'
|
} from '@element-plus/icons-vue'
|
||||||
import {
|
import {
|
||||||
workspaceToolbarApi,
|
workspaceToolbarApi,
|
||||||
@@ -217,6 +241,14 @@ import {
|
|||||||
type ToolbarState
|
type ToolbarState
|
||||||
} from '@/api/workspaceToolbar'
|
} from '@/api/workspaceToolbar'
|
||||||
|
|
||||||
|
const props = withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
/** 当前选中的车辆 numeric id(地图监控侧栏/画布选中车辆时传入) */
|
||||||
|
followCarId?: number | null
|
||||||
|
}>(),
|
||||||
|
{ followCarId: null }
|
||||||
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 平台 iframe 画布工具栏(对应 SimpleLite `Panel_4` / `WorkspaceBottomBar.DefineForTerminalEmbedMinimal`)。
|
* 平台 iframe 画布工具栏(对应 SimpleLite `Panel_4` / `WorkspaceBottomBar.DefineForTerminalEmbedMinimal`)。
|
||||||
*
|
*
|
||||||
@@ -253,7 +285,9 @@ const busy = reactive({
|
|||||||
display: false,
|
display: false,
|
||||||
layer: false,
|
layer: false,
|
||||||
recording: false,
|
recording: false,
|
||||||
playback: false
|
playback: false,
|
||||||
|
view: false,
|
||||||
|
follow: false
|
||||||
})
|
})
|
||||||
|
|
||||||
const playDialogVisible = ref(false)
|
const playDialogVisible = ref(false)
|
||||||
@@ -270,6 +304,17 @@ const recordLabel = computed(() => {
|
|||||||
return `停止 ${mm}:${ss}`
|
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() {
|
async function refresh() {
|
||||||
try {
|
try {
|
||||||
state.value = await workspaceToolbarApi.getState()
|
state.value = await workspaceToolbarApi.getState()
|
||||||
@@ -401,6 +446,39 @@ async function openManageDialog() {
|
|||||||
manageDialogVisible.value = true
|
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 {
|
function formatSize(bytes: number): string {
|
||||||
if (!Number.isFinite(bytes) || bytes <= 0) return '0 B'
|
if (!Number.isFinite(bytes) || bytes <= 0) return '0 B'
|
||||||
if (bytes < 1024) return `${bytes} B`
|
if (bytes < 1024) return `${bytes} B`
|
||||||
@@ -430,12 +508,15 @@ onMounted(() => {
|
|||||||
void refresh()
|
void refresh()
|
||||||
pollTimer = setInterval(() => {
|
pollTimer = setInterval(() => {
|
||||||
const r = state.value?.recording
|
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)
|
}, 1000)
|
||||||
})
|
})
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
if (pollTimer) clearInterval(pollTimer)
|
if (pollTimer) clearInterval(pollTimer)
|
||||||
|
if (cameraFollowing.value) {
|
||||||
|
void workspaceToolbarApi.setCameraFollow(null, false).catch(() => {})
|
||||||
|
}
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -29,7 +29,7 @@
|
|||||||
@ready="onWorkspaceReady"
|
@ready="onWorkspaceReady"
|
||||||
/>
|
/>
|
||||||
<FloatingAlarmStack :alarms="alarms" @locate="onAlarmLocate" @ack="onAlarmAck" />
|
<FloatingAlarmStack :alarms="alarms" @locate="onAlarmLocate" @ack="onAlarmAck" />
|
||||||
<WorkspaceCanvasToolbar />
|
<WorkspaceCanvasToolbar :follow-car-id="selectedFollowCarId" />
|
||||||
</div>
|
</div>
|
||||||
</el-col>
|
</el-col>
|
||||||
<el-col :span="8" class="side-col">
|
<el-col :span="8" class="side-col">
|
||||||
@@ -95,6 +95,7 @@ import type { Car } from '@/types/car'
|
|||||||
import type { Mission } from '@/types/mission'
|
import type { Mission } from '@/types/mission'
|
||||||
import type { DeliveryTask } from '@/types/delivery'
|
import type { DeliveryTask } from '@/types/delivery'
|
||||||
import type { SelectedObjectRef } from '@/types/workbench'
|
import type { SelectedObjectRef } from '@/types/workbench'
|
||||||
|
import { fetchMonitorConfigCached, invalidateMonitorConfigCache } from '@/utils/monitorConfigCache'
|
||||||
|
|
||||||
defineProps<{
|
defineProps<{
|
||||||
/** 只读模式(运营端复用 MapMonitorView 时传 true):3D 不可编辑,选中信息面板动作改用运维白名单。 */
|
/** 只读模式(运营端复用 MapMonitorView 时传 true):3D 不可编辑,选中信息面板动作改用运维白名单。 */
|
||||||
@@ -167,6 +168,21 @@ const selectedVehicleId = computed(() => {
|
|||||||
return match?.id ?? null
|
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 选中对象驱动 */ }
|
function onPick(_p: { x: number; y: number }) { /* 坐标由 3D 选中对象驱动 */ }
|
||||||
|
|
||||||
// SimpleLite 3D 端发送的对象命名规则(见 SimpleUI.cs / UISite.cs / TrackUiHelper):
|
// 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
|
if (sameSelection(selection.value, next)) return
|
||||||
selection.value = next
|
selection.value = next
|
||||||
tick.value++
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function applySelectionFromKindId(kind: 'site' | 'track' | 'car', id: number, names?: readonly string[]) {
|
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}` }
|
: { kind: 'vehicle' as const, id: sid, name: `Vehicle ${sid}` }
|
||||||
if (!sameSelection(selection.value, next)) {
|
if (!sameSelection(selection.value, next)) {
|
||||||
selection.value = next
|
selection.value = next
|
||||||
tick.value++
|
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -250,7 +264,6 @@ function applySelectionFromKindId(kind: 'site' | 'track' | 'car', id: number, na
|
|||||||
}
|
}
|
||||||
if (!sameSelection(selection.value, next)) {
|
if (!sameSelection(selection.value, next)) {
|
||||||
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
|
const nameN = Array.isArray(e.names) ? e.names.length : 0
|
||||||
if (siteN + trackN + carN + nameN === 0) {
|
if (siteN + trackN + carN + nameN === 0) {
|
||||||
selection.value = null
|
selection.value = null
|
||||||
tick.value++
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -333,7 +345,6 @@ async function onVehicleSelect(ref: SelectedObjectRef) {
|
|||||||
selectedDeliveryId.value = null
|
selectedDeliveryId.value = null
|
||||||
if (!sameSelection(selection.value, ref)) {
|
if (!sameSelection(selection.value, ref)) {
|
||||||
selection.value = ref
|
selection.value = ref
|
||||||
tick.value++
|
|
||||||
}
|
}
|
||||||
const id = Number(ref.id)
|
const id = Number(ref.id)
|
||||||
if (!Number.isFinite(id)) return
|
if (!Number.isFinite(id)) return
|
||||||
@@ -359,7 +370,6 @@ async function onDeliverySelect(task: DeliveryTask) {
|
|||||||
id: String(task.carId),
|
id: String(task.carId),
|
||||||
name: task.carName ?? `Vehicle ${task.carId}`
|
name: task.carName ?? `Vehicle ${task.carId}`
|
||||||
}
|
}
|
||||||
tick.value++
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
ElMessage.error(`同步 3D 选中失败:${(err as Error).message}`)
|
ElMessage.error(`同步 3D 选中失败:${(err as Error).message}`)
|
||||||
}
|
}
|
||||||
@@ -370,7 +380,6 @@ async function onDeliverySelect(task: DeliveryTask) {
|
|||||||
id: String(task.id),
|
id: String(task.id),
|
||||||
name: `${task.srcLabel} → ${task.dstLabel}`
|
name: `${task.srcLabel} → ${task.dstLabel}`
|
||||||
}
|
}
|
||||||
tick.value++
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function onDeliveryListRefresh(opts: { includeFinished: boolean; includeAborted: boolean }) {
|
function onDeliveryListRefresh(opts: { includeFinished: boolean; includeAborted: boolean }) {
|
||||||
@@ -416,7 +425,6 @@ async function refreshAll() {
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.warn('[MapMonitor] refreshAll failed', err)
|
console.warn('[MapMonitor] refreshAll failed', err)
|
||||||
} finally {
|
} finally {
|
||||||
tick.value++
|
|
||||||
refreshing.value = false
|
refreshing.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -456,7 +464,8 @@ function onStreamEvent(e: StreamEvent) {
|
|||||||
}
|
}
|
||||||
break
|
break
|
||||||
case 'monitor-config-updated':
|
case 'monitor-config-updated':
|
||||||
// 运营维护保存后 SimpleLite 广播,刷新选中面板动作列表
|
// 运营维护保存后刷新动作白名单(勿在 refreshAll 里 tick++,避免轮询导致选中面板反复重载)
|
||||||
|
invalidateMonitorConfigCache()
|
||||||
tick.value++
|
tick.value++
|
||||||
break
|
break
|
||||||
// 'snapshot-tick' 故意不触发 refreshAll —— 见上面注释。
|
// 'snapshot-tick' 故意不触发 refreshAll —— 见上面注释。
|
||||||
@@ -465,6 +474,7 @@ function onStreamEvent(e: StreamEvent) {
|
|||||||
stream.on(onStreamEvent)
|
stream.on(onStreamEvent)
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
|
void fetchMonitorConfigCached()
|
||||||
await refreshAll()
|
await refreshAll()
|
||||||
pollTimer = setInterval(() => {
|
pollTimer = setInterval(() => {
|
||||||
// SSE 已连但 projection/cars 曾 502 时 connected 仍为 true,需在车列表为空时继续轮询
|
// SSE 已连但 projection/cars 曾 502 时 connected 仍为 true,需在车列表为空时继续轮询
|
||||||
@@ -492,14 +502,29 @@ onUnmounted(() => {
|
|||||||
.kpi-row { flex: none; }
|
.kpi-row { flex: none; }
|
||||||
.main-row { flex: 1; min-height: 0; }
|
.main-row { flex: 1; min-height: 0; }
|
||||||
.main-row > .el-col { display: flex; flex-direction: column; 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 {
|
.side-card {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
min-height: 0;
|
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),
|
.workbench-card :deep(.el-card__body),
|
||||||
.detail-card :deep(.el-card__body) {
|
.detail-card :deep(.el-card__body) {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
|
|||||||
Reference in New Issue
Block a user