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,154 @@
import { computed, onMounted, onUnmounted, ref, shallowRef } from 'vue'
import { listCars } from '@/api/projection'
import { fetchFleetHealth } from '@/api/fleetHealth'
import { useProjectionStream } from '@/composables/useProjectionStream'
import type { Car, FleetHealthRow, VehicleCardModel } from '@/types/car'
const CAR_POLL_MS = 5000
const HEALTH_POLL_MS = 20000
function inferMaintenanceMode(car: Car): VehicleCardModel['maintenanceMode'] {
const s = (car.lstatus ?? '').toLowerCase()
if (/返厂|blown/.test(s)) return 'blown'
if (/现场检修|repair/.test(s)) return 'repair'
if (/下线|offline/.test(s) || car.state === 'offline') return 'offline'
return 'online'
}
function mergeCarHealth(car: Car, health?: FleetHealthRow): VehicleCardModel {
return {
...car,
ip: health?.ip ?? car.ip,
onboardUrl: health?.onboardUrl ?? car.onboardUrl ?? (car.ip ? `http://${car.ip}:8081` : undefined),
latencyMs: health?.latencyMs,
reachable: health?.reachable,
faultRatePercent: health?.faultRatePercent,
isAlarmActive: health?.isAlarmActive ?? car.state === 'fault',
cpuPercent: health?.cpuPercent,
memPercent: health?.memPercent,
maintenanceMode: inferMaintenanceMode(car)
}
}
export function useVehicleHub() {
const cars = shallowRef<Car[]>([])
const healthRows = shallowRef<FleetHealthRow[]>([])
const loading = ref(false)
const healthLoading = ref(false)
const selectedId = ref<string | null>(null)
let carTimer: ReturnType<typeof setInterval> | null = null
let healthTimer: ReturnType<typeof setInterval> | null = null
const healthByCarId = computed(() => {
const map = new Map<number, FleetHealthRow>()
for (const row of healthRows.value) {
map.set(row.carId, row)
}
return map
})
const cardModels = computed<VehicleCardModel[]>(() =>
cars.value.map((car) => {
const rawId = car.rawId ?? parseInt(car.id.replace(/\D/g, ''), 10)
const health = Number.isFinite(rawId) ? healthByCarId.value.get(rawId) : undefined
return mergeCarHealth(car, health)
})
)
const totalCount = computed(() => cardModels.value.length)
const onlineCount = computed(() => cardModels.value.filter((c) => c.state !== 'offline' && c.maintenanceMode === 'online').length)
const maintenanceCount = computed(() =>
cardModels.value.filter((c) => c.maintenanceMode === 'offline' || c.maintenanceMode === 'repair' || c.maintenanceMode === 'blown').length
)
const alarmCount = computed(() => cardModels.value.filter((c) => c.isAlarmActive).length)
const unreachableCount = computed(() => cardModels.value.filter((c) => c.reachable === false).length)
async function loadCars() {
loading.value = true
try {
cars.value = await listCars()
} finally {
loading.value = false
}
}
async function loadHealth() {
healthLoading.value = true
try {
healthRows.value = await fetchFleetHealth()
} finally {
healthLoading.value = false
}
}
async function refreshAll() {
await Promise.all([loadCars(), loadHealth()])
}
function patchCarFromStream(rawId: number, patch: Partial<Car>) {
const idx = cars.value.findIndex((c) => (c.rawId ?? parseInt(c.id.replace(/\D/g, ''), 10)) === rawId)
if (idx < 0) return
const next = [...cars.value]
next[idx] = { ...next[idx], ...patch, lastUpdate: new Date().toISOString() }
cars.value = next
}
const stream = useProjectionStream({
extraEventKinds: ['alarm', 'car-state'],
autoConnect: import.meta.env.VITE_USE_MOCK !== 'true'
})
stream.on((evt) => {
if (evt.kind === 'car-state' && evt.payload && typeof evt.payload === 'object') {
const p = evt.payload as { rawId?: number; id?: string; state?: string; lstatus?: string }
const rawId = p.rawId
if (rawId != null) {
patchCarFromStream(rawId, {
state: p.state as Car['state'],
lstatus: p.lstatus
})
}
}
if (evt.kind === 'alarm' && evt.payload && typeof evt.payload === 'object') {
const p = evt.payload as { carId?: number; action?: string }
if (p.carId != null) {
const isActive = p.action === 'raise' || p.action === 'update'
const idx = healthRows.value.findIndex((r) => r.carId === p.carId)
if (idx >= 0) {
const next = [...healthRows.value]
next[idx] = { ...next[idx], isAlarmActive: isActive }
healthRows.value = next
}
patchCarFromStream(p.carId, { state: isActive ? 'fault' : undefined })
}
}
})
onMounted(() => {
void refreshAll()
carTimer = setInterval(() => void loadCars(), CAR_POLL_MS)
healthTimer = setInterval(() => void loadHealth(), HEALTH_POLL_MS)
})
onUnmounted(() => {
if (carTimer) clearInterval(carTimer)
if (healthTimer) clearInterval(healthTimer)
stream.disconnect()
})
return {
cardModels,
loading,
healthLoading,
selectedId,
totalCount,
onlineCount,
maintenanceCount,
alarmCount,
unreachableCount,
refreshAll,
loadCars,
loadHealth
}
}