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
@@ -142,10 +142,10 @@ onMounted(() => reload())
<style scoped> <style scoped>
.cpb-header { display: flex; align-items: center; gap: 8px; } .cpb-header { display: flex; align-items: center; gap: 8px; }
.cpb-title { font-weight: 600; font-size: 15px; color: #fff; } .cpb-title { font-weight: 600; font-size: 15px; color: var(--mg-text-light); }
.cpb-header .spacer { flex: 1; } .cpb-header .spacer { flex: 1; }
.cpb-desc { .cpb-desc {
color: rgba(232, 215, 245, 0.7); color: var(--mg-text-muted);
font-size: 12.5px; font-size: 12.5px;
margin: 0 0 14px; margin: 0 0 14px;
line-height: 1.6; line-height: 1.6;
@@ -155,8 +155,8 @@ onMounted(() => reload())
margin: 0; padding: 14px; margin: 0; padding: 14px;
font-size: 12px; line-height: 1.55; font-size: 12px; line-height: 1.55;
font-family: ui-monospace, Menlo, Consolas, monospace; font-family: ui-monospace, Menlo, Consolas, monospace;
background: rgba(15, 4, 32, 0.55); background: var(--mg-veil-2);
color: #d6c5ee; color: var(--mg-text-light);
max-height: 520px; max-height: 520px;
overflow: auto; overflow: auto;
} }
@@ -109,8 +109,8 @@ function clearOrphans() {
flex-direction: column; flex-direction: column;
gap: 8px; gap: 8px;
padding: 12px 14px; padding: 12px 14px;
background: rgba(255, 255, 255, 0.04); background: var(--mg-veil-1);
border: 1px solid rgba(255, 255, 255, 0.08); border: 1px solid var(--mg-veil-border);
border-radius: 8px; border-radius: 8px;
} }
.mmcg-head { .mmcg-head {
@@ -122,7 +122,7 @@ function clearOrphans() {
.mmcg-title { .mmcg-title {
font-weight: 600; font-weight: 600;
font-size: 13px; font-size: 13px;
color: #fff; color: var(--mg-text-light);
} }
.mmcg-head .spacer { flex: 1; } .mmcg-head .spacer { flex: 1; }
.mmcg-empty { .mmcg-empty {
@@ -130,7 +130,7 @@ function clearOrphans() {
font-size: 12px; font-size: 12px;
font-style: italic; font-style: italic;
} }
.muted { color: rgba(255, 255, 255, 0.62); } .muted { color: var(--mg-text-dim); }
.mmcg-grid { .mmcg-grid {
display: grid; display: grid;
@@ -143,7 +143,7 @@ function clearOrphans() {
height: 28px; height: 28px;
} }
.mmcg-grid :deep(.el-checkbox__label) { .mmcg-grid :deep(.el-checkbox__label) {
color: rgba(255, 255, 255, 0.92); color: var(--mg-text-light);
font-size: 12.5px; font-size: 12.5px;
font-family: ui-monospace, Menlo, Consolas, monospace; font-family: ui-monospace, Menlo, Consolas, monospace;
} }
@@ -162,7 +162,7 @@ onMounted(refresh)
.rkt-actions { margin-top: 8px; } .rkt-actions { margin-top: 8px; }
.rkt-method-grid { display: flex; flex-wrap: wrap; gap: 6px; } .rkt-method-grid { display: flex; flex-wrap: wrap; gap: 6px; }
.rkt-loading-methods, .rkt-empty { .rkt-loading-methods, .rkt-empty {
color: rgba(232,215,245,0.7); color: var(--mg-text-muted);
font-size: 12px; font-size: 12px;
padding: 8px 4px; padding: 8px 4px;
} }
@@ -150,7 +150,7 @@ defineExpose({ refresh })
.plg-title { .plg-title {
font-size: 14px; font-size: 14px;
font-weight: 600; font-weight: 600;
color: rgba(255, 255, 255, 0.92); color: var(--mg-text-light);
} }
.plg-collapse { margin-left: auto; } .plg-collapse { margin-left: auto; }
.plg-tag { margin-right: 4px; margin-bottom: 2px; } .plg-tag { margin-right: 4px; margin-bottom: 2px; }
@@ -162,11 +162,11 @@ defineExpose({ refresh })
:deep(.el-table th.el-table__cell), :deep(.el-table th.el-table__cell),
:deep(.el-table td.el-table__cell) { :deep(.el-table td.el-table__cell) {
background: transparent !important; background: transparent !important;
color: rgba(232, 215, 245, 0.92); color: var(--mg-text-light);
border-color: rgba(255, 255, 255, 0.08); border-color: var(--mg-divider);
} }
:deep(.el-card__header) { :deep(.el-card__header) {
padding: 10px 14px; padding: 10px 14px;
border-bottom: 1px solid rgba(255, 255, 255, 0.08); border-bottom: 1px solid var(--mg-divider);
} }
</style> </style>
@@ -1011,7 +1011,7 @@ onMounted(refresh)
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 12px; gap: 12px;
color: rgba(232, 215, 245, 0.92); color: var(--mg-text-light);
} }
.ref-mgr-header { .ref-mgr-header {
display: flex; display: flex;
@@ -1019,7 +1019,7 @@ onMounted(refresh)
justify-content: space-between; justify-content: space-between;
gap: 12px; gap: 12px;
padding: 6px 0 4px; padding: 6px 0 4px;
border-bottom: 1px solid rgba(255, 255, 255, 0.08); border-bottom: 1px solid var(--mg-divider);
} }
.ref-mgr-title { .ref-mgr-title {
margin: 0; margin: 0;
@@ -1038,7 +1038,7 @@ onMounted(refresh)
.ref-type-asm { .ref-type-asm {
margin-left: 6px; margin-left: 6px;
color: rgba(180, 160, 220, 0.6); color: var(--mg-text-dim);
font-size: 11px; font-size: 11px;
} }
@@ -1073,7 +1073,7 @@ onMounted(refresh)
justify-content: space-between; justify-content: space-between;
margin-bottom: 14px; margin-bottom: 14px;
padding-bottom: 10px; padding-bottom: 10px;
border-bottom: 1px dashed rgba(255, 255, 255, 0.10); border-bottom: 1px dashed var(--mg-veil-border);
} }
.detail-title { .detail-title {
display: flex; display: flex;
@@ -1086,7 +1086,7 @@ onMounted(refresh)
gap: 8px; gap: 8px;
} }
.detail-id { .detail-id {
color: rgba(232, 215, 245, 0.65); color: var(--mg-text-dim);
font-family: ui-monospace, Menlo, Consolas, monospace; font-family: ui-monospace, Menlo, Consolas, monospace;
font-size: 12.5px; font-size: 12.5px;
} }
@@ -1111,7 +1111,7 @@ onMounted(refresh)
letter-spacing: 0.4px; letter-spacing: 0.4px;
} }
.field-empty { .field-empty {
color: rgba(232, 215, 245, 0.55); color: var(--mg-text-dim);
font-size: 12px; font-size: 12px;
padding: 6px 2px; padding: 6px 2px;
} }
@@ -1124,7 +1124,7 @@ onMounted(refresh)
.status-value--array { .status-value--array {
font-family: ui-monospace, Menlo, Consolas, monospace; font-family: ui-monospace, Menlo, Consolas, monospace;
font-size: 12px; font-size: 12px;
color: rgba(210, 190, 255, 0.95); color: var(--mg-text-muted);
} }
.method-grid { .method-grid {
display: grid; display: grid;
@@ -1237,12 +1237,12 @@ onMounted(refresh)
.field-num :deep(.el-input-number) { width: 100%; } .field-num :deep(.el-input-number) { width: 100%; }
:deep(.el-descriptions__title) { :deep(.el-descriptions__title) {
color: rgba(255, 255, 255, 0.85) !important; color: var(--mg-text-light) !important;
font-size: 13px; font-size: 13px;
font-weight: 600; font-weight: 600;
} }
:deep(.el-descriptions__label) { color: rgba(232, 215, 245, 0.85); } :deep(.el-descriptions__label) { color: var(--mg-text-muted); }
:deep(.el-descriptions__content) { color: #fff; } :deep(.el-descriptions__content) { color: var(--mg-text-light); }
:deep(.el-table) { :deep(.el-table) {
background: transparent; background: transparent;
} }
@@ -1251,13 +1251,40 @@ onMounted(refresh)
:deep(.el-table th.el-table__cell), :deep(.el-table th.el-table__cell),
:deep(.el-table td.el-table__cell) { :deep(.el-table td.el-table__cell) {
background: transparent !important; background: transparent !important;
color: rgba(232, 215, 245, 0.92); color: var(--mg-text-light);
border-color: rgba(255, 255, 255, 0.08); border-color: var(--mg-divider);
} }
:deep(.el-table--striped .el-table__body tr.el-table__row--striped td.el-table__cell) { :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) { :deep(.el-table__body tr.current-row > td.el-table__cell) {
background: rgba(170, 110, 250, 0.20) !important; 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;
}
</style> </style>
@@ -39,7 +39,7 @@
</div> </div>
</div> </div>
</section> </section>
<section v-if="siteActions.length" class="card card--actions"> <section v-if="!readOnly && siteActions.length" class="card card--actions">
<header class="card-hd"><span>操作</span></header> <header class="card-hd"><span>操作</span></header>
<div class="action-grid"> <div class="action-grid">
<button <button
@@ -59,62 +59,80 @@
<template v-else-if="viewKind === 'vehicle'"> <template v-else-if="viewKind === 'vehicle'">
<section class="card card--header"> <section class="card card--header">
<div class="title-row"> <div class="title-row">
<span class="id-tag">{{ carInfo.displayId }}</span> <span class="id-tag">{{ vehicleSummary.id }}</span>
<el-tag size="small" :type="carStateTagType" effect="dark">{{ carInfo.stateLabel }}</el-tag> <el-tag size="small" :type="vehicleSummary.runTagType" effect="dark">
{{ vehicleSummary.runLabel }}
</el-tag>
</div> </div>
<div class="name-line">{{ carInfo.name }}</div> <div class="name-line">{{ vehicleSummary.name }}</div>
<div v-if="carInfo.typeShort" class="sub-line">{{ carInfo.typeShort }}</div>
</section> </section>
<section v-if="statusDisplayRows.length" class="card"> <section class="card">
<header class="card-hd"> <header class="card-hd"><span>运行信息</span></header>
<span>状态</span>
<span class="meta">{{ statusDisplayRows.length }}</span>
</header>
<div class="kv-list"> <div class="kv-list">
<div <div class="kv-row">
v-for="row in statusDisplayRows" <span class="k">交管状态</span>
:key="row.key" <span class="v" :title="vehicleSummary.traffic">{{ vehicleSummary.traffic }}</span>
class="kv-row"
:class="{ 'is-warn': row.warn }">
<span class="k">{{ row.key }}</span>
<span class="v" :title="row.value">{{ row.value }}</span>
</div> </div>
</div> <div class="kv-row">
</section> <span class="k">运行状态</span>
<span class="v">{{ vehicleSummary.runLabel }}</span>
<section v-if="fieldDisplayRows.length" class="card"> </div>
<header class="card-hd"> <div class="kv-row" :class="{ 'is-warn': vehicleSummary.alarmWarn }">
<span>属性</span> <span class="k">报警状态</span>
<span class="meta">{{ fieldDisplayRows.length }}</span> <span class="v" :title="vehicleSummary.alarm">{{ vehicleSummary.alarm }}</span>
</header> </div>
<div class="kv-list"> <div class="kv-row">
<div v-for="row in fieldDisplayRows" :key="row.key" class="kv-row"> <span class="k">电量</span>
<span class="k">{{ row.key }}</span> <span class="v" :class="{ 'is-low-battery': vehicleSummary.batteryLow }">
<span class="v" :title="row.value">{{ row.value }}</span> {{ vehicleSummary.battery }}
</span>
</div>
<div class="kv-row">
<span class="k">位置</span>
<span class="v mono" :title="vehicleSummary.position">{{ vehicleSummary.position }}</span>
</div> </div>
</div> </div>
</section> </section>
<section class="card card--actions"> <section class="card card--actions">
<header class="card-hd"> <header class="card-hd">
<span>动作</span> <span>{{ readOnly ? '运维动作' : '动作' }}</span>
<span v-if="carActions.length" class="meta">{{ carActions.length }}</span> <span v-if="!readOnly && carActions.length" class="meta">{{ carActions.length }}</span>
<span v-else-if="readOnly && opsCarActions.length" class="meta">{{ opsCarActions.length }}</span>
</header> </header>
<div v-if="actionsLoading" class="muted-line">加载动作</div> <template v-if="readOnly">
<div v-else-if="!carActions.length" class="muted-line">{{ carActionHint }}</div> <div v-if="!opsCarActions.length" class="muted-line">当前账号无可执行的运维动作</div>
<div v-else class="action-grid action-grid--vehicle"> <div v-else class="action-grid action-grid--vehicle">
<button <button
v-for="m in carActions" v-for="op in opsCarActions"
:key="m.methodName" :key="op.code"
type="button" type="button"
class="action-btn" class="action-btn"
:class="{ 'action-btn--goto': methodNeedsSitePick(m) }" :class="{ 'action-btn--goto': op.needConfirm }"
:disabled="executing === m.methodName" :disabled="executing === op.code"
@click="onExecute(m)"> :title="op.description"
{{ m.label || m.methodName }} @click="onOpsExecute(op)">
</button> {{ op.label }}
</div> </button>
</div>
</template>
<template v-else>
<div v-if="actionsLoading" class="muted-line">加载动作</div>
<div v-else-if="!carActions.length" class="muted-line">{{ carActionHint }}</div>
<div v-else class="action-grid action-grid--vehicle">
<button
v-for="m in carActions"
:key="m.methodName"
type="button"
class="action-btn"
:class="{ 'action-btn--goto': methodNeedsSitePick(m) }"
:disabled="executing === m.methodName"
@click="onExecute(m)">
{{ m.label || m.methodName }}
</button>
</div>
</template>
</section> </section>
</template> </template>
@@ -140,7 +158,7 @@
</div> </div>
</div> </div>
</section> </section>
<section v-if="trackActions.length" class="card card--actions"> <section v-if="!readOnly && trackActions.length" class="card card--actions">
<header class="card-hd"><span>动作</span></header> <header class="card-hd"><span>动作</span></header>
<div class="action-grid"> <div class="action-grid">
<button <button
@@ -173,7 +191,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, ref, watch } from 'vue' 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 { Loading } from '@element-plus/icons-vue'
import SitePickDialog from '@/components/workbench/SitePickDialog.vue' import SitePickDialog from '@/components/workbench/SitePickDialog.vue'
import { import {
@@ -190,6 +208,9 @@ import {
pickTargetSiteOnMap, pickTargetSiteOnMap,
type SitePickOption type SitePickOption
} from '@/utils/carActionExecute' } 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 { Car } from '@/types/car'
import type { Mission } from '@/types/mission' import type { Mission } from '@/types/mission'
import type { SelectedObjectRef } from '@/types/workbench' import type { SelectedObjectRef } from '@/types/workbench'
@@ -201,8 +222,12 @@ const props = defineProps<{
refreshKey?: number refreshKey?: number
cars?: Car[] cars?: Car[]
missions?: Mission[] missions?: Mission[]
/** 运营端只读模式:3D 不可编辑,车辆动作改用运维白名单(executeOp + 审计),并跳过 reflection 动作/配置加载。 */
readOnly?: boolean
}>() }>()
const auth = useAuthStore()
const initialLoading = ref(false) const initialLoading = ref(false)
const hydrated = ref(false) const hydrated = ref(false)
const actionsLoading = 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 return car.id.replace(/^C/i, '').replace(/^0+/, '') || car.id
} }
function missionLabelForCar(car: Car | undefined): string { /** 站点/路径:仍展示 bundle 返回的状态键值。 */
if (!car?.missionId) return '无' const statusDisplayRows = computed(() =>
const m = props.missions?.find((x) => x.id === car.missionId) statusRows.value.map((r) => ({
if (!m) return car.missionId
const statusMap: Record<string, string> = {
running: '运行中',
queued: '排队',
completed: '已完成',
failed: '失败',
cancelled: '已取消'
}
return `${m.name || m.id}${statusMap[m.status] ?? m.status}`
}
const carInfo = computed(() => {
const car = findCarInList()
const stateLabels: Record<string, string> = {
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) => ({
key: r.key, key: r.key,
value: r.value || '—', value: r.value || '—',
warn: /alarm|error|fault|block|stop/i.test(`${r.key} ${r.value}`) && r.value !== '0' && 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) function getCarTagsText(): string {
const batteryRaw = pickField('batterySoc', 'battery', '电量') const fromField = pickField('tags')
const batteryPct = batteryRaw if (fromField) return fromField
? `${Math.round(Number(batteryRaw) <= 1 ? Number(batteryRaw) * 100 : Number(batteryRaw))}%` const fromStatus = pickStatus('tags', '标记')
: car != null if (fromStatus) return fromStatus
? `${Math.round(car.batterySoc <= 1 ? car.batterySoc * 100 : car.batterySoc)}%` for (const r of statusRows.value) {
: '—' if (/tag|标记/i.test(r.key) && r.value) return r.value
return [
{ key: '位姿', value: pickStatus('位姿', '站点') || '—', warn: false },
{ key: '任务', value: mission, warn: false },
{ key: '电量', value: batteryPct, warn: Number.parseFloat(batteryPct) < 20 }
]
} }
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(() => { function formatBatteryText(): { text: string; low: boolean } {
if (viewKind.value !== 'vehicle') return [] const car = findCarInList()
const entries = Object.entries(fieldMap.value) const raw = pickField('batterySoc', 'battery', '电量')
.filter(([k, v]) => v != null && v !== '' && !FIELD_SKIP.has(k)) let pct: number | null = null
.map(([key, value]) => ({ key, value })) if (raw) {
return entries.sort((a, b) => a.key.localeCompare(b.key)) 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[] { function resolveCarActionWhitelist(): string[] {
@@ -419,6 +492,37 @@ const carActions = computed(() => {
return allMethods.value.filter((m) => set.has(m.methodName)) return allMethods.value.filter((m) => set.has(m.methodName))
}) })
/** 运营端只读模式下车辆动作改用运维白名单(按当前账号权限过滤)。 */
const opsCarActions = computed<OpsAction[]>(() =>
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(() => { const carActionHint = computed(() => {
if (!monitorConfigLoaded.value) return '加载配置中…' if (!monitorConfigLoaded.value) return '加载配置中…'
if (!Object.keys(carActionByType.value).length) { if (!Object.keys(carActionByType.value).length) {
@@ -484,6 +588,23 @@ async function loadAll() {
else actionsLoading.value = true else actionsLoading.value = true
try { 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) await loadMonitorRuntimeConfig(!soft)
const [bundle, methods] = await Promise.all([ const [bundle, methods] = await Promise.all([
reflectionApi.getBundle(rk, idNum), reflectionApi.getBundle(rk, idNum),
@@ -501,7 +622,11 @@ async function loadAll() {
allMethods.value = methods allMethods.value = methods
hydrated.value = true hydrated.value = true
} catch (e) { } catch (e) {
ElMessage.error(`加载选中信息失败:${(e as Error).message}`) if (props.readOnly) {
console.warn('[MonitorSelection] 只读详情加载失败,降级用列表数据', e)
} else {
ElMessage.error(`加载选中信息失败:${(e as Error).message}`)
}
} finally { } finally {
initialLoading.value = false initialLoading.value = false
actionsLoading.value = false actionsLoading.value = false
@@ -517,13 +642,21 @@ async function onExecute(m: ReflectionMethod) {
if (rk === 'car' && methodNeedsSitePick(m)) { if (rk === 'car' && methodNeedsSitePick(m)) {
pendingGotoMethod.value = m pendingGotoMethod.value = m
sitePickTitle.value = `选择目标站点 — ${m.label || m.methodName}` sitePickTitle.value = `选择目标站点 — ${m.label || m.methodName}`
executing.value = m.methodName
try { try {
const siteId = await pickTargetSiteOnMap()
if (siteId != null) {
await onSitePickConfirm(siteId)
return
}
// 取消地图拾取时回退到站点列表对话框
sitePickSites.value = await loadSitePickOptions() sitePickSites.value = await loadSitePickOptions()
sitePickOpen.value = true
} catch (e) { } catch (e) {
ElMessage.error(`加载站点列表失败:${(e as Error).message}`) ElMessage.error(`加载站点列表失败:${(e as Error).message}`)
return } finally {
if (!sitePickOpen.value) executing.value = null
} }
sitePickOpen.value = true
return return
} }
@@ -592,7 +725,7 @@ watch(
justify-content: center; justify-content: center;
gap: 8px; gap: 8px;
padding: 32px; padding: 32px;
color: rgba(255, 255, 255, 0.7); color: var(--mg-text-muted);
font-size: 13px; font-size: 13px;
} }
.empty-hint { .empty-hint {
@@ -602,12 +735,12 @@ watch(
padding: 24px 8px; padding: 24px 8px;
text-align: center; text-align: center;
} }
.empty-title { font-size: 13px; color: rgba(255, 255, 255, 0.9); font-weight: 500; } .empty-title { font-size: 13px; color: var(--mg-text-light); font-weight: 500; }
.empty-desc { font-size: 11.5px; color: rgba(255, 255, 255, 0.65); } .empty-desc { font-size: 11.5px; color: var(--mg-text-dim); }
.card { .card {
background: rgba(255, 255, 255, 0.04); background: var(--mg-veil-1);
border: 1px solid rgba(255, 255, 255, 0.1); border: 1px solid var(--mg-veil-border);
border-radius: 8px; border-radius: 8px;
padding: 8px 10px; padding: 8px 10px;
flex: none; flex: none;
@@ -628,13 +761,13 @@ watch(
font-size: 11px; font-size: 11px;
font-weight: 600; font-weight: 600;
letter-spacing: 0.8px; letter-spacing: 0.8px;
color: rgba(220, 180, 250, 0.88); color: var(--mg-text-muted);
text-transform: uppercase; text-transform: uppercase;
} }
.card-hd .meta { .card-hd .meta {
font-size: 10px; font-size: 10px;
font-weight: 400; font-weight: 400;
color: rgba(200, 180, 220, 0.55); color: var(--mg-text-dim);
text-transform: none; text-transform: none;
letter-spacing: 0; letter-spacing: 0;
} }
@@ -650,24 +783,24 @@ watch(
font-weight: 600; font-weight: 600;
padding: 1px 7px; padding: 1px 7px;
border-radius: 4px; border-radius: 4px;
background: rgba(170, 110, 250, 0.35); background: var(--mg-primary);
color: #fff; color: #fff;
} }
.type-tag { .type-tag {
font-size: 11px; font-size: 11px;
color: rgba(232, 215, 245, 0.75); color: var(--mg-text-muted);
} }
.name-line { .name-line {
font-size: 14px; font-size: 14px;
font-weight: 600; font-weight: 600;
color: #fff; color: var(--mg-text-light);
line-height: 1.3; line-height: 1.3;
word-break: break-all; word-break: break-all;
} }
.sub-line { .sub-line {
margin-top: 2px; margin-top: 2px;
font-size: 11px; font-size: 11px;
color: rgba(200, 180, 230, 0.7); color: var(--mg-text-muted);
font-family: ui-monospace, Consolas, monospace; font-family: ui-monospace, Consolas, monospace;
} }
@@ -685,10 +818,11 @@ watch(
font-size: 12px; font-size: 12px;
line-height: 1.35; line-height: 1.35;
padding: 4px 0; 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: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 { .kv {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -696,12 +830,12 @@ watch(
min-width: 0; min-width: 0;
} }
.k, .kv-row .k { .k, .kv-row .k {
color: rgba(200, 180, 230, 0.75); color: var(--mg-text-muted);
font-size: 11px; font-size: 11px;
word-break: break-word; word-break: break-word;
} }
.v, .kv-row .v { .v, .kv-row .v {
color: #fff; color: var(--mg-text-light);
word-break: break-word; word-break: break-word;
font-size: 12px; font-size: 12px;
} }
@@ -718,8 +852,8 @@ watch(
.action-btn { .action-btn {
appearance: none; appearance: none;
cursor: pointer; cursor: pointer;
border: 1px solid rgba(167, 139, 250, 0.55); border: 1px solid var(--mg-action-border);
background: rgba(76, 29, 149, 0.45); background: var(--mg-action-bg);
color: #fff; color: #fff;
font-size: 12px; font-size: 12px;
font-weight: 600; font-weight: 600;
@@ -734,8 +868,8 @@ watch(
background: rgba(120, 70, 20, 0.45); background: rgba(120, 70, 20, 0.45);
} }
.action-btn:hover:not(:disabled) { .action-btn:hover:not(:disabled) {
background: rgba(109, 40, 217, 0.75); background: var(--mg-action-bg-hover);
border-color: rgba(196, 181, 253, 0.9); border-color: var(--mg-action-border-hi);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.25); box-shadow: 0 2px 8px rgba(0, 0, 0, 0.25);
} }
.action-btn--goto:hover:not(:disabled) { .action-btn--goto:hover:not(:disabled) {
@@ -744,9 +878,20 @@ watch(
} }
.action-btn:disabled { opacity: 0.55; cursor: progress; } .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 { .muted-line {
font-size: 11.5px; font-size: 11.5px;
color: rgba(255, 255, 255, 0.65); color: var(--mg-text-dim);
line-height: 1.4; line-height: 1.4;
padding: 2px 0; padding: 2px 0;
} }
@@ -430,34 +430,34 @@ function toDetailFields(rows: Array<ReflectionKv | { key: string; value: string
<style scoped> <style scoped>
.selection-detail { display: flex; flex-direction: column; gap: 8px; min-height: 0; } .selection-detail { display: flex; flex-direction: column; gap: 8px; min-height: 0; }
.head { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; } .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__header) { margin-bottom: 0; }
.detail-tabs :deep(.el-tabs__item) { color: rgba(255, 255, 255, 0.82) !important; } .detail-tabs :deep(.el-tabs__item) { color: var(--mg-text-muted) !important; }
.detail-tabs :deep(.el-tabs__item.is-active) { color: #fff !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: #fff; } .detail-body { flex: 1; min-height: 120px; overflow: auto; color: var(--mg-text-light); }
.detail-body :deep(.el-descriptions__label) { color: rgba(255, 255, 255, 0.88) !important; } .detail-body :deep(.el-descriptions__label) { color: var(--mg-text-muted) !important; }
.detail-body :deep(.el-descriptions__content), .detail-body :deep(.el-descriptions__content),
.detail-body :deep(.el-table .cell) { .detail-body :deep(.el-table .cell) {
color: #fff !important; color: var(--mg-text-light) !important;
} }
.section { margin-top: 10px; } .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-list { display: flex; flex-direction: column; gap: 6px; }
.action-btn { .action-btn {
justify-content: flex-start; justify-content: flex-start;
border-color: rgba(167, 139, 250, 0.65); border-color: var(--mg-action-border);
background: rgba(76, 29, 149, 0.45); background: var(--mg-action-bg);
color: #fff !important; color: #fff !important;
} }
.action-btn:hover { .action-btn:hover {
border-color: rgba(196, 181, 253, 0.95); border-color: var(--mg-action-border-hi);
background: rgba(109, 40, 217, 0.75); background: var(--mg-action-bg-hover);
} }
.action-btn :deep(span) { .action-btn :deep(span) {
color: #fff !important; color: #fff !important;
font-weight: 600; font-weight: 600;
} }
.action-btn .hint-icon { margin-left: 6px; font-size: 13px; opacity: 0.8; } .action-btn .hint-icon { margin-left: 6px; font-size: 13px; opacity: 0.8; }
.empty { font-size: 12px; padding: 8px 0; color: #fff; } .empty { font-size: 12px; padding: 8px 0; color: var(--mg-text-light); }
.muted { color: rgba(255, 255, 255, 0.78); } .muted { color: var(--mg-text-muted); }
</style> </style>
@@ -100,28 +100,33 @@ defineExpose({ close: () => { visible.value = false }, endMapPick: () => { mapPi
</script> </script>
<style scoped> <style scoped>
.hint { margin: 0 0 10px; font-size: 13px; color: rgba(255, 255, 255, 0.78); } /* el-dialog 会 teleport 到 body,脱离 .mg-content 的深色覆盖,在所有主题下都是白底,
因此对话框内自定义内容统一用固定深紫字 + 浅紫面板(不能用 --mg-text-* 变量,
那些在深色主题里是白色会变白底白字)。 */
.hint { margin: 0 0 10px; font-size: 13px; color: rgba(60, 30, 110, 0.75); }
.filter-input { margin-bottom: 8px; } .filter-input { margin-bottom: 8px; }
.site-scroll { border: 1px solid rgba(255, 255, 255, 0.12); border-radius: 8px; } .site-scroll { border: 1px solid rgba(170, 110, 250, 0.18); border-radius: 8px; }
.site-row { .site-row {
display: flex; display: flex;
gap: 8px; gap: 8px;
width: 100%; width: 100%;
padding: 8px 10px; padding: 8px 10px;
border: none; border: none;
border-bottom: 1px solid rgba(255, 255, 255, 0.06); border-bottom: 1px solid rgba(170, 110, 250, 0.10);
background: transparent; background: transparent;
color: #fff; color: rgba(60, 30, 110, 0.92);
text-align: left; text-align: left;
cursor: pointer; cursor: pointer;
transition: background 0.12s ease; transition: background 0.12s ease;
} }
.site-row:hover { background: rgba(167, 139, 250, 0.15); } .site-row:hover { background: rgba(167, 139, 250, 0.15); }
.site-row.active { .site-row.active {
background: rgba(109, 40, 217, 0.45); background: rgba(124, 58, 237, 0.16);
box-shadow: inset 3px 0 0 rgba(196, 181, 253, 0.95); color: #5b21b6;
font-weight: 600;
box-shadow: inset 3px 0 0 #7c3aed;
} }
.sid { font-family: ui-monospace, Consolas, monospace; font-size: 12px; color: rgba(220, 180, 250, 0.9); flex: none; } .sid { font-family: ui-monospace, Consolas, monospace; font-size: 12px; color: #7c3aed; flex: none; }
.sname { flex: 1; font-size: 13px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .sname { flex: 1; font-size: 13px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.empty { padding: 20px; text-align: center; color: rgba(255, 255, 255, 0.55); font-size: 12px; } .empty { padding: 20px; text-align: center; color: rgba(120, 90, 160, 0.7); font-size: 12px; }
</style> </style>
@@ -56,10 +56,8 @@
@click="onSelect(car)" @click="onSelect(car)"
> >
<div class="vehicle-head"> <div class="vehicle-head">
<div class="vehicle-id-name"> <span class="vid">{{ car.id }}</span>
<span class="vid">{{ car.id }}</span> <span class="vname" :title="car.name">{{ car.name }}</span>
<span class="vname">{{ car.name }}</span>
</div>
<el-tag <el-tag
size="small" size="small"
:type="stateTagType(car.state)" :type="stateTagType(car.state)"
@@ -71,45 +69,37 @@
</el-tag> </el-tag>
</div> </div>
<div class="vehicle-meta muted"> <div class="vehicle-sub">
<span v-if="car.typeName" class="meta-item">{{ shortType(car.typeName) }}</span> <div class="battery-mini" :title="`电量 ${batteryPct(car)}%`">
<span v-if="car.group" class="meta-item">{{ car.group }}</span> <el-progress
<span v-if="car.ip" class="meta-item">{{ car.ip }}</span> :percentage="batteryPct(car)"
</div> :stroke-width="6"
:show-text="false"
<div class="battery-row"> :color="batteryColor(batteryPct(car))"
<span class="battery-label">电量</span> class="battery-bar"
<el-progress />
:percentage="batteryPct(car)" <span class="battery-text" :class="batteryLevel(batteryPct(car))">
:stroke-width="8" {{ batteryPct(car) }}%
:show-text="false"
:color="batteryColor(batteryPct(car))"
class="battery-bar"
/>
<span class="battery-text" :class="batteryLevel(batteryPct(car))">
{{ batteryPct(car) }}%
</span>
</div>
<div class="mission-row">
<span class="mission-label">任务</span>
<template v-if="missionForCar(car)">
<span class="mission-name" :title="missionForCar(car)!.name">
{{ missionForCar(car)!.name }}
</span> </span>
<el-tag </div>
size="small" <div class="mission-mini">
:type="missionStatusTagType(missionForCar(car)!.status)" <template v-if="missionForCar(car)">
effect="plain" <el-tag
class="mission-tag" size="small"
> :type="missionStatusTagType(missionForCar(car)!.status)"
{{ missionStatusLabel(missionForCar(car)!.status) }} effect="plain"
</el-tag> class="mission-tag"
</template> >
<template v-else> {{ missionStatusLabel(missionForCar(car)!.status) }}
<span class="mission-name muted">无任务</span> </el-tag>
<el-tag size="small" type="info" effect="plain" class="mission-tag">空闲</el-tag> <span class="mission-name" :title="missionForCar(car)!.name">
</template> {{ missionForCar(car)!.name }}
</span>
</template>
<template v-else>
<span class="mission-name muted">空闲</span>
</template>
</div>
</div> </div>
</div> </div>
@@ -258,11 +248,6 @@ function batteryLevel(p: number): 'low' | 'mid' | 'high' {
return 'high' return 'high'
} }
function shortType(typeName: string): string {
const idx = typeName.lastIndexOf('.')
return idx >= 0 ? typeName.slice(idx + 1) : typeName
}
const filteredCars = computed(() => { const filteredCars = computed(() => {
const q = search.value.trim().toLowerCase() const q = search.value.trim().toLowerCase()
const tokens = q ? q.split(/\s+/).filter(Boolean) : [] const tokens = q ? q.split(/\s+/).filter(Boolean) : []
@@ -312,8 +297,8 @@ function detailIdForCar(car: Car): string {
flex: none; flex: none;
} }
.overview-item { .overview-item {
background: rgba(255, 255, 255, 0.05); background: var(--mg-veil-1);
border: 1px solid rgba(255, 255, 255, 0.12); border: 1px solid var(--mg-veil-border);
border-radius: 8px; border-radius: 8px;
padding: 6px 4px; padding: 6px 4px;
text-align: center; text-align: center;
@@ -326,16 +311,16 @@ function detailIdForCar(car: Car): string {
.overview-num { .overview-num {
font-size: 18px; font-size: 18px;
font-weight: 600; font-weight: 600;
color: #fff; color: var(--mg-text-light);
line-height: 1.2; 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.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.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 { .overview-label {
font-size: 11px; font-size: 11px;
color: rgba(255, 255, 255, 0.7); color: var(--mg-text-muted);
margin-top: 2px; margin-top: 2px;
} }
@@ -358,14 +343,14 @@ function detailIdForCar(car: Car): string {
} }
.vehicle-row { .vehicle-row {
background: rgba(255, 255, 255, 0.04); background: var(--mg-veil-1);
border: 1px solid rgba(255, 255, 255, 0.1); border: 1px solid var(--mg-veil-border);
border-radius: 8px; border-radius: 6px;
padding: 8px 10px; padding: 6px 8px;
cursor: pointer; cursor: pointer;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 4px; gap: 3px;
transition: all 0.18s ease; transition: all 0.18s ease;
} }
.vehicle-row:hover { .vehicle-row:hover {
@@ -381,25 +366,20 @@ function detailIdForCar(car: Car): string {
.vehicle-head { .vehicle-head {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between;
gap: 8px;
}
.vehicle-id-name {
display: flex;
align-items: baseline;
gap: 6px; gap: 6px;
min-width: 0;
} }
.vid { .vid {
font-size: 12px; font-size: 11.5px;
color: rgba(255, 255, 255, 0.7); color: var(--mg-text-muted);
font-family: ui-monospace, 'SFMono-Regular', 'Menlo', monospace; font-family: ui-monospace, 'SFMono-Regular', 'Menlo', monospace;
flex: none; flex: none;
} }
.vname { .vname {
color: #fff; flex: 1;
min-width: 0;
color: var(--mg-text-light);
font-weight: 600; font-weight: 600;
font-size: 13px; font-size: 12.5px;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; 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-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-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-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); } .dot-idle { color: var(--mg-accent); box-shadow: 0 0 6px rgba(var(--mg-accent-rgb), 0.6); }
@keyframes pulse { @keyframes pulse {
@@ -430,55 +410,54 @@ function detailIdForCar(car: Car): string {
50% { opacity: 0.35; } 50% { opacity: 0.35; }
} }
.vehicle-meta { .vehicle-sub {
display: flex;
flex-wrap: wrap;
gap: 8px;
font-size: 11px;
line-height: 1.3;
}
.meta-item { white-space: nowrap; }
.battery-row,
.mission-row {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 8px; gap: 10px;
font-size: 12px; font-size: 11.5px;
} }
.battery-label, .battery-mini {
.mission-label { display: flex;
width: 32px; align-items: center;
color: rgba(255, 255, 255, 0.65); gap: 6px;
flex: none; flex: 0 0 44%;
min-width: 0;
}
.mission-mini {
display: flex;
align-items: center;
gap: 6px;
flex: 1;
min-width: 0;
} }
.battery-bar { .battery-bar {
flex: 1; flex: 1;
min-width: 0; min-width: 0;
} }
.battery-bar :deep(.el-progress-bar__outer) { .battery-bar :deep(.el-progress-bar__outer) {
background: rgba(255, 255, 255, 0.1); background: var(--mg-veil-3);
} }
.battery-text { .battery-text {
width: 38px; width: 34px;
text-align: right; text-align: right;
font-variant-numeric: tabular-nums; font-variant-numeric: tabular-nums;
font-weight: 600; font-weight: 600;
font-size: 11.5px;
flex: none; flex: none;
} }
.battery-text.low { color: #f56c6c; } .battery-text.low { color: var(--mg-status-danger); }
.battery-text.mid { color: #e6a23c; } .battery-text.mid { color: var(--mg-status-warning); }
.battery-text.high { color: #67c23a; } .battery-text.high { color: var(--mg-status-success); }
.mission-name { .mission-name {
flex: 1; flex: 1;
min-width: 0; min-width: 0;
color: #fff; color: var(--mg-text-light);
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; 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; } .mission-tag { flex: none; }
.empty { .empty {
@@ -486,5 +465,5 @@ function detailIdForCar(car: Car): string {
padding: 24px 0; padding: 24px 0;
font-size: 12px; font-size: 12px;
} }
.muted { color: rgba(255, 255, 255, 0.7); } .muted { color: var(--mg-text-muted); }
</style> </style>
@@ -162,15 +162,15 @@ onMounted(loadList)
.workbench-panel :deep(.el-tabs__item), .workbench-panel :deep(.el-tabs__item),
.workbench-panel :deep(.el-table .cell), .workbench-panel :deep(.el-table .cell),
.workbench-panel :deep(.el-input__inner) { .workbench-panel :deep(.el-input__inner) {
color: #fff; color: var(--mg-text-light);
} }
.toolbar-hint { color: rgba(255, 255, 255, 0.88); } .toolbar-hint { color: var(--mg-text-muted); }
.muted { color: rgba(255, 255, 255, 0.75); } .muted { color: var(--mg-text-muted); }
:deep(.el-table th.el-table__cell) { :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 { :deep(.is-selected-row) td {
background: rgba(var(--mg-primary-hover-rgb), 0.28) !important; background: rgba(var(--mg-primary-hover-rgb), 0.28) !important;
color: #fff !important; color: var(--mg-text-light) !important;
} }
</style> </style>
@@ -115,6 +115,24 @@
--el-color-primary-dark-2: var(--mg-primary-dark-2); --el-color-primary-dark-2: var(--mg-primary-dark-2);
--mg-transition: all .25s cubic-bezier(.25, .8, .25, 1); --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 { html {
@@ -920,6 +938,19 @@ a:hover { color: rgba(var(--mg-accent-rgb), 0.85); }
--mg-status-info-rgb: 29, 78, 216; --mg-status-info-rgb: 29, 78, 216;
--mg-status-idle: #64748b; --mg-status-idle: #64748b;
--mg-status-idle-rgb: 100, 116, 139; --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:1WCAG AA 通过) */
--mg-action-bg: #7c3aed;
--mg-action-bg-hover: #6d28d9;
--mg-action-border: #7c3aed;
--mg-action-border-hi: #6d28d9;
} }
/* body:纯净紫白渐变(更柔和、长时间阅读不刺眼) */ /* body:纯净紫白渐变(更柔和、长时间阅读不刺眼) */
@@ -47,14 +47,41 @@ export async function pickTargetSiteOnMap(): Promise<number | null> {
} }
} }
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<boolean> { export async function executeCarGotoSite(carId: number, siteId: number): Promise<boolean> {
try { try {
const r = await reflectionApi.gotoCarSite(carId, siteId) const r = await reflectionApi.gotoCarSite(carId, siteId)
ElMessage.success(r.message || `已下发前往站点 #${siteId}`) ElMessage.success(r.message || `已下发前往站点 #${siteId}`)
return true return true
} catch (e) { } catch (e) {
ElMessage.error(`前往站点失败:${e instanceof Error ? e.message : String(e)}`) if (!isHttp404(e)) {
return false ElMessage.error(`前往站点失败:${e instanceof Error ? e.message : String(e)}`)
return false
}
// goto-site 路由未加载(旧 DLL):回退 execute WebGotoSiteDummyCar / 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
}
} }
} }
@@ -63,13 +63,13 @@ function run(t: CadTool) {
<style scoped> <style scoped>
.tool-card { cursor: pointer; text-align: center; padding: 8px; } .tool-card { cursor: pointer; text-align: center; padding: 8px; }
.tool-name { font-weight: 600; margin-top: 6px; } .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 { .tool-icon {
color: var(--mg-accent); color: var(--mg-accent);
filter: drop-shadow(0 0 8px rgba(var(--mg-accent-rgb), 0.55)); filter: drop-shadow(0 0 8px rgba(var(--mg-accent-rgb), 0.55));
} }
.tool-card:hover .tool-icon { .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)); filter: drop-shadow(0 0 14px rgba(var(--mg-primary-hover-rgb), 0.8));
} }
</style> </style>
@@ -22,7 +22,7 @@
:host="vrHost" :host="vrHost"
:scope="auth.scope ?? 'Platform'" :scope="auth.scope ?? 'Platform'"
:token="auth.token ?? ''" :token="auth.token ?? ''"
:read-only="false" :read-only="!!readOnly"
:embed-ui="true" :embed-ui="true"
@pick="onPick" @pick="onPick"
@select="onSelect" @select="onSelect"
@@ -35,17 +35,31 @@
<el-col :span="8" class="side-col"> <el-col :span="8" class="side-col">
<el-card shadow="never" class="side-card workbench-card"> <el-card shadow="never" class="side-card workbench-card">
<template #header> <template #header>
<span>车辆监控台</span> <div class="workbench-header">
<el-button link type="primary" size="small" :loading="refreshing" @click="refreshAll"> <el-tabs v-model="workbenchTab" class="workbench-tabs">
刷新 <el-tab-pane label="车辆监控台" name="vehicle" />
</el-button> <el-tab-pane label="任务列表" name="mission" />
</el-tabs>
<el-button link type="primary" size="small" :loading="refreshing" @click="refreshAll">
刷新
</el-button>
</div>
</template> </template>
<VehicleMonitorPanel <VehicleMonitorPanel
v-show="workbenchTab === 'vehicle'"
:cars="cars" :cars="cars"
:missions="missions" :missions="missions"
:selected-id="selectedVehicleId" :selected-id="selectedVehicleId"
@select="onVehicleSelect" @select="onVehicleSelect"
/> />
<MissionListPanel
v-show="workbenchTab === 'mission'"
:deliveries="deliveries"
:selected-id="selectedDeliveryId"
@select="onDeliverySelect"
@refresh="onDeliveryListRefresh"
@action-done="refreshDeliveries"
/>
</el-card> </el-card>
<el-card shadow="never" class="side-card detail-card"> <el-card shadow="never" class="side-card detail-card">
<template #header><span>选中信息</span></template> <template #header><span>选中信息</span></template>
@@ -54,6 +68,7 @@
:refresh-key="tick" :refresh-key="tick"
:cars="cars" :cars="cars"
:missions="missions" :missions="missions"
:read-only="!!readOnly"
/> />
</el-card> </el-card>
</el-col> </el-col>
@@ -65,27 +80,39 @@
import { computed, onMounted, onUnmounted, ref } from 'vue' import { computed, onMounted, onUnmounted, ref } from 'vue'
import Workspace3D from '@/components/Workspace3D.vue' import Workspace3D from '@/components/Workspace3D.vue'
import VehicleMonitorPanel from '@/components/workbench/VehicleMonitorPanel.vue' import VehicleMonitorPanel from '@/components/workbench/VehicleMonitorPanel.vue'
import MissionListPanel from '@/components/workbench/MissionListPanel.vue'
import MonitorSelectionPanel from '@/components/workbench/MonitorSelectionPanel.vue' import MonitorSelectionPanel from '@/components/workbench/MonitorSelectionPanel.vue'
import FloatingAlarmStack from '@/components/map-monitor/FloatingAlarmStack.vue' import FloatingAlarmStack from '@/components/map-monitor/FloatingAlarmStack.vue'
import WorkspaceCanvasToolbar from '@/components/workspace/WorkspaceCanvasToolbar.vue' import WorkspaceCanvasToolbar from '@/components/workspace/WorkspaceCanvasToolbar.vue'
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus'
import { useAuthStore } from '@/stores/auth' import { useAuthStore } from '@/stores/auth'
import { listCars, listMissions } from '@/api/projection' import { listCars, listMissions } from '@/api/projection'
import { listDeliveries } from '@/api/delivery'
import { useProjectionStream, type StreamEvent } from '@/composables/useProjectionStream' import { useProjectionStream, type StreamEvent } from '@/composables/useProjectionStream'
import type { AlarmEvent, SelectionDetailEvent } from '@/composables/useMapEditStream' import type { AlarmEvent, SelectionDetailEvent } from '@/composables/useMapEditStream'
import { reflectionApi } from '@/api/reflection' import { reflectionApi } from '@/api/reflection'
import type { Car } from '@/types/car' 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 { SelectedObjectRef } from '@/types/workbench' import type { SelectedObjectRef } from '@/types/workbench'
defineProps<{
/** 只读模式(运营端复用 MapMonitorView 时传 true):3D 不可编辑,选中信息面板动作改用运维白名单。 */
readOnly?: boolean
}>()
const auth = useAuthStore() const auth = useAuthStore()
const vrHost = (import.meta.env.VITE_VRENDER_HOST as string | undefined) ?? 'localhost:8223' const vrHost = (import.meta.env.VITE_VRENDER_HOST as string | undefined) ?? 'localhost:8223'
const cars = ref<Car[]>([]) const cars = ref<Car[]>([])
const missions = ref<Mission[]>([]) const missions = ref<Mission[]>([])
const deliveries = ref<DeliveryTask[]>([])
const deliveryListOpts = ref({ includeFinished: true, includeAborted: true })
const selection = ref<SelectedObjectRef | null>(null) const selection = ref<SelectedObjectRef | null>(null)
const tick = ref(0) const tick = ref(0)
const refreshing = ref(false) 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 workspaceRef = ref<InstanceType<typeof Workspace3D> | null>(null)
/** /**
@@ -122,8 +149,16 @@ function onAlarmAck(_a: AlarmEvent) {
let pollTimer: ReturnType<typeof setInterval> | null = null let pollTimer: ReturnType<typeof setInterval> | null = null
const onlineCars = computed(() => cars.value.filter((c) => c.state !== 'offline').length) const onlineCars = computed(() => cars.value.filter((c) => c.state !== 'offline').length)
const runningMissions = computed(() => missions.value.filter((m) => m.status === 'running').length) const runningMissions = computed(() =>
const queuedMissions = computed(() => missions.value.filter((m) => m.status === 'queued').length) 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(() => { const selectedVehicleId = computed(() => {
if (!selection.value || selection.value.kind !== 'vehicle') return null if (!selection.value || selection.value.kind !== 'vehicle') return null
@@ -168,6 +203,7 @@ function sameSelection(a: SelectedObjectRef | null, b: SelectedObjectRef | null)
/** 把解析结果写入侧栏 selection;仅当对象变化时刷新详情,避免选中闪烁。 */ /** 把解析结果写入侧栏 selection;仅当对象变化时刷新详情,避免选中闪烁。 */
function applyParsedSelection(parsed: { kind: 'vehicle' | 'site' | 'track'; id: string }, rawName?: string) { function applyParsedSelection(parsed: { kind: 'vehicle' | 'site' | 'track'; id: string }, rawName?: string) {
selectedDeliveryId.value = null
let next: SelectedObjectRef let next: SelectedObjectRef
if (parsed.kind === 'vehicle') { if (parsed.kind === 'vehicle') {
const car = (rawName ? findSelectedCar(rawName) : undefined) 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[]) { function applySelectionFromKindId(kind: 'site' | 'track' | 'car', id: number, names?: readonly string[]) {
selectedDeliveryId.value = null
if (kind === 'car') { if (kind === 'car') {
const sid = String(id) const sid = String(id)
const rawName = names?.find((n) => RX_CAR.test(n)) ?? `Car-${id}` const rawName = names?.find((n) => RX_CAR.test(n)) ?? `Car-${id}`
@@ -293,6 +330,7 @@ async function fallbackSelectionFromBackend() {
} }
async function onVehicleSelect(ref: SelectedObjectRef) { async function onVehicleSelect(ref: SelectedObjectRef) {
selectedDeliveryId.value = null
if (!sameSelection(selection.value, ref)) { if (!sameSelection(selection.value, ref)) {
selection.value = ref selection.value = ref
tick.value++ 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 { function findSelectedCar(name: string): Car | undefined {
const parsedId = parseWorkspaceCarId(name) const parsedId = parseWorkspaceCarId(name)
return cars.value.find((c) => return cars.value.find((c) =>
@@ -328,8 +409,10 @@ function detailIdForCar(car: Car): string {
async function refreshAll() { async function refreshAll() {
refreshing.value = true refreshing.value = true
try { try {
cars.value = await listCars() const [carList, missionList] = await Promise.all([listCars(), listMissions()])
missions.value = await listMissions() cars.value = carList
missions.value = missionList
await refreshDeliveries()
} catch (err) { } catch (err) {
console.warn('[MapMonitor] refreshAll failed', err) console.warn('[MapMonitor] refreshAll failed', err)
} finally { } finally {
@@ -385,7 +468,11 @@ onMounted(async () => {
await refreshAll() await refreshAll()
pollTimer = setInterval(() => { pollTimer = setInterval(() => {
// SSE projection/cars 502 connected true // 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) }, 3000)
}) })
@@ -411,8 +498,8 @@ onUnmounted(() => {
flex-direction: column; flex-direction: column;
min-height: 0; min-height: 0;
} }
.workbench-card { flex: 1.2; } .workbench-card { flex: 1; min-height: 0; }
.detail-card { flex: 1; } .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;
@@ -421,10 +508,39 @@ onUnmounted(() => {
display: flex; display: flex;
flex-direction: column; 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), .workbench-card :deep(.el-card__header),
.detail-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-row.main-row) { height: 100%; }
:deep(.el-col-16 > div) { height: 100%; min-height: 0; } :deep(.el-col-16 > div) { height: 100%; min-height: 0; }
@@ -144,22 +144,22 @@ onMounted(() => reload())
<style scoped> <style scoped>
.mmc-card { margin: 0; } .mmc-card { margin: 0; }
.mmc-header { display: flex; align-items: center; gap: 8px; } .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-header .spacer { flex: 1; }
.mmc-desc { .mmc-desc {
color: rgba(232, 215, 245, 0.78); color: var(--mg-text-muted);
font-size: 12.5px; font-size: 12.5px;
margin: 0 0 12px; margin: 0 0 12px;
line-height: 1.7; line-height: 1.7;
} }
.mmc-desc code { .mmc-desc code {
background: rgba(255, 255, 255, 0.08); background: var(--mg-veil-2);
padding: 1px 5px; padding: 1px 5px;
border-radius: 4px; border-radius: 4px;
font-family: ui-monospace, Menlo, Consolas, monospace; 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) { color: var(--mg-text-muted) !important; }
.mmc-tabs :deep(.el-tabs__item.is-active) { color: #fff !important; } .mmc-tabs :deep(.el-tabs__item.is-active) { color: var(--mg-text-light) !important; }
.mmc-tab-body { .mmc-tab-body {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -8,10 +8,10 @@
<el-form label-width="160px" :model="payload"> <el-form label-width="160px" :model="payload">
<el-form-item label="分配模式"> <el-form-item label="分配模式">
<el-radio-group :model-value="payload.mode" @update:model-value="(v: any) => update({ ...payload, mode: v })"> <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 value="roundRobin">轮询</el-radio-button>
<el-radio-button label="nearest">就近</el-radio-button> <el-radio-button value="nearest">就近</el-radio-button>
<el-radio-button label="leastLoad">最少负载</el-radio-button> <el-radio-button value="leastLoad">最少负载</el-radio-button>
<el-radio-button label="custom">自定义</el-radio-button> <el-radio-button value="custom">自定义</el-radio-button>
</el-radio-group> </el-radio-group>
</el-form-item> </el-form-item>
<el-form-item label="启用负载均衡"> <el-form-item label="启用负载均衡">
@@ -78,5 +78,5 @@ onMounted(async () => {
</script> </script>
<style scoped> <style scoped>
.muted { color: rgba(232, 215, 245, 0.55); font-size: 12px; } .muted { color: var(--mg-text-dim); font-size: 12px; }
</style> </style>
@@ -1,131 +1,19 @@
<template> <template>
<div class="monitor-map-page"> <MapMonitorView read-only />
<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>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, ref } from 'vue' /**
import { ElMessage, ElMessageBox } from 'element-plus' * 运营端RCSMonitor地图监控页复用管理员端 MapMonitorView统一界面仅以 read-only 区分
import { InfoFilled } from '@element-plus/icons-vue' *
import Workspace3D from '@/components/Workspace3D.vue' * 只读模式下
import WorkspaceCanvasToolbar from '@/components/workspace/WorkspaceCanvasToolbar.vue' * - 3D 画布不可编辑Workspace3D read-onlyscope MapMonitorView 内部按 auth.scope
import { useAuthStore } from '@/stores/auth' * 运营端登录后即 RCSMonitor
import { OPS_WHITELIST, type OpsAction } from '@/types/ops' * - 右侧选中信息面板的车辆动作改用运维白名单OPS_WHITELIST + executeOp 审计 + 二次确认
import { listCars, listMissions } from '@/api/projection' * 按当前账号 hasOp 过滤站点/路径的编辑动作区隐藏
import { executeOp } from '@/api/ops' *
* 备注原运营端地图页内的任务白名单动作暂停/取消/重派等请走 /monitor/ops 运维操作页
const auth = useAuthStore() * 地图监控页聚焦于地图对象//的监视与车辆运维动作
const vrHost = (import.meta.env.VITE_VRENDER_HOST as string | undefined) ?? 'localhost:8223' */
import MapMonitorView from '@/views/admin/MapMonitorView.vue'
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 }))
})
</script> </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>