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:
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user