feat(vehicle-hub): 新增车辆运维页(健康监控 / 维护操作 / 批量管理)

- 新增 fleetHealth / vehicleOps API、useVehicleHub 组合式与 VehicleHealthCard 卡片
- 新增 /admin/config/vehicle-hub 与 /monitor/vehicle-hub 双端路由及侧栏菜单
- car 类型补充 rawId/onboardUrl/lstatus 及 FleetHealthRow/VehicleCardModel
- 后端 PageCatalog 注册「车辆运维」页,原「车辆维护」更名为「车辆维护策略」

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
zhaowei.huang
2026-05-31 00:16:16 +08:00
co-authored by Cursor
parent 34635dca05
commit 62943e6c42
12 changed files with 926 additions and 10 deletions
@@ -0,0 +1,420 @@
<template>
<div
class="vehicle-health-card"
:class="cardClass"
@click="onCardClick"
@dblclick="onCardDblClick"
>
<div class="battery-strip">
<el-progress
:percentage="batteryPct"
:stroke-width="4"
:show-text="false"
:color="batteryColor"
/>
</div>
<div class="card-head">
<div class="title-block">
<span class="vid">{{ vehicle.id }}</span>
<span class="vname" :title="vehicle.name">{{ vehicle.name }}</span>
</div>
<div class="head-actions" @click.stop>
<el-dropdown trigger="click" @command="onMaintenanceCommand">
<el-button size="small" text :icon="MoreFilled" />
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="online">上线</el-dropdown-item>
<el-dropdown-item command="offline">下线维护</el-dropdown-item>
<el-dropdown-item command="repair">现场检修</el-dropdown-item>
<el-dropdown-item command="blown" divided>返厂检修</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
<el-switch
:model-value="switchOn"
size="small"
:disabled="!canWrite"
inline-prompt
active-text=""
inactive-text=""
@change="(v: string | number | boolean) => onToggleMaintenance(Boolean(v))"
/>
</div>
</div>
<div class="card-body">
<div class="thumb">
<el-icon :size="36"><Van /></el-icon>
</div>
<div class="metrics">
<div class="metric-row">
<span class="label">IP</span>
<span class="value mono">{{ vehicle.ip ?? '—' }}</span>
</div>
<div class="metric-row">
<span class="label">延迟</span>
<span class="value" :class="latencyClass">{{ latencyLabel }}</span>
</div>
<div class="metric-row">
<span class="label">故障率</span>
<span class="value" :class="faultClass">{{ faultLabel }}</span>
</div>
<div class="metric-row">
<span class="label">群组</span>
<span class="value">{{ vehicle.group ?? '—' }}</span>
</div>
<div class="metric-row">
<span class="label">状态</span>
<span class="value">{{ vehicle.lstatus ?? stateLabel }}</span>
</div>
</div>
</div>
<div class="icon-grid">
<div class="icon-cell" title="CPU">
<el-icon><Cpu /></el-icon>
<span>{{ cpuLabel }}</span>
</div>
<div class="icon-cell" title="内存">
<el-icon><Coin /></el-icon>
<span>{{ memLabel }}</span>
</div>
<div class="icon-cell" title="报警">
<el-icon :class="{ 'is-alarm': vehicle.isAlarmActive }"><Warning /></el-icon>
<span>{{ vehicle.isAlarmActive ? '报警' : '正常' }}</span>
</div>
<div class="icon-cell" title="连接">
<el-icon :class="{ 'is-down': vehicle.reachable === false }"><Connection /></el-icon>
<span>{{ vehicle.reachable === false ? '不可达' : '可达' }}</span>
</div>
</div>
<div class="card-foot">
<span>电量 {{ batteryPct }}%</span>
<span class="hint">双击打开车载界面</span>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Van, Cpu, Coin, Warning, Connection, MoreFilled } from '@element-plus/icons-vue'
import type { VehicleCardModel } from '@/types/car'
import type { VehicleMaintenanceMode } from '@/api/vehicleOps'
import { openOnboardWeb, setVehicleMaintenance } from '@/api/vehicleOps'
const props = defineProps<{
vehicle: VehicleCardModel
selected?: boolean
canWrite?: boolean
}>()
const emit = defineEmits<{
select: [id: string]
maintenanceChanged: []
}>()
const stateLabels: Record<string, string> = {
idle: '空闲',
running: '运行',
charging: '充电',
paused: '暂停',
fault: '故障',
offline: '离线'
}
const stateLabel = computed(() => stateLabels[props.vehicle.state] ?? props.vehicle.state)
const batteryPct = computed(() => {
const raw = props.vehicle.batterySoc ?? 0
const pct = raw > 1 ? raw : raw * 100
return Math.max(0, Math.min(100, Math.round(pct)))
})
const batteryColor = computed(() => {
const p = batteryPct.value
if (p < 20) return '#f56c6c'
if (p < 50) return '#e6a23c'
return '#67c23a'
})
const switchOn = computed(() => props.vehicle.maintenanceMode === 'online')
const cardClass = computed(() => ({
'is-selected': props.selected,
'is-alarm': props.vehicle.isAlarmActive,
'is-offline': props.vehicle.maintenanceMode === 'offline' || props.vehicle.state === 'offline',
'is-maintenance': props.vehicle.maintenanceMode === 'repair' || props.vehicle.maintenanceMode === 'blown',
'is-unreachable': props.vehicle.reachable === false
}))
const latencyLabel = computed(() => {
const ms = props.vehicle.latencyMs
if (ms == null) return '—'
if (props.vehicle.reachable === false) return '超时'
return `${ms} ms`
})
const latencyClass = computed(() => {
const ms = props.vehicle.latencyMs
if (props.vehicle.reachable === false) return 'danger'
if (ms != null && ms > 80) return 'warn'
return ''
})
const faultLabel = computed(() => {
const v = props.vehicle.faultRatePercent
if (v == null) return '—'
return `${v.toFixed(2)}%`
})
const faultClass = computed(() => {
const v = props.vehicle.faultRatePercent ?? 0
if (v >= 5) return 'danger'
if (v >= 1) return 'warn'
return ''
})
const cpuLabel = computed(() => {
const v = props.vehicle.cpuPercent
return v != null ? `${Math.round(v)}%` : '—'
})
const memLabel = computed(() => {
const v = props.vehicle.memPercent
return v != null ? `${Math.round(v)}%` : '—'
})
function onCardClick() {
emit('select', props.vehicle.id)
}
function onCardDblClick(e: MouseEvent) {
const target = e.target as HTMLElement
if (target.closest('.head-actions')) return
openOnboardWeb(props.vehicle.onboardUrl, props.vehicle.ip)
}
async function applyMaintenance(mode: VehicleMaintenanceMode) {
const rawId = props.vehicle.rawId ?? parseInt(props.vehicle.id.replace(/\D/g, ''), 10)
if (!Number.isFinite(rawId)) return
const ok = await setVehicleMaintenance(rawId, mode)
if (ok) {
ElMessage.success('维护状态已更新')
emit('maintenanceChanged')
} else {
ElMessage.error('维护操作失败')
}
}
async function onToggleMaintenance(on: boolean) {
if (!props.canWrite) return
const mode: VehicleMaintenanceMode = on ? 'online' : 'offline'
try {
await ElMessageBox.confirm(
on ? '确认将车辆上线?' : '确认将车辆下线维护?',
'维护确认',
{ type: 'warning', confirmButtonText: '确定', cancelButtonText: '取消' }
)
await applyMaintenance(mode)
} catch {
/* cancelled */
}
}
async function onMaintenanceCommand(cmd: string) {
if (!props.canWrite) return
const mode = cmd as VehicleMaintenanceMode
if (mode === 'blown') {
try {
await ElMessageBox.confirm('返厂检修将停止调度并清空站点,确认?', '危险操作', {
type: 'error',
confirmButtonText: '确定',
cancelButtonText: '取消'
})
await applyMaintenance(mode)
} catch {
/* cancelled */
}
return
}
if (mode === 'repair') {
try {
await ElMessageBox.confirm('现场检修:不调度但仍刷新状态,确认?', '维护确认', {
type: 'warning'
})
await applyMaintenance(mode)
} catch {
/* cancelled */
}
return
}
await applyMaintenance(mode)
}
</script>
<style scoped>
.vehicle-health-card {
border: 1px solid var(--el-border-color-light);
border-radius: 8px;
background: var(--el-bg-color);
overflow: hidden;
cursor: pointer;
transition: box-shadow 0.15s ease, border-color 0.15s ease;
}
.vehicle-health-card:hover {
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08);
}
.vehicle-health-card.is-selected {
border-color: var(--el-color-primary);
box-shadow: 0 0 0 1px var(--el-color-primary-light-7);
}
.vehicle-health-card.is-alarm {
border-color: var(--el-color-danger-light-5);
}
.vehicle-health-card.is-offline,
.vehicle-health-card.is-maintenance {
border-color: var(--el-color-warning-light-5);
}
.vehicle-health-card.is-unreachable {
opacity: 0.85;
}
.battery-strip {
height: 4px;
}
.card-head {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 10px 4px;
gap: 8px;
}
.title-block {
min-width: 0;
display: flex;
flex-direction: column;
gap: 2px;
}
.vid {
font-size: 12px;
color: var(--el-text-color-secondary);
font-family: ui-monospace, monospace;
}
.vname {
font-weight: 600;
font-size: 14px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.head-actions {
display: flex;
align-items: center;
gap: 4px;
flex-shrink: 0;
}
.card-body {
display: flex;
gap: 10px;
padding: 4px 10px 8px;
}
.thumb {
width: 56px;
height: 56px;
border-radius: 6px;
background: var(--el-fill-color-light);
display: flex;
align-items: center;
justify-content: center;
color: var(--el-text-color-secondary);
flex-shrink: 0;
}
.metrics {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 4px;
font-size: 12px;
}
.metric-row {
display: flex;
justify-content: space-between;
gap: 8px;
}
.metric-row .label {
color: var(--el-text-color-secondary);
}
.metric-row .value {
font-weight: 500;
}
.metric-row .value.mono {
font-family: ui-monospace, monospace;
}
.metric-row .value.danger {
color: var(--el-color-danger);
}
.metric-row .value.warn {
color: var(--el-color-warning);
}
.icon-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 4px;
padding: 0 10px 8px;
}
.icon-cell {
display: flex;
flex-direction: column;
align-items: center;
gap: 2px;
font-size: 10px;
color: var(--el-text-color-secondary);
}
.icon-cell .is-alarm {
color: var(--el-color-danger);
}
.icon-cell .is-down {
color: var(--el-color-info);
}
.card-foot {
display: flex;
justify-content: space-between;
padding: 6px 10px;
font-size: 11px;
color: var(--el-text-color-secondary);
background: var(--el-fill-color-lighter);
border-top: 1px solid var(--el-border-color-lighter);
}
.card-foot .hint {
opacity: 0.7;
}
</style>