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>