From 78771469341ae0beda3dcdc13b0ecf69a0a3ccb6 Mon Sep 17 00:00:00 2001 From: "zhaowei.huang" <228127304@qq.com> Date: Fri, 29 May 2026 23:52:42 +0800 Subject: [PATCH] =?UTF-8?q?refactor(web):=20=E5=B7=A5=E4=BD=9C=E5=8F=B0?= =?UTF-8?q?=E4=B8=8E=E7=9B=91=E6=8E=A7=E8=A7=86=E5=9B=BE=20UI=20=E4=BC=98?= =?UTF-8?q?=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - MonitorSelectionPanel / VehicleMonitorPanel / SelectionDetailPanel 等工作台面板交互与展示优化 - carActionExecute:前往站点遇 404(旧 DLL 无 goto-site 路由)时回退 WebGotoSite,并给出热更新指引 - 地图监控、任务分配、反射管理等视图细节调整;theme.css 补充样式 --- .../src/components/ConfigPageBase.vue | 8 +- .../config/MapMonitorConfigGroup.vue | 10 +- .../orchestration/ReflectionKindTable.vue | 2 +- .../components/reflection/PluginListPanel.vue | 8 +- .../reflection/ReflectionManagerPanel.vue | 53 ++- .../workbench/MonitorSelectionPanel.vue | 423 ++++++++++++------ .../workbench/SelectionDetailPanel.vue | 26 +- .../components/workbench/SitePickDialog.vue | 21 +- .../workbench/VehicleMonitorPanel.vue | 171 ++++--- .../workbench/WorkbenchSidePanel.vue | 10 +- .../simple-platform-vue/src/styles/theme.css | 31 ++ .../src/utils/carActionExecute.ts | 31 +- .../src/views/admin/CadToolbarView.vue | 4 +- .../src/views/admin/MapMonitorView.vue | 144 +++++- .../admin/config/MapMonitorConfigView.vue | 10 +- .../views/admin/config/TaskAllocationView.vue | 8 +- .../views/monitor/MonitorDashboardView.vue | 2 +- .../src/views/monitor/MonitorMapView.vue | 140 +----- 18 files changed, 660 insertions(+), 442 deletions(-) diff --git a/frontends/apps/simple-platform-vue/src/components/ConfigPageBase.vue b/frontends/apps/simple-platform-vue/src/components/ConfigPageBase.vue index 0db98eb..7f00bbe 100644 --- a/frontends/apps/simple-platform-vue/src/components/ConfigPageBase.vue +++ b/frontends/apps/simple-platform-vue/src/components/ConfigPageBase.vue @@ -142,10 +142,10 @@ onMounted(() => reload()) diff --git a/frontends/apps/simple-platform-vue/src/components/reflection/ReflectionManagerPanel.vue b/frontends/apps/simple-platform-vue/src/components/reflection/ReflectionManagerPanel.vue index ef7bc10..20961a3 100644 --- a/frontends/apps/simple-platform-vue/src/components/reflection/ReflectionManagerPanel.vue +++ b/frontends/apps/simple-platform-vue/src/components/reflection/ReflectionManagerPanel.vue @@ -1011,7 +1011,7 @@ onMounted(refresh) display: flex; flex-direction: column; gap: 12px; - color: rgba(232, 215, 245, 0.92); + color: var(--mg-text-light); } .ref-mgr-header { display: flex; @@ -1019,7 +1019,7 @@ onMounted(refresh) justify-content: space-between; gap: 12px; padding: 6px 0 4px; - border-bottom: 1px solid rgba(255, 255, 255, 0.08); + border-bottom: 1px solid var(--mg-divider); } .ref-mgr-title { margin: 0; @@ -1038,7 +1038,7 @@ onMounted(refresh) .ref-type-asm { margin-left: 6px; - color: rgba(180, 160, 220, 0.6); + color: var(--mg-text-dim); font-size: 11px; } @@ -1073,7 +1073,7 @@ onMounted(refresh) justify-content: space-between; margin-bottom: 14px; padding-bottom: 10px; - border-bottom: 1px dashed rgba(255, 255, 255, 0.10); + border-bottom: 1px dashed var(--mg-veil-border); } .detail-title { display: flex; @@ -1086,7 +1086,7 @@ onMounted(refresh) gap: 8px; } .detail-id { - color: rgba(232, 215, 245, 0.65); + color: var(--mg-text-dim); font-family: ui-monospace, Menlo, Consolas, monospace; font-size: 12.5px; } @@ -1111,7 +1111,7 @@ onMounted(refresh) letter-spacing: 0.4px; } .field-empty { - color: rgba(232, 215, 245, 0.55); + color: var(--mg-text-dim); font-size: 12px; padding: 6px 2px; } @@ -1124,7 +1124,7 @@ onMounted(refresh) .status-value--array { font-family: ui-monospace, Menlo, Consolas, monospace; font-size: 12px; - color: rgba(210, 190, 255, 0.95); + color: var(--mg-text-muted); } .method-grid { display: grid; @@ -1237,12 +1237,12 @@ onMounted(refresh) .field-num :deep(.el-input-number) { width: 100%; } :deep(.el-descriptions__title) { - color: rgba(255, 255, 255, 0.85) !important; + color: var(--mg-text-light) !important; font-size: 13px; font-weight: 600; } -:deep(.el-descriptions__label) { color: rgba(232, 215, 245, 0.85); } -:deep(.el-descriptions__content) { color: #fff; } +:deep(.el-descriptions__label) { color: var(--mg-text-muted); } +:deep(.el-descriptions__content) { color: var(--mg-text-light); } :deep(.el-table) { background: transparent; } @@ -1251,13 +1251,40 @@ onMounted(refresh) :deep(.el-table th.el-table__cell), :deep(.el-table td.el-table__cell) { background: transparent !important; - color: rgba(232, 215, 245, 0.92); - border-color: rgba(255, 255, 255, 0.08); + color: var(--mg-text-light); + border-color: var(--mg-divider); } :deep(.el-table--striped .el-table__body tr.el-table__row--striped td.el-table__cell) { - background: rgba(255, 255, 255, 0.04) !important; + background: var(--mg-veil-1) !important; } :deep(.el-table__body tr.current-row > td.el-table__cell) { background: rgba(170, 110, 250, 0.20) !important; } + +/* ── 浅紫主题:卡片变白后,半透明渐变动作按钮上的白字会看不清, + 改为实色紫底白字(对比 ~6.9:1,过 WCAG AA)。scoped 限本组件,不影响地图编辑画布。 ── */ +:root[data-theme="fame-lavender"] .method-btn, +:root[data-theme="fame-lavender"] .header-action-btn { + background: #7c3aed; + border-color: #7c3aed; + color: #fff; +} +:root[data-theme="fame-lavender"] .method-btn:hover:not(:disabled), +:root[data-theme="fame-lavender"] .header-action-btn:hover { + background: #6d28d9; + border-color: #6d28d9; + box-shadow: 0 4px 14px rgba(124, 58, 237, 0.35); +} +:root[data-theme="fame-lavender"] .method-btn-icon { color: rgba(255, 255, 255, 0.85); } +:root[data-theme="fame-lavender"] .method-btn-tag { background: rgba(255, 255, 255, 0.28); } +:root[data-theme="fame-lavender"] .add-field-btn { + background: rgba(124, 58, 237, 0.12); + border-color: rgba(124, 58, 237, 0.5); + color: #6d28d9; +} +:root[data-theme="fame-lavender"] .add-field-btn:hover { + background: rgba(124, 58, 237, 0.22); + border-color: rgba(124, 58, 237, 0.7); + color: #5b21b6; +} diff --git a/frontends/apps/simple-platform-vue/src/components/workbench/MonitorSelectionPanel.vue b/frontends/apps/simple-platform-vue/src/components/workbench/MonitorSelectionPanel.vue index ea3c2d7..101f19e 100644 --- a/frontends/apps/simple-platform-vue/src/components/workbench/MonitorSelectionPanel.vue +++ b/frontends/apps/simple-platform-vue/src/components/workbench/MonitorSelectionPanel.vue @@ -39,7 +39,7 @@ - + 操作 - {{ carInfo.displayId }} - {{ carInfo.stateLabel }} + {{ vehicleSummary.id }} + + {{ vehicleSummary.runLabel }} + - {{ carInfo.name }} - {{ carInfo.typeShort }} + {{ vehicleSummary.name }} - - - 状态 - {{ statusDisplayRows.length }} - + + 运行信息 - - {{ row.key }} - {{ row.value }} + + 交管状态 + {{ vehicleSummary.traffic }} - - - - - - 属性 - {{ fieldDisplayRows.length }} - - - - {{ row.key }} - {{ row.value }} + + 运行状态 + {{ vehicleSummary.runLabel }} + + + 报警状态 + {{ vehicleSummary.alarm }} + + + 电量 + + {{ vehicleSummary.battery }} + + + + 位置 + {{ vehicleSummary.position }} - 动作 - {{ carActions.length }} + {{ readOnly ? '运维动作' : '动作' }} + {{ carActions.length }} + {{ opsCarActions.length }} - 加载动作… - {{ carActionHint }} - - - {{ m.label || m.methodName }} - - + + 当前账号无可执行的运维动作。 + + + {{ op.label }} + + + + + 加载动作… + {{ carActionHint }} + + + {{ m.label || m.methodName }} + + + @@ -140,7 +158,7 @@ - + 动作 import { computed, ref, watch } from 'vue' -import { ElMessage } from 'element-plus' +import { ElMessage, ElMessageBox } from 'element-plus' import { Loading } from '@element-plus/icons-vue' import SitePickDialog from '@/components/workbench/SitePickDialog.vue' import { @@ -190,6 +208,9 @@ import { pickTargetSiteOnMap, type SitePickOption } from '@/utils/carActionExecute' +import { OPS_WHITELIST, type OpsAction } from '@/types/ops' +import { executeOp } from '@/api/ops' +import { useAuthStore } from '@/stores/auth' import type { Car } from '@/types/car' import type { Mission } from '@/types/mission' import type { SelectedObjectRef } from '@/types/workbench' @@ -201,8 +222,12 @@ const props = defineProps<{ refreshKey?: number cars?: Car[] missions?: Mission[] + /** 运营端只读模式:3D 不可编辑,车辆动作改用运维白名单(executeOp + 审计),并跳过 reflection 动作/配置加载。 */ + readOnly?: boolean }>() +const auth = useAuthStore() + const initialLoading = ref(false) const hydrated = ref(false) const actionsLoading = ref(false) @@ -318,82 +343,130 @@ function detailIdForCar(car: Car): string { return car.id.replace(/^C/i, '').replace(/^0+/, '') || car.id } -function missionLabelForCar(car: Car | undefined): string { - if (!car?.missionId) return '无' - const m = props.missions?.find((x) => x.id === car.missionId) - if (!m) return car.missionId - const statusMap: Record = { - running: '运行中', - queued: '排队', - completed: '已完成', - failed: '失败', - cancelled: '已取消' - } - return `${m.name || m.id}(${statusMap[m.status] ?? m.status})` -} - -const carInfo = computed(() => { - const car = findCarInList() - const stateLabels: Record = { - idle: '空闲', - running: '运行', - charging: '充电', - paused: '暂停', - fault: '故障', - offline: '离线' - } - const state = car?.state ?? 'offline' - return { - displayId: car?.id ?? props.selection?.id ?? '-', - name: bundleName.value || car?.name || props.selection?.name || '车辆', - typeShort: shortType(bundleTypeName.value || car?.typeName), - stateLabel: stateLabels[state] ?? state, - state - } -}) - -const carStateTagType = computed((): 'success' | 'warning' | 'danger' | 'info' => { - switch (carInfo.value.state) { - case 'running': return 'success' - case 'charging': return 'warning' - case 'fault': return 'danger' - default: return 'info' - } -}) - -/** 状态区:展示 bundle 返回的全部 status 键值,不再折叠为少数几项。 */ -const statusDisplayRows = computed(() => { - const rows = statusRows.value.map((r) => ({ +/** 站点/路径:仍展示 bundle 返回的状态键值。 */ +const statusDisplayRows = computed(() => + statusRows.value.map((r) => ({ key: r.key, value: r.value || '—', warn: /alarm|error|fault|block|stop/i.test(`${r.key} ${r.value}`) && r.value !== '0' && r.value !== '无' })) - if (viewKind.value === 'vehicle' && !rows.length) { - const car = findCarInList() - const mission = missionLabelForCar(car) - const batteryRaw = pickField('batterySoc', 'battery', '电量') - const batteryPct = batteryRaw - ? `${Math.round(Number(batteryRaw) <= 1 ? Number(batteryRaw) * 100 : Number(batteryRaw))}%` - : car != null - ? `${Math.round(car.batterySoc <= 1 ? car.batterySoc * 100 : car.batterySoc)}%` - : '—' - return [ - { key: '位姿', value: pickStatus('位姿', '站点') || '—', warn: false }, - { key: '任务', value: mission, warn: false }, - { key: '电量', value: batteryPct, warn: Number.parseFloat(batteryPct) < 20 } - ] +) + +function getCarTagsText(): string { + const fromField = pickField('tags') + if (fromField) return fromField + const fromStatus = pickStatus('tags', '标记') + if (fromStatus) return fromStatus + for (const r of statusRows.value) { + if (/tag|标记/i.test(r.key) && r.value) return r.value } - return rows -}) + return '' +} -const FIELD_SKIP = new Set(['name', 'x', 'y', 'z', 'th', 'theta', 'layerName', 'layer']) +function carHasOccupiedTag(): boolean { + const tags = getCarTagsText() + if (/occupied/i.test(tags)) return true + for (const r of statusRows.value) { + if (/occupied/i.test(r.value ?? '')) return true + } + return false +} -const fieldDisplayRows = computed(() => { - if (viewKind.value !== 'vehicle') return [] - const entries = Object.entries(fieldMap.value) - .filter(([k, v]) => v != null && v !== '' && !FIELD_SKIP.has(k)) - .map(([key, value]) => ({ key, value })) - return entries.sort((a, b) => a.key.localeCompare(b.key)) +function formatBatteryText(): { text: string; low: boolean } { + const car = findCarInList() + const raw = pickField('batterySoc', 'battery', '电量') + let pct: number | null = null + if (raw) { + const n = Number(raw) + if (Number.isFinite(n)) pct = n <= 1 ? Math.round(n * 100) : Math.round(n) + } else if (car != null) { + pct = Math.round(car.batterySoc <= 1 ? car.batterySoc * 100 : car.batterySoc) + } + if (pct == null) return { text: '—', low: false } + return { text: `${pct}%`, low: pct < 20 } +} + +function formatCarPosition(): string { + const car = findCarInList() + const x = pickField('x') + const y = pickField('y') + const th = pickField('th', 'theta') + const px = x || (car != null ? String(car.x) : '') + const py = y || (car != null ? String(car.y) : '') + const pth = th || (car != null ? String(car.theta) : '') + if (px || py) { + return `(${px || '—'}, ${py || '—'}${pth ? `, ${fmtNum(pth, 1)}°` : ''})` + } + const siteId = pickField('siteID') + if (siteId && siteId !== '-1') return `站点 #${siteId}` + return pickStatus('位姿', '站点') || '—' +} + +function formatTrafficStatus(): string { + const tc = pickStatus('TCStat') || pickField('TCStat') + if (tc && tc !== '/' && tc.trim() !== '') return tc + + const blockedRaw = pickStatus('blockedBy') + if (blockedRaw && blockedRaw !== '[]' && blockedRaw !== '/') { + try { + const arr = JSON.parse(blockedRaw) as Array<{ carid?: number; type?: number }> + if (Array.isArray(arr) && arr.length > 0) { + const ids = arr.map((b) => b.carid).filter((id): id is number => id != null) + if (ids.length) return `被车辆 ${ids.join('、')} 交管` + return '交管阻塞' + } + } catch { + return blockedRaw.length > 48 ? `${blockedRaw.slice(0, 48)}…` : blockedRaw + } + } + + const stopped = pickStatus('trafficStopped') + if (stopped && stopped !== '(0, 0)' && stopped !== '(0,0)' && stopped !== '/') { + return `交管暂停 ${stopped}` + } + + const holding = pickStatus('holdingLocks') + const pending = pickStatus('pendingLocks') + const parts: string[] = [] + if (holding && holding !== '[]') parts.push(`已占 ${holding}`) + if (pending && pending !== '[]') parts.push(`待锁 ${pending}`) + if (parts.length) return parts.join(';') + + return '正常' +} + +function formatAlarmStatus(): { text: string; warn: boolean } { + const fromEnum = + pickStatus('车体_AlarmInfo', 'AlarmInfo') || + statusRows.value.find((r) => /alarminfo/i.test(r.key))?.value + if (fromEnum && fromEnum !== '0' && fromEnum !== '/' && fromEnum.trim() !== '') { + return { text: fromEnum, warn: true } + } + const lstatus = pickField('lstatus') || pickStatus('lstatus') + if (lstatus && /故障|报警|异常|失联|超时|检修/i.test(lstatus)) { + return { text: lstatus, warn: true } + } + return { text: '无', warn: false } +} + +const vehicleSummary = computed(() => { + const car = findCarInList() + const occupied = carHasOccupiedTag() + const runLabel = occupied ? '任务中' : '空闲' + const alarm = formatAlarmStatus() + const battery = formatBatteryText() + return { + id: car?.id ?? props.selection?.id ?? '-', + name: bundleName.value || car?.name || props.selection?.name || '车辆', + runLabel, + runTagType: (occupied ? 'success' : 'info') as 'success' | 'info', + traffic: formatTrafficStatus(), + alarm: alarm.text, + alarmWarn: alarm.warn, + battery: battery.text, + batteryLow: battery.low, + position: formatCarPosition() + } }) function resolveCarActionWhitelist(): string[] { @@ -419,6 +492,37 @@ const carActions = computed(() => { return allMethods.value.filter((m) => set.has(m.methodName)) }) +/** 运营端只读模式下车辆动作改用运维白名单(按当前账号权限过滤)。 */ +const opsCarActions = computed(() => + OPS_WHITELIST.filter((o) => o.target === 'car' && auth.hasOp(o.code)) +) + +async function onOpsExecute(op: OpsAction) { + const idNum = Number(props.selection?.id) + if (!Number.isFinite(idNum)) return + if (op.needConfirm) { + try { + await ElMessageBox.confirm( + `确认执行 [${op.label}]?\n目标车辆:${props.selection?.id}`, + '二次确认', + { type: 'warning' } + ) + } catch { + return + } + } + executing.value = op.code + try { + const resp = await executeOp({ opCode: op.code, targetId: String(idNum), reason: '' }) + if (resp.ok) ElMessage.success(`已执行 ${op.label}(auditId=${resp.auditId})`) + else ElMessage.warning(resp.message || `执行未成功:${op.label}`) + } catch (e) { + ElMessage.error(`执行失败:${(e as Error).message}`) + } finally { + executing.value = null + } +} + const carActionHint = computed(() => { if (!monitorConfigLoaded.value) return '加载配置中…' if (!Object.keys(carActionByType.value).length) { @@ -484,6 +588,23 @@ async function loadAll() { else actionsLoading.value = true try { + if (props.readOnly) { + // 运营端只读:只取 bundle 展示详情,不加载 reflection 动作/运营配置; + // 失败时静默降级,由 findCarInList() 用 cars 列表数据兜底。 + const bundle = await reflectionApi.getBundle(rk, idNum) + bundleName.value = bundle.summary?.name ?? props.selection.name ?? '' + bundleTypeName.value = bundle.typeName ?? '' + bundleFullTypeName.value = + ('fullTypeName' in bundle && bundle.fullTypeName) ? bundle.fullTypeName : bundle.typeName ?? '' + fieldMap.value = { ...bundle.fields } + for (const f of bundle.fieldList ?? []) { + fieldMap.value[f.key] = f.value + } + statusRows.value = bundle.status ?? [] + allMethods.value = [] + hydrated.value = true + return + } await loadMonitorRuntimeConfig(!soft) const [bundle, methods] = await Promise.all([ reflectionApi.getBundle(rk, idNum), @@ -501,7 +622,11 @@ async function loadAll() { allMethods.value = methods hydrated.value = true } catch (e) { - ElMessage.error(`加载选中信息失败:${(e as Error).message}`) + if (props.readOnly) { + console.warn('[MonitorSelection] 只读详情加载失败,降级用列表数据', e) + } else { + ElMessage.error(`加载选中信息失败:${(e as Error).message}`) + } } finally { initialLoading.value = false actionsLoading.value = false @@ -517,13 +642,21 @@ async function onExecute(m: ReflectionMethod) { if (rk === 'car' && methodNeedsSitePick(m)) { pendingGotoMethod.value = m sitePickTitle.value = `选择目标站点 — ${m.label || m.methodName}` + executing.value = m.methodName try { + const siteId = await pickTargetSiteOnMap() + if (siteId != null) { + await onSitePickConfirm(siteId) + return + } + // 取消地图拾取时回退到站点列表对话框 sitePickSites.value = await loadSitePickOptions() + sitePickOpen.value = true } catch (e) { ElMessage.error(`加载站点列表失败:${(e as Error).message}`) - return + } finally { + if (!sitePickOpen.value) executing.value = null } - sitePickOpen.value = true return } @@ -592,7 +725,7 @@ watch( justify-content: center; gap: 8px; padding: 32px; - color: rgba(255, 255, 255, 0.7); + color: var(--mg-text-muted); font-size: 13px; } .empty-hint { @@ -602,12 +735,12 @@ watch( padding: 24px 8px; text-align: center; } -.empty-title { font-size: 13px; color: rgba(255, 255, 255, 0.9); font-weight: 500; } -.empty-desc { font-size: 11.5px; color: rgba(255, 255, 255, 0.65); } +.empty-title { font-size: 13px; color: var(--mg-text-light); font-weight: 500; } +.empty-desc { font-size: 11.5px; color: var(--mg-text-dim); } .card { - background: rgba(255, 255, 255, 0.04); - border: 1px solid rgba(255, 255, 255, 0.1); + background: var(--mg-veil-1); + border: 1px solid var(--mg-veil-border); border-radius: 8px; padding: 8px 10px; flex: none; @@ -628,13 +761,13 @@ watch( font-size: 11px; font-weight: 600; letter-spacing: 0.8px; - color: rgba(220, 180, 250, 0.88); + color: var(--mg-text-muted); text-transform: uppercase; } .card-hd .meta { font-size: 10px; font-weight: 400; - color: rgba(200, 180, 220, 0.55); + color: var(--mg-text-dim); text-transform: none; letter-spacing: 0; } @@ -650,24 +783,24 @@ watch( font-weight: 600; padding: 1px 7px; border-radius: 4px; - background: rgba(170, 110, 250, 0.35); + background: var(--mg-primary); color: #fff; } .type-tag { font-size: 11px; - color: rgba(232, 215, 245, 0.75); + color: var(--mg-text-muted); } .name-line { font-size: 14px; font-weight: 600; - color: #fff; + color: var(--mg-text-light); line-height: 1.3; word-break: break-all; } .sub-line { margin-top: 2px; font-size: 11px; - color: rgba(200, 180, 230, 0.7); + color: var(--mg-text-muted); font-family: ui-monospace, Consolas, monospace; } @@ -685,10 +818,11 @@ watch( font-size: 12px; line-height: 1.35; padding: 4px 0; - border-bottom: 1px solid rgba(255, 255, 255, 0.05); + border-bottom: 1px solid var(--mg-divider); } .kv-row:last-child { border-bottom: none; } -.kv-row.is-warn .v { color: #ffb4b4; } +.kv-row.is-warn .v { color: var(--mg-status-danger); } +.kv-row .v.is-low-battery { color: var(--mg-status-danger); } .kv { display: flex; flex-direction: column; @@ -696,12 +830,12 @@ watch( min-width: 0; } .k, .kv-row .k { - color: rgba(200, 180, 230, 0.75); + color: var(--mg-text-muted); font-size: 11px; word-break: break-word; } .v, .kv-row .v { - color: #fff; + color: var(--mg-text-light); word-break: break-word; font-size: 12px; } @@ -718,8 +852,8 @@ watch( .action-btn { appearance: none; cursor: pointer; - border: 1px solid rgba(167, 139, 250, 0.55); - background: rgba(76, 29, 149, 0.45); + border: 1px solid var(--mg-action-border); + background: var(--mg-action-bg); color: #fff; font-size: 12px; font-weight: 600; @@ -734,8 +868,8 @@ watch( background: rgba(120, 70, 20, 0.45); } .action-btn:hover:not(:disabled) { - background: rgba(109, 40, 217, 0.75); - border-color: rgba(196, 181, 253, 0.9); + background: var(--mg-action-bg-hover); + border-color: var(--mg-action-border-hi); box-shadow: 0 2px 8px rgba(0, 0, 0, 0.25); } .action-btn--goto:hover:not(:disabled) { @@ -744,9 +878,20 @@ watch( } .action-btn:disabled { opacity: 0.55; cursor: progress; } +/* fame-lavender 浅紫主题:goto(前往站点)按钮在白底下需实色琥珀 + 白字, + 否则半透明棕底叠白底→浅棕 + 白字不可读。 */ +:root[data-theme="fame-lavender"] .action-btn--goto { + border-color: #b45309; + background: #b45309; +} +:root[data-theme="fame-lavender"] .action-btn--goto:hover:not(:disabled) { + border-color: #92400e; + background: #92400e; +} + .muted-line { font-size: 11.5px; - color: rgba(255, 255, 255, 0.65); + color: var(--mg-text-dim); line-height: 1.4; padding: 2px 0; } diff --git a/frontends/apps/simple-platform-vue/src/components/workbench/SelectionDetailPanel.vue b/frontends/apps/simple-platform-vue/src/components/workbench/SelectionDetailPanel.vue index d7b3cea..c0005a6 100644 --- a/frontends/apps/simple-platform-vue/src/components/workbench/SelectionDetailPanel.vue +++ b/frontends/apps/simple-platform-vue/src/components/workbench/SelectionDetailPanel.vue @@ -430,34 +430,34 @@ function toDetailFields(rows: Array .selection-detail { display: flex; flex-direction: column; gap: 8px; min-height: 0; } .head { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; } -.name { font-weight: 600; font-size: 13px; color: #fff; } +.name { font-weight: 600; font-size: 13px; color: var(--mg-text-light); } .detail-tabs :deep(.el-tabs__header) { margin-bottom: 0; } -.detail-tabs :deep(.el-tabs__item) { color: rgba(255, 255, 255, 0.82) !important; } -.detail-tabs :deep(.el-tabs__item.is-active) { color: #fff !important; } -.detail-body { flex: 1; min-height: 120px; overflow: auto; color: #fff; } -.detail-body :deep(.el-descriptions__label) { color: rgba(255, 255, 255, 0.88) !important; } +.detail-tabs :deep(.el-tabs__item) { color: var(--mg-text-muted) !important; } +.detail-tabs :deep(.el-tabs__item.is-active) { color: var(--mg-text-light) !important; } +.detail-body { flex: 1; min-height: 120px; overflow: auto; color: var(--mg-text-light); } +.detail-body :deep(.el-descriptions__label) { color: var(--mg-text-muted) !important; } .detail-body :deep(.el-descriptions__content), .detail-body :deep(.el-table .cell) { - color: #fff !important; + color: var(--mg-text-light) !important; } .section { margin-top: 10px; } -.section-title { font-size: 12px; color: #fff; margin-bottom: 6px; } +.section-title { font-size: 12px; color: var(--mg-text-light); margin-bottom: 6px; } .action-list { display: flex; flex-direction: column; gap: 6px; } .action-btn { justify-content: flex-start; - border-color: rgba(167, 139, 250, 0.65); - background: rgba(76, 29, 149, 0.45); + border-color: var(--mg-action-border); + background: var(--mg-action-bg); color: #fff !important; } .action-btn:hover { - border-color: rgba(196, 181, 253, 0.95); - background: rgba(109, 40, 217, 0.75); + border-color: var(--mg-action-border-hi); + background: var(--mg-action-bg-hover); } .action-btn :deep(span) { color: #fff !important; font-weight: 600; } .action-btn .hint-icon { margin-left: 6px; font-size: 13px; opacity: 0.8; } -.empty { font-size: 12px; padding: 8px 0; color: #fff; } -.muted { color: rgba(255, 255, 255, 0.78); } +.empty { font-size: 12px; padding: 8px 0; color: var(--mg-text-light); } +.muted { color: var(--mg-text-muted); } diff --git a/frontends/apps/simple-platform-vue/src/components/workbench/SitePickDialog.vue b/frontends/apps/simple-platform-vue/src/components/workbench/SitePickDialog.vue index 206a1e1..c65d8f8 100644 --- a/frontends/apps/simple-platform-vue/src/components/workbench/SitePickDialog.vue +++ b/frontends/apps/simple-platform-vue/src/components/workbench/SitePickDialog.vue @@ -100,28 +100,33 @@ defineExpose({ close: () => { visible.value = false }, endMapPick: () => { mapPi diff --git a/frontends/apps/simple-platform-vue/src/components/workbench/VehicleMonitorPanel.vue b/frontends/apps/simple-platform-vue/src/components/workbench/VehicleMonitorPanel.vue index b352d45..4dfcade 100644 --- a/frontends/apps/simple-platform-vue/src/components/workbench/VehicleMonitorPanel.vue +++ b/frontends/apps/simple-platform-vue/src/components/workbench/VehicleMonitorPanel.vue @@ -56,10 +56,8 @@ @click="onSelect(car)" > - - {{ car.id }} - {{ car.name }} - + {{ car.id }} + {{ car.name }} - - {{ shortType(car.typeName) }} - {{ car.group }} - {{ car.ip }} - - - - 电量 - - - {{ batteryPct(car) }}% - - - - - 任务 - - - {{ missionForCar(car)!.name }} + + + + + {{ batteryPct(car) }}% - - {{ missionStatusLabel(missionForCar(car)!.status) }} - - - - 无任务 - 空闲 - + + + + + {{ missionStatusLabel(missionForCar(car)!.status) }} + + + {{ missionForCar(car)!.name }} + + + + 空闲 + + @@ -258,11 +248,6 @@ function batteryLevel(p: number): 'low' | 'mid' | 'high' { return 'high' } -function shortType(typeName: string): string { - const idx = typeName.lastIndexOf('.') - return idx >= 0 ? typeName.slice(idx + 1) : typeName -} - const filteredCars = computed(() => { const q = search.value.trim().toLowerCase() const tokens = q ? q.split(/\s+/).filter(Boolean) : [] @@ -312,8 +297,8 @@ function detailIdForCar(car: Car): string { flex: none; } .overview-item { - background: rgba(255, 255, 255, 0.05); - border: 1px solid rgba(255, 255, 255, 0.12); + background: var(--mg-veil-1); + border: 1px solid var(--mg-veil-border); border-radius: 8px; padding: 6px 4px; text-align: center; @@ -326,16 +311,16 @@ function detailIdForCar(car: Car): string { .overview-num { font-size: 18px; font-weight: 600; - color: #fff; + color: var(--mg-text-light); line-height: 1.2; } .overview-num.online { color: var(--mg-accent); text-shadow: 0 0 12px rgba(var(--mg-accent-rgb), 0.55); } -.overview-num.running { color: #b6e7a3; text-shadow: 0 0 12px rgba(103, 194, 58, 0.45); } +.overview-num.running { color: var(--mg-status-success); text-shadow: 0 0 12px rgba(var(--mg-status-success-rgb), 0.45); } .overview-num.charging { color: var(--mg-primary-hover); text-shadow: 0 0 12px rgba(var(--mg-primary-hover-rgb), 0.55); } -.overview-num.fault { color: #ff9bb8; text-shadow: 0 0 12px rgba(245, 108, 108, 0.5); } +.overview-num.fault { color: var(--mg-status-danger); text-shadow: 0 0 12px rgba(var(--mg-status-danger-rgb), 0.5); } .overview-label { font-size: 11px; - color: rgba(255, 255, 255, 0.7); + color: var(--mg-text-muted); margin-top: 2px; } @@ -358,14 +343,14 @@ function detailIdForCar(car: Car): string { } .vehicle-row { - background: rgba(255, 255, 255, 0.04); - border: 1px solid rgba(255, 255, 255, 0.1); - border-radius: 8px; - padding: 8px 10px; + background: var(--mg-veil-1); + border: 1px solid var(--mg-veil-border); + border-radius: 6px; + padding: 6px 8px; cursor: pointer; display: flex; flex-direction: column; - gap: 4px; + gap: 3px; transition: all 0.18s ease; } .vehicle-row:hover { @@ -381,25 +366,20 @@ function detailIdForCar(car: Car): string { .vehicle-head { display: flex; align-items: center; - justify-content: space-between; - gap: 8px; -} -.vehicle-id-name { - display: flex; - align-items: baseline; gap: 6px; - min-width: 0; } .vid { - font-size: 12px; - color: rgba(255, 255, 255, 0.7); + font-size: 11.5px; + color: var(--mg-text-muted); font-family: ui-monospace, 'SFMono-Regular', 'Menlo', monospace; flex: none; } .vname { - color: #fff; + flex: 1; + min-width: 0; + color: var(--mg-text-light); font-weight: 600; - font-size: 13px; + font-size: 12.5px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; @@ -422,7 +402,7 @@ function detailIdForCar(car: Car): string { .dot-charging { color: var(--mg-primary-hover); box-shadow: 0 0 8px var(--mg-primary-hover); } .dot-paused { color: #e6a23c; box-shadow: 0 0 6px #e6a23c; } .dot-fault { color: #ff7396; box-shadow: 0 0 8px #ff7396; animation: pulse 0.9s infinite; } -.dot-offline { color: rgba(255, 255, 255, 0.35); } +.dot-offline { color: var(--mg-text-faint); } .dot-idle { color: var(--mg-accent); box-shadow: 0 0 6px rgba(var(--mg-accent-rgb), 0.6); } @keyframes pulse { @@ -430,55 +410,54 @@ function detailIdForCar(car: Car): string { 50% { opacity: 0.35; } } -.vehicle-meta { - display: flex; - flex-wrap: wrap; - gap: 8px; - font-size: 11px; - line-height: 1.3; -} -.meta-item { white-space: nowrap; } - -.battery-row, -.mission-row { +.vehicle-sub { display: flex; align-items: center; - gap: 8px; - font-size: 12px; + gap: 10px; + font-size: 11.5px; } -.battery-label, -.mission-label { - width: 32px; - color: rgba(255, 255, 255, 0.65); - flex: none; +.battery-mini { + display: flex; + align-items: center; + gap: 6px; + flex: 0 0 44%; + min-width: 0; +} +.mission-mini { + display: flex; + align-items: center; + gap: 6px; + flex: 1; + min-width: 0; } .battery-bar { flex: 1; min-width: 0; } .battery-bar :deep(.el-progress-bar__outer) { - background: rgba(255, 255, 255, 0.1); + background: var(--mg-veil-3); } .battery-text { - width: 38px; + width: 34px; text-align: right; font-variant-numeric: tabular-nums; font-weight: 600; + font-size: 11.5px; flex: none; } -.battery-text.low { color: #f56c6c; } -.battery-text.mid { color: #e6a23c; } -.battery-text.high { color: #67c23a; } +.battery-text.low { color: var(--mg-status-danger); } +.battery-text.mid { color: var(--mg-status-warning); } +.battery-text.high { color: var(--mg-status-success); } .mission-name { flex: 1; min-width: 0; - color: #fff; + color: var(--mg-text-light); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } -.mission-name.muted { color: rgba(255, 255, 255, 0.5); } +.mission-name.muted { color: var(--mg-text-dim); } .mission-tag { flex: none; } .empty { @@ -486,5 +465,5 @@ function detailIdForCar(car: Car): string { padding: 24px 0; font-size: 12px; } -.muted { color: rgba(255, 255, 255, 0.7); } +.muted { color: var(--mg-text-muted); } diff --git a/frontends/apps/simple-platform-vue/src/components/workbench/WorkbenchSidePanel.vue b/frontends/apps/simple-platform-vue/src/components/workbench/WorkbenchSidePanel.vue index 5f781c8..4697d47 100644 --- a/frontends/apps/simple-platform-vue/src/components/workbench/WorkbenchSidePanel.vue +++ b/frontends/apps/simple-platform-vue/src/components/workbench/WorkbenchSidePanel.vue @@ -162,15 +162,15 @@ onMounted(loadList) .workbench-panel :deep(.el-tabs__item), .workbench-panel :deep(.el-table .cell), .workbench-panel :deep(.el-input__inner) { - color: #fff; + color: var(--mg-text-light); } -.toolbar-hint { color: rgba(255, 255, 255, 0.88); } -.muted { color: rgba(255, 255, 255, 0.75); } +.toolbar-hint { color: var(--mg-text-muted); } +.muted { color: var(--mg-text-muted); } :deep(.el-table th.el-table__cell) { - color: rgba(255, 255, 255, 0.9) !important; + color: var(--mg-text-muted) !important; } :deep(.is-selected-row) td { background: rgba(var(--mg-primary-hover-rgb), 0.28) !important; - color: #fff !important; + color: var(--mg-text-light) !important; } diff --git a/frontends/apps/simple-platform-vue/src/styles/theme.css b/frontends/apps/simple-platform-vue/src/styles/theme.css index d14d814..6d0d763 100644 --- a/frontends/apps/simple-platform-vue/src/styles/theme.css +++ b/frontends/apps/simple-platform-vue/src/styles/theme.css @@ -115,6 +115,24 @@ --el-color-primary-dark-2: var(--mg-primary-dark-2); --mg-transition: all .25s cubic-bezier(.25, .8, .25, 1); + + /* ── 通用「内嵌表面」veil 令牌 ── + * 面板里的嵌套行 / 子卡 / 统计项常用 rgba(255,255,255,.0x) 当作浅色微透表面, + * 在深色主题里是「白色微透」效果;但 fame-lavender 浅紫主题切到白底后这些白veil + * 直接消失(白叠白)。把它们抽成令牌:深色主题保持白微透,fame-lavender 翻成紫微透, + * 这样所有引用处自动双主题可见,且不影响 6 套深色主题观感。 */ + --mg-veil-1: rgba(255, 255, 255, 0.05); + --mg-veil-2: rgba(255, 255, 255, 0.08); + --mg-veil-3: rgba(255, 255, 255, 0.12); + --mg-veil-border: rgba(255, 255, 255, 0.12); + --mg-veil-border-hi: rgba(255, 255, 255, 0.20); + + /* ── 面板「动作按钮」令牌 ── + * 深色主题:半透明紫玻璃底 + 白字;fame-lavender:实色紫底 + 白字(对比足够)。 */ + --mg-action-bg: rgba(76, 29, 149, 0.45); + --mg-action-bg-hover: rgba(109, 40, 217, 0.75); + --mg-action-border: rgba(167, 139, 250, 0.55); + --mg-action-border-hi: rgba(196, 181, 253, 0.9); } html { @@ -920,6 +938,19 @@ a:hover { color: rgba(var(--mg-accent-rgb), 0.85); } --mg-status-info-rgb: 29, 78, 216; --mg-status-idle: #64748b; --mg-status-idle-rgb: 100, 116, 139; + + /* 内嵌表面 veil:浅紫主题翻成紫色微透,保证白底卡片内的嵌套行 / 子卡可见 */ + --mg-veil-1: rgba(124, 58, 237, 0.05); + --mg-veil-2: rgba(124, 58, 237, 0.10); + --mg-veil-3: rgba(124, 58, 237, 0.16); + --mg-veil-border: rgba(124, 58, 237, 0.16); + --mg-veil-border-hi: rgba(124, 58, 237, 0.32); + + /* 动作按钮:实色紫底 + 白字(对比 ~6.9:1,WCAG AA 通过) */ + --mg-action-bg: #7c3aed; + --mg-action-bg-hover: #6d28d9; + --mg-action-border: #7c3aed; + --mg-action-border-hi: #6d28d9; } /* body:纯净紫白渐变(更柔和、长时间阅读不刺眼) */ diff --git a/frontends/apps/simple-platform-vue/src/utils/carActionExecute.ts b/frontends/apps/simple-platform-vue/src/utils/carActionExecute.ts index 6305e7a..cca9aeb 100644 --- a/frontends/apps/simple-platform-vue/src/utils/carActionExecute.ts +++ b/frontends/apps/simple-platform-vue/src/utils/carActionExecute.ts @@ -47,14 +47,41 @@ export async function pickTargetSiteOnMap(): Promise { } } +function isHttp404(e: unknown): boolean { + if (e && typeof e === 'object' && 'response' in e) { + const status = (e as { response?: { status?: number } }).response?.status + if (status === 404) return true + } + const msg = e instanceof Error ? e.message : String(e) + return /status code 404/i.test(msg) +} + export async function executeCarGotoSite(carId: number, siteId: number): Promise { try { const r = await reflectionApi.gotoCarSite(carId, siteId) ElMessage.success(r.message || `已下发前往站点 #${siteId}`) return true } catch (e) { - ElMessage.error(`前往站点失败:${e instanceof Error ? e.message : String(e)}`) - return false + if (!isHttp404(e)) { + ElMessage.error(`前往站点失败:${e instanceof Error ? e.message : String(e)}`) + return false + } + // goto-site 路由未加载(旧 DLL):回退 execute WebGotoSite(DummyCar / Car 上均有) + try { + const r2 = await reflectionApi.execute('car', carId, 'WebGotoSite', { siteId }) + ElMessage.success(r2.returnValue ? `已执行:${r2.returnValue}` : `已下发前往站点 #${siteId}`) + return true + } catch (e2) { + const msg2 = e2 instanceof Error ? e2.message : String(e2) + const needDeploy = + /WebGotoSite|goto-site|不存在可调用方法/i.test(msg2) + ElMessage.error( + needDeploy + ? `${msg2}。当前 SimpleLite 仍是旧版 DLL:请先完全关闭 SimpleLite 窗口,再在 PowerShell 执行 Migu2.0/scripts/redeploy-simplelite.ps1,然后重新登录;或调用 POST /api/health/simplelite/restart-for-update` + : `前往站点失败:${msg2}` + ) + return false + } } } diff --git a/frontends/apps/simple-platform-vue/src/views/admin/CadToolbarView.vue b/frontends/apps/simple-platform-vue/src/views/admin/CadToolbarView.vue index 4c69734..8d6f4df 100644 --- a/frontends/apps/simple-platform-vue/src/views/admin/CadToolbarView.vue +++ b/frontends/apps/simple-platform-vue/src/views/admin/CadToolbarView.vue @@ -63,13 +63,13 @@ function run(t: CadTool) { 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 a40d487..709e1a3 100644 --- a/frontends/apps/simple-platform-vue/src/views/admin/MapMonitorView.vue +++ b/frontends/apps/simple-platform-vue/src/views/admin/MapMonitorView.vue @@ -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 @@ - 车辆监控台 - - 刷新 - + + + + + + + 刷新 + + + 选中信息 @@ -54,6 +68,7 @@ :refresh-key="tick" :cars="cars" :missions="missions" + :read-only="!!readOnly" /> @@ -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([]) const missions = ref([]) +const deliveries = ref([]) +const deliveryListOpts = ref({ includeFinished: true, includeAborted: true }) const selection = ref(null) const tick = ref(0) const refreshing = ref(false) +const workbenchTab = ref<'vehicle' | 'mission'>('vehicle') +const selectedDeliveryId = ref(null) const workspaceRef = ref | null>(null) /** @@ -122,8 +149,16 @@ function onAlarmAck(_a: AlarmEvent) { let pollTimer: ReturnType | 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; } diff --git a/frontends/apps/simple-platform-vue/src/views/admin/config/MapMonitorConfigView.vue b/frontends/apps/simple-platform-vue/src/views/admin/config/MapMonitorConfigView.vue index b7921db..47f51ae 100644 --- a/frontends/apps/simple-platform-vue/src/views/admin/config/MapMonitorConfigView.vue +++ b/frontends/apps/simple-platform-vue/src/views/admin/config/MapMonitorConfigView.vue @@ -144,22 +144,22 @@ onMounted(() => reload()) diff --git a/frontends/apps/simple-platform-vue/src/views/monitor/MonitorMapView.vue b/frontends/apps/simple-platform-vue/src/views/monitor/MonitorMapView.vue index 988c8ac..a753831 100644 --- a/frontends/apps/simple-platform-vue/src/views/monitor/MonitorMapView.vue +++ b/frontends/apps/simple-platform-vue/src/views/monitor/MonitorMapView.vue @@ -1,131 +1,19 @@ - - - RCSMonitor 只读视图:iframe 嵌入 SimpleLite webVRender({{ vrHost }}),右侧可执行白名单运维动作。 - - - - - - - - - - - 选中目标 - - 车辆 - 任务 - - - - - - - {{ lastPick ? `(${lastPick.x.toFixed(0)}, ${lastPick.y.toFixed(0)})` : '—' }} - - - {{ lastSelect.length ? lastSelect.join(', ') : '—' }} - - - - - - 运维白名单(按权限隐藏) - - - - {{ op.label }} - - - - - - - - + - -