refactor(web): 工作台与监控视图 UI 优化
- MonitorSelectionPanel / VehicleMonitorPanel / SelectionDetailPanel 等工作台面板交互与展示优化 - carActionExecute:前往站点遇 404(旧 DLL 无 goto-site 路由)时回退 WebGotoSite,并给出热更新指引 - 地图监控、任务分配、反射管理等视图细节调整;theme.css 补充样式
This commit is contained in:
@@ -142,10 +142,10 @@ onMounted(() => reload())
|
||||
|
||||
<style scoped>
|
||||
.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-desc {
|
||||
color: rgba(232, 215, 245, 0.7);
|
||||
color: var(--mg-text-muted);
|
||||
font-size: 12.5px;
|
||||
margin: 0 0 14px;
|
||||
line-height: 1.6;
|
||||
@@ -155,8 +155,8 @@ onMounted(() => reload())
|
||||
margin: 0; padding: 14px;
|
||||
font-size: 12px; line-height: 1.55;
|
||||
font-family: ui-monospace, Menlo, Consolas, monospace;
|
||||
background: rgba(15, 4, 32, 0.55);
|
||||
color: #d6c5ee;
|
||||
background: var(--mg-veil-2);
|
||||
color: var(--mg-text-light);
|
||||
max-height: 520px;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
@@ -109,8 +109,8 @@ function clearOrphans() {
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding: 12px 14px;
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
background: var(--mg-veil-1);
|
||||
border: 1px solid var(--mg-veil-border);
|
||||
border-radius: 8px;
|
||||
}
|
||||
.mmcg-head {
|
||||
@@ -122,7 +122,7 @@ function clearOrphans() {
|
||||
.mmcg-title {
|
||||
font-weight: 600;
|
||||
font-size: 13px;
|
||||
color: #fff;
|
||||
color: var(--mg-text-light);
|
||||
}
|
||||
.mmcg-head .spacer { flex: 1; }
|
||||
.mmcg-empty {
|
||||
@@ -130,7 +130,7 @@ function clearOrphans() {
|
||||
font-size: 12px;
|
||||
font-style: italic;
|
||||
}
|
||||
.muted { color: rgba(255, 255, 255, 0.62); }
|
||||
.muted { color: var(--mg-text-dim); }
|
||||
|
||||
.mmcg-grid {
|
||||
display: grid;
|
||||
@@ -143,7 +143,7 @@ function clearOrphans() {
|
||||
height: 28px;
|
||||
}
|
||||
.mmcg-grid :deep(.el-checkbox__label) {
|
||||
color: rgba(255, 255, 255, 0.92);
|
||||
color: var(--mg-text-light);
|
||||
font-size: 12.5px;
|
||||
font-family: ui-monospace, Menlo, Consolas, monospace;
|
||||
}
|
||||
|
||||
+1
-1
@@ -162,7 +162,7 @@ onMounted(refresh)
|
||||
.rkt-actions { margin-top: 8px; }
|
||||
.rkt-method-grid { display: flex; flex-wrap: wrap; gap: 6px; }
|
||||
.rkt-loading-methods, .rkt-empty {
|
||||
color: rgba(232,215,245,0.7);
|
||||
color: var(--mg-text-muted);
|
||||
font-size: 12px;
|
||||
padding: 8px 4px;
|
||||
}
|
||||
|
||||
@@ -150,7 +150,7 @@ defineExpose({ refresh })
|
||||
.plg-title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: rgba(255, 255, 255, 0.92);
|
||||
color: var(--mg-text-light);
|
||||
}
|
||||
.plg-collapse { margin-left: auto; }
|
||||
.plg-tag { margin-right: 4px; margin-bottom: 2px; }
|
||||
@@ -162,11 +162,11 @@ defineExpose({ 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-card__header) {
|
||||
padding: 10px 14px;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-bottom: 1px solid var(--mg-divider);
|
||||
}
|
||||
</style>
|
||||
|
||||
+40
-13
@@ -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;
|
||||
}
|
||||
</style>
|
||||
|
||||
+284
-139
@@ -39,7 +39,7 @@
|
||||
</div>
|
||||
</div>
|
||||
</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>
|
||||
<div class="action-grid">
|
||||
<button
|
||||
@@ -59,62 +59,80 @@
|
||||
<template v-else-if="viewKind === 'vehicle'">
|
||||
<section class="card card--header">
|
||||
<div class="title-row">
|
||||
<span class="id-tag">{{ carInfo.displayId }}</span>
|
||||
<el-tag size="small" :type="carStateTagType" effect="dark">{{ carInfo.stateLabel }}</el-tag>
|
||||
<span class="id-tag">{{ vehicleSummary.id }}</span>
|
||||
<el-tag size="small" :type="vehicleSummary.runTagType" effect="dark">
|
||||
{{ vehicleSummary.runLabel }}
|
||||
</el-tag>
|
||||
</div>
|
||||
<div class="name-line">{{ carInfo.name }}</div>
|
||||
<div v-if="carInfo.typeShort" class="sub-line">{{ carInfo.typeShort }}</div>
|
||||
<div class="name-line">{{ vehicleSummary.name }}</div>
|
||||
</section>
|
||||
|
||||
<section v-if="statusDisplayRows.length" class="card">
|
||||
<header class="card-hd">
|
||||
<span>状态</span>
|
||||
<span class="meta">{{ statusDisplayRows.length }}</span>
|
||||
</header>
|
||||
<section class="card">
|
||||
<header class="card-hd"><span>运行信息</span></header>
|
||||
<div class="kv-list">
|
||||
<div
|
||||
v-for="row in statusDisplayRows"
|
||||
:key="row.key"
|
||||
class="kv-row"
|
||||
:class="{ 'is-warn': row.warn }">
|
||||
<span class="k">{{ row.key }}</span>
|
||||
<span class="v" :title="row.value">{{ row.value }}</span>
|
||||
<div class="kv-row">
|
||||
<span class="k">交管状态</span>
|
||||
<span class="v" :title="vehicleSummary.traffic">{{ vehicleSummary.traffic }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section v-if="fieldDisplayRows.length" class="card">
|
||||
<header class="card-hd">
|
||||
<span>属性</span>
|
||||
<span class="meta">{{ fieldDisplayRows.length }}</span>
|
||||
</header>
|
||||
<div class="kv-list">
|
||||
<div v-for="row in fieldDisplayRows" :key="row.key" class="kv-row">
|
||||
<span class="k">{{ row.key }}</span>
|
||||
<span class="v" :title="row.value">{{ row.value }}</span>
|
||||
<div class="kv-row">
|
||||
<span class="k">运行状态</span>
|
||||
<span class="v">{{ vehicleSummary.runLabel }}</span>
|
||||
</div>
|
||||
<div class="kv-row" :class="{ 'is-warn': vehicleSummary.alarmWarn }">
|
||||
<span class="k">报警状态</span>
|
||||
<span class="v" :title="vehicleSummary.alarm">{{ vehicleSummary.alarm }}</span>
|
||||
</div>
|
||||
<div class="kv-row">
|
||||
<span class="k">电量</span>
|
||||
<span class="v" :class="{ 'is-low-battery': vehicleSummary.batteryLow }">
|
||||
{{ vehicleSummary.battery }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="kv-row">
|
||||
<span class="k">位置</span>
|
||||
<span class="v mono" :title="vehicleSummary.position">{{ vehicleSummary.position }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="card card--actions">
|
||||
<header class="card-hd">
|
||||
<span>动作</span>
|
||||
<span v-if="carActions.length" class="meta">{{ carActions.length }}</span>
|
||||
<span>{{ readOnly ? '运维动作' : '动作' }}</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>
|
||||
<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 v-if="readOnly">
|
||||
<div v-if="!opsCarActions.length" class="muted-line">当前账号无可执行的运维动作。</div>
|
||||
<div v-else class="action-grid action-grid--vehicle">
|
||||
<button
|
||||
v-for="op in opsCarActions"
|
||||
:key="op.code"
|
||||
type="button"
|
||||
class="action-btn"
|
||||
:class="{ 'action-btn--goto': op.needConfirm }"
|
||||
:disabled="executing === op.code"
|
||||
:title="op.description"
|
||||
@click="onOpsExecute(op)">
|
||||
{{ op.label }}
|
||||
</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>
|
||||
</template>
|
||||
|
||||
@@ -140,7 +158,7 @@
|
||||
</div>
|
||||
</div>
|
||||
</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>
|
||||
<div class="action-grid">
|
||||
<button
|
||||
@@ -173,7 +191,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
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<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) => ({
|
||||
/** 站点/路径:仍展示 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<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(() => {
|
||||
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;
|
||||
}
|
||||
|
||||
+13
-13
@@ -430,34 +430,34 @@ function toDetailFields(rows: Array<ReflectionKv | { key: string; value: string
|
||||
<style scoped>
|
||||
.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); }
|
||||
</style>
|
||||
|
||||
@@ -100,28 +100,33 @@ defineExpose({ close: () => { visible.value = false }, endMapPick: () => { mapPi
|
||||
</script>
|
||||
|
||||
<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; }
|
||||
.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 {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
padding: 8px 10px;
|
||||
border: none;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
|
||||
border-bottom: 1px solid rgba(170, 110, 250, 0.10);
|
||||
background: transparent;
|
||||
color: #fff;
|
||||
color: rgba(60, 30, 110, 0.92);
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition: background 0.12s ease;
|
||||
}
|
||||
.site-row:hover { background: rgba(167, 139, 250, 0.15); }
|
||||
.site-row.active {
|
||||
background: rgba(109, 40, 217, 0.45);
|
||||
box-shadow: inset 3px 0 0 rgba(196, 181, 253, 0.95);
|
||||
background: rgba(124, 58, 237, 0.16);
|
||||
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; }
|
||||
.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>
|
||||
|
||||
+75
-96
@@ -56,10 +56,8 @@
|
||||
@click="onSelect(car)"
|
||||
>
|
||||
<div class="vehicle-head">
|
||||
<div class="vehicle-id-name">
|
||||
<span class="vid">{{ car.id }}</span>
|
||||
<span class="vname">{{ car.name }}</span>
|
||||
</div>
|
||||
<span class="vid">{{ car.id }}</span>
|
||||
<span class="vname" :title="car.name">{{ car.name }}</span>
|
||||
<el-tag
|
||||
size="small"
|
||||
:type="stateTagType(car.state)"
|
||||
@@ -71,45 +69,37 @@
|
||||
</el-tag>
|
||||
</div>
|
||||
|
||||
<div class="vehicle-meta muted">
|
||||
<span v-if="car.typeName" class="meta-item">{{ shortType(car.typeName) }}</span>
|
||||
<span v-if="car.group" class="meta-item">{{ car.group }}</span>
|
||||
<span v-if="car.ip" class="meta-item">{{ car.ip }}</span>
|
||||
</div>
|
||||
|
||||
<div class="battery-row">
|
||||
<span class="battery-label">电量</span>
|
||||
<el-progress
|
||||
:percentage="batteryPct(car)"
|
||||
:stroke-width="8"
|
||||
: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 }}
|
||||
<div class="vehicle-sub">
|
||||
<div class="battery-mini" :title="`电量 ${batteryPct(car)}%`">
|
||||
<el-progress
|
||||
:percentage="batteryPct(car)"
|
||||
:stroke-width="6"
|
||||
:show-text="false"
|
||||
:color="batteryColor(batteryPct(car))"
|
||||
class="battery-bar"
|
||||
/>
|
||||
<span class="battery-text" :class="batteryLevel(batteryPct(car))">
|
||||
{{ batteryPct(car) }}%
|
||||
</span>
|
||||
<el-tag
|
||||
size="small"
|
||||
:type="missionStatusTagType(missionForCar(car)!.status)"
|
||||
effect="plain"
|
||||
class="mission-tag"
|
||||
>
|
||||
{{ missionStatusLabel(missionForCar(car)!.status) }}
|
||||
</el-tag>
|
||||
</template>
|
||||
<template v-else>
|
||||
<span class="mission-name muted">无任务</span>
|
||||
<el-tag size="small" type="info" effect="plain" class="mission-tag">空闲</el-tag>
|
||||
</template>
|
||||
</div>
|
||||
<div class="mission-mini">
|
||||
<template v-if="missionForCar(car)">
|
||||
<el-tag
|
||||
size="small"
|
||||
:type="missionStatusTagType(missionForCar(car)!.status)"
|
||||
effect="plain"
|
||||
class="mission-tag"
|
||||
>
|
||||
{{ missionStatusLabel(missionForCar(car)!.status) }}
|
||||
</el-tag>
|
||||
<span class="mission-name" :title="missionForCar(car)!.name">
|
||||
{{ missionForCar(car)!.name }}
|
||||
</span>
|
||||
</template>
|
||||
<template v-else>
|
||||
<span class="mission-name muted">空闲</span>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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); }
|
||||
</style>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -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:纯净紫白渐变(更柔和、长时间阅读不刺眼) */
|
||||
|
||||
@@ -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> {
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -63,13 +63,13 @@ function run(t: CadTool) {
|
||||
<style scoped>
|
||||
.tool-card { cursor: pointer; text-align: center; padding: 8px; }
|
||||
.tool-name { font-weight: 600; margin-top: 6px; }
|
||||
.tool-desc { color: rgba(220, 200, 252, 0.55); font-size: 12px; margin-top: 4px; }
|
||||
.tool-desc { color: var(--mg-text-dim); font-size: 12px; margin-top: 4px; }
|
||||
.tool-icon {
|
||||
color: var(--mg-accent);
|
||||
filter: drop-shadow(0 0 8px rgba(var(--mg-accent-rgb), 0.55));
|
||||
}
|
||||
.tool-card:hover .tool-icon {
|
||||
color: #fff;
|
||||
color: var(--mg-text-light);
|
||||
filter: drop-shadow(0 0 14px rgba(var(--mg-primary-hover-rgb), 0.8));
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
:host="vrHost"
|
||||
:scope="auth.scope ?? 'Platform'"
|
||||
:token="auth.token ?? ''"
|
||||
:read-only="false"
|
||||
:read-only="!!readOnly"
|
||||
:embed-ui="true"
|
||||
@pick="onPick"
|
||||
@select="onSelect"
|
||||
@@ -35,17 +35,31 @@
|
||||
<el-col :span="8" class="side-col">
|
||||
<el-card shadow="never" class="side-card workbench-card">
|
||||
<template #header>
|
||||
<span>车辆监控台</span>
|
||||
<el-button link type="primary" size="small" :loading="refreshing" @click="refreshAll">
|
||||
刷新
|
||||
</el-button>
|
||||
<div class="workbench-header">
|
||||
<el-tabs v-model="workbenchTab" class="workbench-tabs">
|
||||
<el-tab-pane label="车辆监控台" name="vehicle" />
|
||||
<el-tab-pane label="任务列表" name="mission" />
|
||||
</el-tabs>
|
||||
<el-button link type="primary" size="small" :loading="refreshing" @click="refreshAll">
|
||||
刷新
|
||||
</el-button>
|
||||
</div>
|
||||
</template>
|
||||
<VehicleMonitorPanel
|
||||
v-show="workbenchTab === 'vehicle'"
|
||||
:cars="cars"
|
||||
:missions="missions"
|
||||
:selected-id="selectedVehicleId"
|
||||
@select="onVehicleSelect"
|
||||
/>
|
||||
<MissionListPanel
|
||||
v-show="workbenchTab === 'mission'"
|
||||
:deliveries="deliveries"
|
||||
:selected-id="selectedDeliveryId"
|
||||
@select="onDeliverySelect"
|
||||
@refresh="onDeliveryListRefresh"
|
||||
@action-done="refreshDeliveries"
|
||||
/>
|
||||
</el-card>
|
||||
<el-card shadow="never" class="side-card detail-card">
|
||||
<template #header><span>选中信息</span></template>
|
||||
@@ -54,6 +68,7 @@
|
||||
:refresh-key="tick"
|
||||
:cars="cars"
|
||||
:missions="missions"
|
||||
:read-only="!!readOnly"
|
||||
/>
|
||||
</el-card>
|
||||
</el-col>
|
||||
@@ -65,27 +80,39 @@
|
||||
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||
import Workspace3D from '@/components/Workspace3D.vue'
|
||||
import VehicleMonitorPanel from '@/components/workbench/VehicleMonitorPanel.vue'
|
||||
import MissionListPanel from '@/components/workbench/MissionListPanel.vue'
|
||||
import MonitorSelectionPanel from '@/components/workbench/MonitorSelectionPanel.vue'
|
||||
import FloatingAlarmStack from '@/components/map-monitor/FloatingAlarmStack.vue'
|
||||
import WorkspaceCanvasToolbar from '@/components/workspace/WorkspaceCanvasToolbar.vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { listCars, listMissions } from '@/api/projection'
|
||||
import { listDeliveries } from '@/api/delivery'
|
||||
import { useProjectionStream, type StreamEvent } from '@/composables/useProjectionStream'
|
||||
import type { AlarmEvent, SelectionDetailEvent } from '@/composables/useMapEditStream'
|
||||
import { reflectionApi } from '@/api/reflection'
|
||||
import type { Car } from '@/types/car'
|
||||
import type { Mission } from '@/types/mission'
|
||||
import type { DeliveryTask } from '@/types/delivery'
|
||||
import type { SelectedObjectRef } from '@/types/workbench'
|
||||
|
||||
defineProps<{
|
||||
/** 只读模式(运营端复用 MapMonitorView 时传 true):3D 不可编辑,选中信息面板动作改用运维白名单。 */
|
||||
readOnly?: boolean
|
||||
}>()
|
||||
|
||||
const auth = useAuthStore()
|
||||
const vrHost = (import.meta.env.VITE_VRENDER_HOST as string | undefined) ?? 'localhost:8223'
|
||||
|
||||
const cars = ref<Car[]>([])
|
||||
const missions = ref<Mission[]>([])
|
||||
const deliveries = ref<DeliveryTask[]>([])
|
||||
const deliveryListOpts = ref({ includeFinished: true, includeAborted: true })
|
||||
const selection = ref<SelectedObjectRef | null>(null)
|
||||
const tick = ref(0)
|
||||
const refreshing = ref(false)
|
||||
const workbenchTab = ref<'vehicle' | 'mission'>('vehicle')
|
||||
const selectedDeliveryId = ref<number | null>(null)
|
||||
const workspaceRef = ref<InstanceType<typeof Workspace3D> | null>(null)
|
||||
|
||||
/**
|
||||
@@ -122,8 +149,16 @@ function onAlarmAck(_a: AlarmEvent) {
|
||||
let pollTimer: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
const onlineCars = computed(() => cars.value.filter((c) => c.state !== 'offline').length)
|
||||
const runningMissions = computed(() => missions.value.filter((m) => m.status === 'running').length)
|
||||
const queuedMissions = computed(() => missions.value.filter((m) => m.status === 'queued').length)
|
||||
const runningMissions = computed(() =>
|
||||
deliveries.value.length > 0
|
||||
? deliveries.value.filter((d) => d.statusCode === 'Fetching' || d.statusCode === 'Putting').length
|
||||
: missions.value.filter((m) => m.status === 'running').length
|
||||
)
|
||||
const queuedMissions = computed(() =>
|
||||
deliveries.value.length > 0
|
||||
? deliveries.value.filter((d) => d.statusCode === 'Waiting').length
|
||||
: missions.value.filter((m) => m.status === 'queued').length
|
||||
)
|
||||
|
||||
const selectedVehicleId = computed(() => {
|
||||
if (!selection.value || selection.value.kind !== 'vehicle') return null
|
||||
@@ -168,6 +203,7 @@ function sameSelection(a: SelectedObjectRef | null, b: SelectedObjectRef | null)
|
||||
|
||||
/** 把解析结果写入侧栏 selection;仅当对象变化时刷新详情,避免选中闪烁。 */
|
||||
function applyParsedSelection(parsed: { kind: 'vehicle' | 'site' | 'track'; id: string }, rawName?: string) {
|
||||
selectedDeliveryId.value = null
|
||||
let next: SelectedObjectRef
|
||||
if (parsed.kind === 'vehicle') {
|
||||
const car = (rawName ? findSelectedCar(rawName) : undefined)
|
||||
@@ -188,6 +224,7 @@ function applyParsedSelection(parsed: { kind: 'vehicle' | 'site' | 'track'; id:
|
||||
}
|
||||
|
||||
function applySelectionFromKindId(kind: 'site' | 'track' | 'car', id: number, names?: readonly string[]) {
|
||||
selectedDeliveryId.value = null
|
||||
if (kind === 'car') {
|
||||
const sid = String(id)
|
||||
const rawName = names?.find((n) => RX_CAR.test(n)) ?? `Car-${id}`
|
||||
@@ -293,6 +330,7 @@ async function fallbackSelectionFromBackend() {
|
||||
}
|
||||
|
||||
async function onVehicleSelect(ref: SelectedObjectRef) {
|
||||
selectedDeliveryId.value = null
|
||||
if (!sameSelection(selection.value, ref)) {
|
||||
selection.value = ref
|
||||
tick.value++
|
||||
@@ -306,6 +344,49 @@ async function onVehicleSelect(ref: SelectedObjectRef) {
|
||||
}
|
||||
}
|
||||
|
||||
async function onDeliverySelect(task: DeliveryTask) {
|
||||
selectedDeliveryId.value = task.id
|
||||
if (task.carId != null) {
|
||||
const car = cars.value.find((c) => c.rawId === task.carId || detailIdForCar(c) === String(task.carId))
|
||||
if (car) {
|
||||
await onVehicleSelect({ kind: 'vehicle', id: detailIdForCar(car), name: car.name })
|
||||
return
|
||||
}
|
||||
try {
|
||||
await reflectionApi.setSelection('car', task.carId)
|
||||
selection.value = {
|
||||
kind: 'vehicle',
|
||||
id: String(task.carId),
|
||||
name: task.carName ?? `Vehicle ${task.carId}`
|
||||
}
|
||||
tick.value++
|
||||
} catch (err) {
|
||||
ElMessage.error(`同步 3D 选中失败:${(err as Error).message}`)
|
||||
}
|
||||
return
|
||||
}
|
||||
selection.value = {
|
||||
kind: 'delivery',
|
||||
id: String(task.id),
|
||||
name: `${task.srcLabel} → ${task.dstLabel}`
|
||||
}
|
||||
tick.value++
|
||||
}
|
||||
|
||||
function onDeliveryListRefresh(opts: { includeFinished: boolean; includeAborted: boolean }) {
|
||||
deliveryListOpts.value = opts
|
||||
void refreshDeliveries()
|
||||
}
|
||||
|
||||
async function refreshDeliveries() {
|
||||
try {
|
||||
deliveries.value = await listDeliveries(deliveryListOpts.value)
|
||||
} catch (err) {
|
||||
console.warn('[MapMonitor] refreshDeliveries failed', err)
|
||||
deliveries.value = []
|
||||
}
|
||||
}
|
||||
|
||||
function findSelectedCar(name: string): Car | undefined {
|
||||
const parsedId = parseWorkspaceCarId(name)
|
||||
return cars.value.find((c) =>
|
||||
@@ -328,8 +409,10 @@ function detailIdForCar(car: Car): string {
|
||||
async function refreshAll() {
|
||||
refreshing.value = true
|
||||
try {
|
||||
cars.value = await listCars()
|
||||
missions.value = await listMissions()
|
||||
const [carList, missionList] = await Promise.all([listCars(), listMissions()])
|
||||
cars.value = carList
|
||||
missions.value = missionList
|
||||
await refreshDeliveries()
|
||||
} catch (err) {
|
||||
console.warn('[MapMonitor] refreshAll failed', err)
|
||||
} finally {
|
||||
@@ -385,7 +468,11 @@ onMounted(async () => {
|
||||
await refreshAll()
|
||||
pollTimer = setInterval(() => {
|
||||
// SSE 已连但 projection/cars 曾 502 时 connected 仍为 true,需在车列表为空时继续轮询
|
||||
if (!stream.connected.value || cars.value.length === 0) void refreshAll()
|
||||
if (!stream.connected.value || cars.value.length === 0) {
|
||||
void refreshAll()
|
||||
} else if (workbenchTab.value === 'mission') {
|
||||
void refreshDeliveries()
|
||||
}
|
||||
}, 3000)
|
||||
})
|
||||
|
||||
@@ -411,8 +498,8 @@ onUnmounted(() => {
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
}
|
||||
.workbench-card { flex: 1.2; }
|
||||
.detail-card { flex: 1; }
|
||||
.workbench-card { flex: 1; min-height: 0; }
|
||||
.detail-card { flex: 1; min-height: 0; }
|
||||
.workbench-card :deep(.el-card__body),
|
||||
.detail-card :deep(.el-card__body) {
|
||||
flex: 1;
|
||||
@@ -421,10 +508,39 @@ onUnmounted(() => {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.muted { color: rgba(255, 255, 255, 0.78); font-size: 12px; }
|
||||
.muted { color: var(--mg-text-muted); font-size: 12px; }
|
||||
.workbench-card :deep(.el-card__header),
|
||||
.detail-card :deep(.el-card__header) {
|
||||
color: #fff;
|
||||
color: var(--mg-text-light);
|
||||
}
|
||||
.workbench-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
}
|
||||
.workbench-tabs {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.workbench-tabs :deep(.el-tabs__header) {
|
||||
margin: 0;
|
||||
}
|
||||
.workbench-tabs :deep(.el-tabs__nav-wrap::after) {
|
||||
display: none;
|
||||
}
|
||||
.workbench-tabs :deep(.el-tabs__item) {
|
||||
color: var(--mg-text-muted);
|
||||
padding: 0 10px;
|
||||
height: 32px;
|
||||
line-height: 32px;
|
||||
}
|
||||
.workbench-tabs :deep(.el-tabs__item.is-active) {
|
||||
color: var(--mg-text-light);
|
||||
}
|
||||
.workbench-tabs :deep(.el-tabs__active-bar) {
|
||||
background-color: var(--mg-accent, #8ec8fc);
|
||||
}
|
||||
:deep(.el-row.main-row) { height: 100%; }
|
||||
:deep(.el-col-16 > div) { height: 100%; min-height: 0; }
|
||||
|
||||
@@ -144,22 +144,22 @@ onMounted(() => reload())
|
||||
<style scoped>
|
||||
.mmc-card { margin: 0; }
|
||||
.mmc-header { display: flex; align-items: center; gap: 8px; }
|
||||
.mmc-title { font-weight: 600; font-size: 15px; color: #fff; }
|
||||
.mmc-title { font-weight: 600; font-size: 15px; color: var(--mg-text-light); }
|
||||
.mmc-header .spacer { flex: 1; }
|
||||
.mmc-desc {
|
||||
color: rgba(232, 215, 245, 0.78);
|
||||
color: var(--mg-text-muted);
|
||||
font-size: 12.5px;
|
||||
margin: 0 0 12px;
|
||||
line-height: 1.7;
|
||||
}
|
||||
.mmc-desc code {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
background: var(--mg-veil-2);
|
||||
padding: 1px 5px;
|
||||
border-radius: 4px;
|
||||
font-family: ui-monospace, Menlo, Consolas, monospace;
|
||||
}
|
||||
.mmc-tabs :deep(.el-tabs__item) { color: rgba(255, 255, 255, 0.82) !important; }
|
||||
.mmc-tabs :deep(.el-tabs__item.is-active) { color: #fff !important; }
|
||||
.mmc-tabs :deep(.el-tabs__item) { color: var(--mg-text-muted) !important; }
|
||||
.mmc-tabs :deep(.el-tabs__item.is-active) { color: var(--mg-text-light) !important; }
|
||||
.mmc-tab-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -8,10 +8,10 @@
|
||||
<el-form label-width="160px" :model="payload">
|
||||
<el-form-item label="分配模式">
|
||||
<el-radio-group :model-value="payload.mode" @update:model-value="(v: any) => update({ ...payload, mode: v })">
|
||||
<el-radio-button label="roundRobin">轮询</el-radio-button>
|
||||
<el-radio-button label="nearest">就近</el-radio-button>
|
||||
<el-radio-button label="leastLoad">最少负载</el-radio-button>
|
||||
<el-radio-button label="custom">自定义</el-radio-button>
|
||||
<el-radio-button value="roundRobin">轮询</el-radio-button>
|
||||
<el-radio-button value="nearest">就近</el-radio-button>
|
||||
<el-radio-button value="leastLoad">最少负载</el-radio-button>
|
||||
<el-radio-button value="custom">自定义</el-radio-button>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
<el-form-item label="启用负载均衡">
|
||||
|
||||
@@ -78,5 +78,5 @@ onMounted(async () => {
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.muted { color: rgba(232, 215, 245, 0.55); font-size: 12px; }
|
||||
.muted { color: var(--mg-text-dim); font-size: 12px; }
|
||||
</style>
|
||||
|
||||
@@ -1,131 +1,19 @@
|
||||
<template>
|
||||
<div class="monitor-map-page">
|
||||
<el-alert type="info" :closable="false" show-icon style="margin-bottom: 12px">
|
||||
RCSMonitor 只读视图:iframe 嵌入 SimpleLite webVRender({{ vrHost }}),右侧可执行白名单运维动作。
|
||||
</el-alert>
|
||||
<el-row :gutter="12" class="main-row">
|
||||
<el-col :span="17">
|
||||
<div class="canvas-with-toolbar">
|
||||
<Workspace3D
|
||||
:host="vrHost"
|
||||
:scope="'RCSMonitor'"
|
||||
:token="auth.token ?? ''"
|
||||
:read-only="true"
|
||||
:embed-ui="true"
|
||||
@pick="onPick"
|
||||
@select="onSelect" />
|
||||
<WorkspaceCanvasToolbar />
|
||||
</div>
|
||||
</el-col>
|
||||
<el-col :span="7">
|
||||
<el-card shadow="never">
|
||||
<template #header><span>选中目标</span></template>
|
||||
<el-radio-group v-model="targetType" size="small">
|
||||
<el-radio-button label="car">车辆</el-radio-button>
|
||||
<el-radio-button label="task">任务</el-radio-button>
|
||||
</el-radio-group>
|
||||
<el-select v-model="targetId" placeholder="选择目标 ID" filterable style="width: 100%; margin-top: 8px">
|
||||
<el-option v-for="opt in currentOptions" :key="opt.value" :label="opt.label" :value="opt.value" />
|
||||
</el-select>
|
||||
<el-descriptions :column="1" border size="small" style="margin-top: 8px">
|
||||
<el-descriptions-item label="最近 Pick">
|
||||
{{ lastPick ? `(${lastPick.x.toFixed(0)}, ${lastPick.y.toFixed(0)})` : '—' }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="最近 Select">
|
||||
{{ lastSelect.length ? lastSelect.join(', ') : '—' }}
|
||||
</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
</el-card>
|
||||
|
||||
<el-card shadow="never" style="margin-top: 12px">
|
||||
<template #header><span>运维白名单(按权限隐藏)</span></template>
|
||||
<div class="ops-grid">
|
||||
<template v-for="op in OPS_WHITELIST" :key="op.code">
|
||||
<el-button
|
||||
v-if="auth.hasOp(op.code) && opMatchTarget(op)"
|
||||
:type="op.needConfirm ? 'warning' : 'primary'"
|
||||
size="small"
|
||||
plain
|
||||
@click="execute(op)">
|
||||
{{ op.label }}
|
||||
<el-tooltip :content="op.description"><el-icon style="margin-left: 4px"><InfoFilled /></el-icon></el-tooltip>
|
||||
</el-button>
|
||||
</template>
|
||||
</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</div>
|
||||
<MapMonitorView read-only />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { InfoFilled } from '@element-plus/icons-vue'
|
||||
import Workspace3D from '@/components/Workspace3D.vue'
|
||||
import WorkspaceCanvasToolbar from '@/components/workspace/WorkspaceCanvasToolbar.vue'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { OPS_WHITELIST, type OpsAction } from '@/types/ops'
|
||||
import { listCars, listMissions } from '@/api/projection'
|
||||
import { executeOp } from '@/api/ops'
|
||||
|
||||
const auth = useAuthStore()
|
||||
const vrHost = (import.meta.env.VITE_VRENDER_HOST as string | undefined) ?? 'localhost:8223'
|
||||
|
||||
const targetType = ref<'car' | 'task'>('car')
|
||||
const targetId = ref('')
|
||||
const carOptions = ref<Array<{ label: string; value: string }>>([])
|
||||
const missionOptions = ref<Array<{ label: string; value: string }>>([])
|
||||
const lastPick = ref<{ x: number; y: number } | null>(null)
|
||||
const lastSelect = ref<string[]>([])
|
||||
|
||||
const currentOptions = computed(() => (targetType.value === 'car' ? carOptions.value : missionOptions.value))
|
||||
|
||||
function opMatchTarget(op: OpsAction) {
|
||||
return op.target === targetType.value || op.target === 'note'
|
||||
}
|
||||
|
||||
async function execute(op: OpsAction) {
|
||||
if (!targetId.value && op.target !== 'note') {
|
||||
ElMessage.warning('请先选择目标')
|
||||
return
|
||||
}
|
||||
if (op.needConfirm) {
|
||||
try {
|
||||
await ElMessageBox.confirm(`确认执行 [${op.label}]?\n目标:${targetId.value}`, '二次确认', { type: 'warning' })
|
||||
} catch { return }
|
||||
}
|
||||
try {
|
||||
const resp = await executeOp({ opCode: op.code, targetId: targetId.value, reason: '' })
|
||||
ElMessage.success(`已执行 ${op.label},auditId=${resp.auditId}`)
|
||||
} catch (e) {
|
||||
ElMessage.error(`执行失败:${e instanceof Error ? e.message : String(e)}`)
|
||||
}
|
||||
}
|
||||
|
||||
function onPick(p: { x: number; y: number }) { lastPick.value = p }
|
||||
function onSelect(names: string[]) {
|
||||
lastSelect.value = names
|
||||
if (names.length === 1 && names[0].startsWith('UICar-')) {
|
||||
targetType.value = 'car'
|
||||
targetId.value = names[0].replace('UICar-', '')
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
const cars = await listCars()
|
||||
carOptions.value = cars.map((c) => ({ label: `${c.name} (${c.id})`, value: c.id }))
|
||||
const missions = await listMissions()
|
||||
missionOptions.value = missions.map((m) => ({ label: `${m.name} (${m.id})`, value: m.id }))
|
||||
})
|
||||
/**
|
||||
* 运营端(RCSMonitor)地图监控页:复用管理员端 MapMonitorView,统一界面,仅以 read-only 区分。
|
||||
*
|
||||
* 只读模式下:
|
||||
* - 3D 画布不可编辑(Workspace3D read-only),scope 由 MapMonitorView 内部按 auth.scope 取,
|
||||
* 运营端登录后即 RCSMonitor。
|
||||
* - 右侧「选中信息」面板的车辆动作改用运维白名单(OPS_WHITELIST + executeOp 审计 + 二次确认,
|
||||
* 按当前账号 hasOp 过滤),站点/路径的编辑动作区隐藏。
|
||||
*
|
||||
* 备注:原运营端地图页内的「任务」白名单动作(暂停/取消/重派等)请走 /monitor/ops 运维操作页,
|
||||
* 地图监控页聚焦于地图对象(车/站/路)的监视与车辆运维动作。
|
||||
*/
|
||||
import MapMonitorView from '@/views/admin/MapMonitorView.vue'
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.monitor-map-page { display: flex; flex-direction: column; height: calc(100vh - 56px - 36px - 32px); }
|
||||
.main-row { flex: 1; min-height: 0; }
|
||||
.main-row > .el-col { display: flex; flex-direction: column; }
|
||||
.main-row > .el-col:first-child { min-height: 0; }
|
||||
.ops-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
|
||||
.canvas-with-toolbar { flex: 1; min-height: 0; display: flex; flex-direction: column; }
|
||||
.canvas-with-toolbar :deep(.workspace-3d-wrap) { flex: 1; min-height: 0; }
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user