refactor(web): 工作台与监控视图 UI 优化

- MonitorSelectionPanel / VehicleMonitorPanel / SelectionDetailPanel 等工作台面板交互与展示优化
- carActionExecute:前往站点遇 404(旧 DLL 无 goto-site 路由)时回退 WebGotoSite,并给出热更新指引
- 地图监控、任务分配、反射管理等视图细节调整;theme.css 补充样式
This commit is contained in:
zhaowei.huang
2026-05-29 23:52:42 +08:00
parent ea036760cb
commit 7877146934
18 changed files with 660 additions and 442 deletions
@@ -63,13 +63,13 @@ function run(t: CadTool) {
<style scoped>
.tool-card { cursor: pointer; text-align: center; padding: 8px; }
.tool-name { font-weight: 600; margin-top: 6px; }
.tool-desc { color: rgba(220, 200, 252, 0.55); font-size: 12px; margin-top: 4px; }
.tool-desc { color: var(--mg-text-dim); font-size: 12px; margin-top: 4px; }
.tool-icon {
color: var(--mg-accent);
filter: drop-shadow(0 0 8px rgba(var(--mg-accent-rgb), 0.55));
}
.tool-card:hover .tool-icon {
color: #fff;
color: var(--mg-text-light);
filter: drop-shadow(0 0 14px rgba(var(--mg-primary-hover-rgb), 0.8));
}
</style>
@@ -22,7 +22,7 @@
:host="vrHost"
:scope="auth.scope ?? 'Platform'"
:token="auth.token ?? ''"
:read-only="false"
:read-only="!!readOnly"
:embed-ui="true"
@pick="onPick"
@select="onSelect"
@@ -35,17 +35,31 @@
<el-col :span="8" class="side-col">
<el-card shadow="never" class="side-card workbench-card">
<template #header>
<span>车辆监控台</span>
<el-button link type="primary" size="small" :loading="refreshing" @click="refreshAll">
刷新
</el-button>
<div class="workbench-header">
<el-tabs v-model="workbenchTab" class="workbench-tabs">
<el-tab-pane label="车辆监控台" name="vehicle" />
<el-tab-pane label="任务列表" name="mission" />
</el-tabs>
<el-button link type="primary" size="small" :loading="refreshing" @click="refreshAll">
刷新
</el-button>
</div>
</template>
<VehicleMonitorPanel
v-show="workbenchTab === 'vehicle'"
:cars="cars"
:missions="missions"
:selected-id="selectedVehicleId"
@select="onVehicleSelect"
/>
<MissionListPanel
v-show="workbenchTab === 'mission'"
:deliveries="deliveries"
:selected-id="selectedDeliveryId"
@select="onDeliverySelect"
@refresh="onDeliveryListRefresh"
@action-done="refreshDeliveries"
/>
</el-card>
<el-card shadow="never" class="side-card detail-card">
<template #header><span>选中信息</span></template>
@@ -54,6 +68,7 @@
:refresh-key="tick"
:cars="cars"
:missions="missions"
:read-only="!!readOnly"
/>
</el-card>
</el-col>
@@ -65,27 +80,39 @@
import { computed, onMounted, onUnmounted, ref } from 'vue'
import Workspace3D from '@/components/Workspace3D.vue'
import VehicleMonitorPanel from '@/components/workbench/VehicleMonitorPanel.vue'
import MissionListPanel from '@/components/workbench/MissionListPanel.vue'
import MonitorSelectionPanel from '@/components/workbench/MonitorSelectionPanel.vue'
import FloatingAlarmStack from '@/components/map-monitor/FloatingAlarmStack.vue'
import WorkspaceCanvasToolbar from '@/components/workspace/WorkspaceCanvasToolbar.vue'
import { ElMessage } from 'element-plus'
import { useAuthStore } from '@/stores/auth'
import { listCars, listMissions } from '@/api/projection'
import { listDeliveries } from '@/api/delivery'
import { useProjectionStream, type StreamEvent } from '@/composables/useProjectionStream'
import type { AlarmEvent, SelectionDetailEvent } from '@/composables/useMapEditStream'
import { reflectionApi } from '@/api/reflection'
import type { Car } from '@/types/car'
import type { Mission } from '@/types/mission'
import type { DeliveryTask } from '@/types/delivery'
import type { SelectedObjectRef } from '@/types/workbench'
defineProps<{
/** 只读模式(运营端复用 MapMonitorView 时传 true):3D 不可编辑,选中信息面板动作改用运维白名单。 */
readOnly?: boolean
}>()
const auth = useAuthStore()
const vrHost = (import.meta.env.VITE_VRENDER_HOST as string | undefined) ?? 'localhost:8223'
const cars = ref<Car[]>([])
const missions = ref<Mission[]>([])
const deliveries = ref<DeliveryTask[]>([])
const deliveryListOpts = ref({ includeFinished: true, includeAborted: true })
const selection = ref<SelectedObjectRef | null>(null)
const tick = ref(0)
const refreshing = ref(false)
const workbenchTab = ref<'vehicle' | 'mission'>('vehicle')
const selectedDeliveryId = ref<number | null>(null)
const workspaceRef = ref<InstanceType<typeof Workspace3D> | null>(null)
/**
@@ -122,8 +149,16 @@ function onAlarmAck(_a: AlarmEvent) {
let pollTimer: ReturnType<typeof setInterval> | null = null
const onlineCars = computed(() => cars.value.filter((c) => c.state !== 'offline').length)
const runningMissions = computed(() => missions.value.filter((m) => m.status === 'running').length)
const queuedMissions = computed(() => missions.value.filter((m) => m.status === 'queued').length)
const runningMissions = computed(() =>
deliveries.value.length > 0
? deliveries.value.filter((d) => d.statusCode === 'Fetching' || d.statusCode === 'Putting').length
: missions.value.filter((m) => m.status === 'running').length
)
const queuedMissions = computed(() =>
deliveries.value.length > 0
? deliveries.value.filter((d) => d.statusCode === 'Waiting').length
: missions.value.filter((m) => m.status === 'queued').length
)
const selectedVehicleId = computed(() => {
if (!selection.value || selection.value.kind !== 'vehicle') return null
@@ -168,6 +203,7 @@ function sameSelection(a: SelectedObjectRef | null, b: SelectedObjectRef | null)
/** 把解析结果写入侧栏 selection;仅当对象变化时刷新详情,避免选中闪烁。 */
function applyParsedSelection(parsed: { kind: 'vehicle' | 'site' | 'track'; id: string }, rawName?: string) {
selectedDeliveryId.value = null
let next: SelectedObjectRef
if (parsed.kind === 'vehicle') {
const car = (rawName ? findSelectedCar(rawName) : undefined)
@@ -188,6 +224,7 @@ function applyParsedSelection(parsed: { kind: 'vehicle' | 'site' | 'track'; id:
}
function applySelectionFromKindId(kind: 'site' | 'track' | 'car', id: number, names?: readonly string[]) {
selectedDeliveryId.value = null
if (kind === 'car') {
const sid = String(id)
const rawName = names?.find((n) => RX_CAR.test(n)) ?? `Car-${id}`
@@ -293,6 +330,7 @@ async function fallbackSelectionFromBackend() {
}
async function onVehicleSelect(ref: SelectedObjectRef) {
selectedDeliveryId.value = null
if (!sameSelection(selection.value, ref)) {
selection.value = ref
tick.value++
@@ -306,6 +344,49 @@ async function onVehicleSelect(ref: SelectedObjectRef) {
}
}
async function onDeliverySelect(task: DeliveryTask) {
selectedDeliveryId.value = task.id
if (task.carId != null) {
const car = cars.value.find((c) => c.rawId === task.carId || detailIdForCar(c) === String(task.carId))
if (car) {
await onVehicleSelect({ kind: 'vehicle', id: detailIdForCar(car), name: car.name })
return
}
try {
await reflectionApi.setSelection('car', task.carId)
selection.value = {
kind: 'vehicle',
id: String(task.carId),
name: task.carName ?? `Vehicle ${task.carId}`
}
tick.value++
} catch (err) {
ElMessage.error(`同步 3D 选中失败:${(err as Error).message}`)
}
return
}
selection.value = {
kind: 'delivery',
id: String(task.id),
name: `${task.srcLabel}${task.dstLabel}`
}
tick.value++
}
function onDeliveryListRefresh(opts: { includeFinished: boolean; includeAborted: boolean }) {
deliveryListOpts.value = opts
void refreshDeliveries()
}
async function refreshDeliveries() {
try {
deliveries.value = await listDeliveries(deliveryListOpts.value)
} catch (err) {
console.warn('[MapMonitor] refreshDeliveries failed', err)
deliveries.value = []
}
}
function findSelectedCar(name: string): Car | undefined {
const parsedId = parseWorkspaceCarId(name)
return cars.value.find((c) =>
@@ -328,8 +409,10 @@ function detailIdForCar(car: Car): string {
async function refreshAll() {
refreshing.value = true
try {
cars.value = await listCars()
missions.value = await listMissions()
const [carList, missionList] = await Promise.all([listCars(), listMissions()])
cars.value = carList
missions.value = missionList
await refreshDeliveries()
} catch (err) {
console.warn('[MapMonitor] refreshAll failed', err)
} finally {
@@ -385,7 +468,11 @@ onMounted(async () => {
await refreshAll()
pollTimer = setInterval(() => {
// SSE 已连但 projection/cars 曾 502 时 connected 仍为 true,需在车列表为空时继续轮询
if (!stream.connected.value || cars.value.length === 0) void refreshAll()
if (!stream.connected.value || cars.value.length === 0) {
void refreshAll()
} else if (workbenchTab.value === 'mission') {
void refreshDeliveries()
}
}, 3000)
})
@@ -411,8 +498,8 @@ onUnmounted(() => {
flex-direction: column;
min-height: 0;
}
.workbench-card { flex: 1.2; }
.detail-card { flex: 1; }
.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;
@@ -421,10 +508,39 @@ onUnmounted(() => {
display: flex;
flex-direction: column;
}
.muted { color: rgba(255, 255, 255, 0.78); font-size: 12px; }
.muted { color: var(--mg-text-muted); font-size: 12px; }
.workbench-card :deep(.el-card__header),
.detail-card :deep(.el-card__header) {
color: #fff;
color: var(--mg-text-light);
}
.workbench-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
width: 100%;
}
.workbench-tabs {
flex: 1;
min-width: 0;
}
.workbench-tabs :deep(.el-tabs__header) {
margin: 0;
}
.workbench-tabs :deep(.el-tabs__nav-wrap::after) {
display: none;
}
.workbench-tabs :deep(.el-tabs__item) {
color: var(--mg-text-muted);
padding: 0 10px;
height: 32px;
line-height: 32px;
}
.workbench-tabs :deep(.el-tabs__item.is-active) {
color: var(--mg-text-light);
}
.workbench-tabs :deep(.el-tabs__active-bar) {
background-color: var(--mg-accent, #8ec8fc);
}
:deep(.el-row.main-row) { height: 100%; }
:deep(.el-col-16 > div) { height: 100%; min-height: 0; }
@@ -144,22 +144,22 @@ onMounted(() => reload())
<style scoped>
.mmc-card { margin: 0; }
.mmc-header { display: flex; align-items: center; gap: 8px; }
.mmc-title { font-weight: 600; font-size: 15px; color: #fff; }
.mmc-title { font-weight: 600; font-size: 15px; color: var(--mg-text-light); }
.mmc-header .spacer { flex: 1; }
.mmc-desc {
color: rgba(232, 215, 245, 0.78);
color: var(--mg-text-muted);
font-size: 12.5px;
margin: 0 0 12px;
line-height: 1.7;
}
.mmc-desc code {
background: rgba(255, 255, 255, 0.08);
background: var(--mg-veil-2);
padding: 1px 5px;
border-radius: 4px;
font-family: ui-monospace, Menlo, Consolas, monospace;
}
.mmc-tabs :deep(.el-tabs__item) { color: rgba(255, 255, 255, 0.82) !important; }
.mmc-tabs :deep(.el-tabs__item.is-active) { color: #fff !important; }
.mmc-tabs :deep(.el-tabs__item) { color: var(--mg-text-muted) !important; }
.mmc-tabs :deep(.el-tabs__item.is-active) { color: var(--mg-text-light) !important; }
.mmc-tab-body {
display: flex;
flex-direction: column;
@@ -8,10 +8,10 @@
<el-form label-width="160px" :model="payload">
<el-form-item label="分配模式">
<el-radio-group :model-value="payload.mode" @update:model-value="(v: any) => update({ ...payload, mode: v })">
<el-radio-button label="roundRobin">轮询</el-radio-button>
<el-radio-button label="nearest">就近</el-radio-button>
<el-radio-button label="leastLoad">最少负载</el-radio-button>
<el-radio-button label="custom">自定义</el-radio-button>
<el-radio-button value="roundRobin">轮询</el-radio-button>
<el-radio-button value="nearest">就近</el-radio-button>
<el-radio-button value="leastLoad">最少负载</el-radio-button>
<el-radio-button value="custom">自定义</el-radio-button>
</el-radio-group>
</el-form-item>
<el-form-item label="启用负载均衡">
@@ -78,5 +78,5 @@ onMounted(async () => {
</script>
<style scoped>
.muted { color: rgba(232, 215, 245, 0.55); font-size: 12px; }
.muted { color: var(--mg-text-dim); font-size: 12px; }
</style>
@@ -1,131 +1,19 @@
<template>
<div class="monitor-map-page">
<el-alert type="info" :closable="false" show-icon style="margin-bottom: 12px">
RCSMonitor 只读视图iframe 嵌入 SimpleLite webVRender{{ vrHost }}右侧可执行白名单运维动作
</el-alert>
<el-row :gutter="12" class="main-row">
<el-col :span="17">
<div class="canvas-with-toolbar">
<Workspace3D
:host="vrHost"
:scope="'RCSMonitor'"
:token="auth.token ?? ''"
:read-only="true"
:embed-ui="true"
@pick="onPick"
@select="onSelect" />
<WorkspaceCanvasToolbar />
</div>
</el-col>
<el-col :span="7">
<el-card shadow="never">
<template #header><span>选中目标</span></template>
<el-radio-group v-model="targetType" size="small">
<el-radio-button label="car">车辆</el-radio-button>
<el-radio-button label="task">任务</el-radio-button>
</el-radio-group>
<el-select v-model="targetId" placeholder="选择目标 ID" filterable style="width: 100%; margin-top: 8px">
<el-option v-for="opt in currentOptions" :key="opt.value" :label="opt.label" :value="opt.value" />
</el-select>
<el-descriptions :column="1" border size="small" style="margin-top: 8px">
<el-descriptions-item label="最近 Pick">
{{ lastPick ? `(${lastPick.x.toFixed(0)}, ${lastPick.y.toFixed(0)})` : '—' }}
</el-descriptions-item>
<el-descriptions-item label="最近 Select">
{{ lastSelect.length ? lastSelect.join(', ') : '—' }}
</el-descriptions-item>
</el-descriptions>
</el-card>
<el-card shadow="never" style="margin-top: 12px">
<template #header><span>运维白名单按权限隐藏</span></template>
<div class="ops-grid">
<template v-for="op in OPS_WHITELIST" :key="op.code">
<el-button
v-if="auth.hasOp(op.code) && opMatchTarget(op)"
:type="op.needConfirm ? 'warning' : 'primary'"
size="small"
plain
@click="execute(op)">
{{ op.label }}
<el-tooltip :content="op.description"><el-icon style="margin-left: 4px"><InfoFilled /></el-icon></el-tooltip>
</el-button>
</template>
</div>
</el-card>
</el-col>
</el-row>
</div>
<MapMonitorView read-only />
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { InfoFilled } from '@element-plus/icons-vue'
import Workspace3D from '@/components/Workspace3D.vue'
import WorkspaceCanvasToolbar from '@/components/workspace/WorkspaceCanvasToolbar.vue'
import { useAuthStore } from '@/stores/auth'
import { OPS_WHITELIST, type OpsAction } from '@/types/ops'
import { listCars, listMissions } from '@/api/projection'
import { executeOp } from '@/api/ops'
const auth = useAuthStore()
const vrHost = (import.meta.env.VITE_VRENDER_HOST as string | undefined) ?? 'localhost:8223'
const targetType = ref<'car' | 'task'>('car')
const targetId = ref('')
const carOptions = ref<Array<{ label: string; value: string }>>([])
const missionOptions = ref<Array<{ label: string; value: string }>>([])
const lastPick = ref<{ x: number; y: number } | null>(null)
const lastSelect = ref<string[]>([])
const currentOptions = computed(() => (targetType.value === 'car' ? carOptions.value : missionOptions.value))
function opMatchTarget(op: OpsAction) {
return op.target === targetType.value || op.target === 'note'
}
async function execute(op: OpsAction) {
if (!targetId.value && op.target !== 'note') {
ElMessage.warning('请先选择目标')
return
}
if (op.needConfirm) {
try {
await ElMessageBox.confirm(`确认执行 [${op.label}]\n目标:${targetId.value}`, '二次确认', { type: 'warning' })
} catch { return }
}
try {
const resp = await executeOp({ opCode: op.code, targetId: targetId.value, reason: '' })
ElMessage.success(`已执行 ${op.label}auditId=${resp.auditId}`)
} catch (e) {
ElMessage.error(`执行失败:${e instanceof Error ? e.message : String(e)}`)
}
}
function onPick(p: { x: number; y: number }) { lastPick.value = p }
function onSelect(names: string[]) {
lastSelect.value = names
if (names.length === 1 && names[0].startsWith('UICar-')) {
targetType.value = 'car'
targetId.value = names[0].replace('UICar-', '')
}
}
onMounted(async () => {
const cars = await listCars()
carOptions.value = cars.map((c) => ({ label: `${c.name} (${c.id})`, value: c.id }))
const missions = await listMissions()
missionOptions.value = missions.map((m) => ({ label: `${m.name} (${m.id})`, value: m.id }))
})
/**
* 运营端(RCSMonitor)地图监控页:复用管理员端 MapMonitorView,统一界面,仅以 read-only 区分。
*
* 只读模式下:
* - 3D 画布不可编辑(Workspace3D read-only),scope 由 MapMonitorView 内部按 auth.scope 取,
* 运营端登录后即 RCSMonitor。
* - 右侧「选中信息」面板的车辆动作改用运维白名单(OPS_WHITELIST + executeOp 审计 + 二次确认,
* 按当前账号 hasOp 过滤),站点/路径的编辑动作区隐藏。
*
* 备注:原运营端地图页内的「任务」白名单动作(暂停/取消/重派等)请走 /monitor/ops 运维操作页,
* 地图监控页聚焦于地图对象(车/站/路)的监视与车辆运维动作。
*/
import MapMonitorView from '@/views/admin/MapMonitorView.vue'
</script>
<style scoped>
.monitor-map-page { display: flex; flex-direction: column; height: calc(100vh - 56px - 36px - 32px); }
.main-row { flex: 1; min-height: 0; }
.main-row > .el-col { display: flex; flex-direction: column; }
.main-row > .el-col:first-child { min-height: 0; }
.ops-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
.canvas-with-toolbar { flex: 1; min-height: 0; display: flex; flex-direction: column; }
.canvas-with-toolbar :deep(.workspace-3d-wrap) { flex: 1; min-height: 0; }
</style>