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>
.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;
}
@@ -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>
@@ -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>
@@ -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;
}
@@ -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>
@@ -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:1WCAG 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 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>
.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-onlyscope 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>