import type { AlertEvent, AlertRule, AlertStatus, AlertTriggerType } from '../../api/types'; import { formatShanghaiDateTime } from './formatters'; export type EventCategory = 'safety' | 'geofence' | 'connectivity' | 'telemetry' | 'business'; export type EventExecutionState = 'attention' | 'handling' | 'automated' | 'completed' | 'ignored'; export interface ProtocolEventSource { protocol: 'GB32960' | 'JT808' | 'YUTONG_MQTT'; label: string; contract: string; role: string; } export interface VehicleEventView { id: string; type: string; category: EventCategory; categoryLabel: string; title: string; subject: { type: 'vehicle'; vin: string; plate: string }; source: { protocol: string; eventId: string }; occurredAt: string; receivedAt: string; automation: { id: string; name: string; version: number }; severity?: AlertEvent['severity']; execution: { state: EventExecutionState; label: string; requiresAttention: boolean }; } export const protocolEventSources: ProtocolEventSource[] = [ { protocol: 'GB32960', label: 'GB/T 32960', contract: 'vehicle.telemetry.reported', role: '整车与新能源遥测' }, { protocol: 'JT808', label: 'JT/T 808', contract: 'vehicle.location.reported', role: '位置、行驶与终端状态' }, { protocol: 'YUTONG_MQTT', label: '宇通 MQTT', contract: 'vehicle.oem.telemetry.reported', role: '厂商扩展遥测' } ]; export const eventCategoryLabels: Record = { safety: '安全异常', geofence: '地理围栏', connectivity: '连接状态', telemetry: '遥测变化', business: '业务事件' }; export const eventExecutionLabels: Record = { attention: '待处理', handling: '处理中', automated: '已恢复', completed: '已完成', ignored: '已忽略' }; const statusToExecution: Record = { unprocessed: 'attention', processing: 'handling', recovered: 'automated', closed: 'completed', ignored: 'ignored' }; function eventCategory(event: Pick): EventCategory { if (event.triggerType === 'geofence') return 'geofence'; if (event.triggerType === 'offline' || event.metric === 'freshness_sec') return 'connectivity'; if (event.metric === 'alarm_active' || event.metric.includes('hydrogen')) return 'safety'; if (event.metric.includes('mileage') || event.metric.includes('daily_')) return 'business'; return 'telemetry'; } function normalizedEventType(triggerType: AlertTriggerType | undefined, metric: string, category: EventCategory) { if (triggerType === 'geofence') return 'vehicle.geofence.changed'; if (triggerType === 'offline' || metric === 'freshness_sec') return 'vehicle.connectivity.offline'; if (triggerType === 'stationary') return 'vehicle.motion.stationary'; const detail = (metric || 'changed').replace(/[^a-z0-9_]+/gi, '_').toLowerCase(); return category === 'business' ? `vehicle.business.${detail}` : `vehicle.${category}.${detail}`; } export function automationEventType(rule: Pick) { if (rule.triggerType === 'geofence') return `vehicle.geofence.${({ enter: 'entered', exit: 'exited', inside: 'inside', outside: 'outside' } as Record)[rule.operator] || 'changed'}`; if (rule.triggerType === 'offline' || rule.metric === 'freshness_sec') return 'vehicle.connectivity.offline'; if (rule.triggerType === 'stationary') return 'vehicle.motion.stationary'; if (rule.metric === 'soc_percent' && ['lt', 'lte'].includes(rule.operator)) return 'vehicle.telemetry.soc_low'; if (rule.metric === 'speed_kmh' && ['gt', 'gte'].includes(rule.operator)) return 'vehicle.motion.speed_high'; if (rule.metric === 'alarm_active') return 'vehicle.safety.alarm_activated'; if (rule.metric === 'hydrogen_concentration_percent') return 'vehicle.safety.hydrogen_concentration_high'; if (rule.metric === 'daily_mileage_km') return 'vehicle.mileage.daily_completed'; return normalizedEventType(rule.triggerType, rule.metric, eventCategory({ triggerType: rule.triggerType, metric: rule.metric })); } export function toVehicleEvent(event: AlertEvent): VehicleEventView { const category = eventCategory(event); const state = statusToExecution[event.status]; return { id: event.id, type: event.eventType || normalizedEventType(event.triggerType, event.metric, category), category: (event.eventCategory as EventCategory | undefined) || category, categoryLabel: eventCategoryLabels[(event.eventCategory as EventCategory | undefined) || category], title: event.ruleName, subject: { type: 'vehicle', vin: event.vin, plate: event.plate }, source: { protocol: event.protocol, eventId: event.sourceEventId }, occurredAt: event.eventAt || event.triggeredAt, receivedAt: event.receivedAt, automation: { id: event.ruleId, name: event.ruleName, version: event.ruleVersion }, severity: category === 'business' ? undefined : event.severity, execution: { state, label: eventExecutionLabels[state], requiresAttention: state === 'attention' || state === 'handling' } }; } export function eventContract(event: AlertEvent) { const normalized = toVehicleEvent(event); return [ { key: 'event.type', value: normalized.type }, { key: 'source.protocol', value: normalized.source.protocol }, { key: 'subject.vin', value: normalized.subject.vin }, { key: 'occurred_at', value: normalized.occurredAt }, { key: 'received_at', value: normalized.receivedAt } ]; } function shanghaiMinute(value: Date) { return formatShanghaiDateTime(value.toISOString()).replace(' ', 'T').slice(0, 16); } export function eventEvidenceHistoryPath(event: AlertEvent, windowMinutes = 3) { const normalized = toVehicleEvent(event); const params = new URLSearchParams({ keywords: normalized.subject.vin, category: 'raw', eventId: normalized.id, eventTitle: normalized.title, eventVin: normalized.subject.vin, eventAt: normalized.occurredAt }); if (normalized.subject.plate) params.set('eventPlate', normalized.subject.plate); if (normalized.source.protocol) params.set('protocol', normalized.source.protocol); const occurredAt = new Date(normalized.occurredAt); if (Number.isFinite(occurredAt.getTime())) { const safeWindow = Math.min(60, Math.max(1, Math.round(windowMinutes))); params.set('dateFrom', shanghaiMinute(new Date(occurredAt.getTime() - safeWindow * 60_000))); params.set('dateTo', shanghaiMinute(new Date(occurredAt.getTime() + safeWindow * 60_000))); } return `/history?${params.toString()}`; } export function automationSource(rule: Pick) { const protocols = rule.scopeProtocols ?? []; if (!protocols.length) return '三类协议'; if (protocols.length === 1) return protocolEventSources.find((item) => item.protocol === protocols[0])?.label ?? protocols[0]; return `${protocols.length} 类协议`; } export function isNativeAlarm(event: Pick) { return event.ruleId === 'native-gb32960-alarm'; } export function nativeAlarmDetails(event: AlertEvent) { const fields = event.nativeAlarmFields; if (!fields) return []; return [ ['max_alarm_level', '最高报警等级'], ['general_alarm_flag', '通用报警标志'], ['battery_faults', '可充电储能装置故障码'], ['motor_faults', '驱动电机故障码'], ['engine_faults', '发动机故障码'], ['other_faults', '其他故障码'], ].map(([name, label]) => { const value = fields[`gb32960.alarm.${name}`]; let text = value == null ? '未上报' : Array.isArray(value) ? (value.length ? value.join('、') : '无') : String(value); if (name === 'max_alarm_level' && value != null) text = ({ '0': '0 · 无故障', '1': '1 · 一级故障', '2': '2 · 二级故障', '3': '3 · 三级故障', '254': '254 · 异常', '255': '255 · 无效' } as Record)[String(value)] ?? text; return { key: label, value: text }; }); }