chore: snapshot production code before Apple Design UI refinement

This commit is contained in:
lingniu
2026-09-17 18:05:57 +08:00
parent 9f0a87f49e
commit f7e7176f88
90 changed files with 8990 additions and 328 deletions
@@ -477,7 +477,7 @@ export const api = {
}),
vehicleRealtime: (params = new URLSearchParams(), signal?: AbortSignal) => request<Page<VehicleRealtimeRow>>(`/api/realtime/vehicles?${params.toString()}`, signal ? { signal } : undefined),
realtimeLocations: (params = new URLSearchParams()) => request<Page<RealtimeLocationRow>>(`/api/realtime/locations?${params.toString()}`),
historyLocations: (params = new URLSearchParams()) => request<Page<HistoryLocationRow>>(`/api/history/locations?${params.toString()}`),
historyLocations: (params = new URLSearchParams(), signal?: AbortSignal) => request<Page<HistoryLocationRow>>(`/api/history/locations?${params.toString()}`, withSignal(undefined, signal)),
rawFrames: (params = new URLSearchParams()) => request<Page<RawFrameRow>>(`/api/history/raw-frames?${params.toString()}`),
rawFramesQuery: (query: RawFrameQuery) => request<Page<RawFrameRow>>('/api/history/raw-frames/query', {
method: 'POST',
@@ -381,6 +381,8 @@ export interface AlertQuery { keyword?: string; severity?: string; status?: stri
export interface AlertSummary { active: number; unprocessed: number; processing: number; recovered: number; closed: number; ignored: number; unreadNotifications: number; asOf: string; }
export interface AlertAction { id: number; action: string; fromStatus: string; toStatus: string; actor: string; note: string; createdAt: string; }
export interface AlertEvent {
nativeAlarmFields?: Record<string, unknown>;
nativeAlarmNames?: string[]; nativeAlarmReservedBits?: number[];
id: string; eventType?: string; eventCategory?: string; executionState?: 'pending' | 'processing' | 'recovered' | 'completed' | 'ignored'; ruleId: string; ruleName: string; ruleVersion: number; severity: AlertSeverity; triggerType?: AlertTriggerType; status: AlertStatus;
vin: string; plate: string; protocol: string; metric: string; operator: string; triggerValue: number; threshold: number; thresholdHigh: number;
unit: string; durationSec: number; location: string; longitude?: number; latitude?: number; sourceEventId: string;
@@ -901,6 +903,12 @@ export interface LatestTelemetryResponse {
}
export interface DailyMileageRow {
province?: string;
city?: string;
region?: string;
locationTime?: string;
locationStatus?: string;
hydrogenPhysicalConsumptionKg?: number | null;
vin: string;
plate: string;
date: string;
@@ -941,7 +949,7 @@ export interface HydrogenDailyEvidence {
vin: string; plate: string; date: string; source: string; rawConsumptionKg: number;
batterySocDeltaPct?: number; batteryDischargeKWh?: number; batteryEquivalentKg?: number;
socBalancedConsumptionKg?: number; mixedMileageKm: number; pureElectricMileageKm: number;
consumptionKgPer100Km?: number; socBalancedKgPer100Km?: number; sampleCount: number;
physicalConsumptionKgPer100Km?: number; consumptionKgPer100Km?: number; socBalancedKgPer100Km?: number; sampleCount: number;
refuelCount: number; refuelAmountKg?: number; chargeCount: number; chargeEnergyKWh?: number; validSegmentCount: number; invalidSegmentCount: number;
qualityStatus: string; qualityReason: string; algorithmVersion: string; calculatedAt: string;
parameters: {
@@ -14,7 +14,7 @@ const LEGACY_LOCAL_SQL_TIMESTAMP = /\.\d{6}Z$/;
export const severityLabels: Record<AlertSeverity, string> = { critical: '紧急', major: '重要', minor: '一般' };
export const statusLabels: Record<AlertStatus, string> = { unprocessed: '未处理', processing: '处理中', recovered: '已恢复', closed: '已关闭', ignored: '已忽略' };
export const actionLabels: Record<string, string> = { trigger: '触发', acknowledge: '已确认', close: '已关闭', ignore: '已忽略', recover: '已恢复', processing: '处理中', recovered: '已恢复', closed: '已关闭', ignored: '已忽略' };
export const actionLabels: Record<string, string> = { repair: '修正告警信息', trigger: '触发', acknowledge: '已确认', close: '已关闭', ignore: '已忽略', recover: '已恢复', processing: '处理中', recovered: '已恢复', closed: '已关闭', ignored: '已忽略' };
export const metricLabels: Record<string, string> = { speed_kmh: '速度', soc_percent: 'SOC', alarm_active: '协议告警位', freshness_sec: '离线时长', data_delay_sec: '数据延迟' };
export const operatorLabels: Record<string, string> = { gt: '>', gte: '≥', lt: '<', lte: '≤', eq: '=', neq: '≠', between: '区间内', outside: '区间外', changed: '状态变化' };
export const triggerTypeLabels: Record<string, string> = { metric: '数值触发', geofence: '电子围栏', stationary: '长时间静止', offline: '长时间离线' };
@@ -145,3 +145,25 @@ export function automationSource(rule: Pick<AlertRule, 'scopeProtocols'>) {
if (protocols.length === 1) return protocolEventSources.find((item) => item.protocol === protocols[0])?.label ?? protocols[0];
return `${protocols.length} 类协议`;
}
export function isNativeAlarm(event: Pick<AlertEvent, 'ruleId'>) {
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, string>)[String(value)] ?? text;
return { key: label, value: text };
});
}
@@ -14,7 +14,7 @@ test('creates a styled numeric mileage workbook with formulas and frozen panes',
{ vin: 'LTEST000000000002', plate: '粤A54321', brandName: '飞驰', modelName: 'FSQ' }
],
mileageRows: [
{ vin: 'LTEST000000000001', plate: '粤A12345', date: '2026-07-13', startMileageKm: 100, endMileageKm: 188.7, dailyMileageKm: 88.7, pureHydrogenMileageKm: 56.2, pureElectricMileageKm: 32.5, mixedMileageKm: 56.2, hydrogenConsumptionKg: 3.1, hydrogenConsumptionKgPer100Km: 5.5, hydrogenSocBalancedKg: 3.126, batterySocDeltaPct: -2, chargeCount: 1, chargeEnergyKWh: 12.5, refuelCount: 1, refuelAmountKg: 8.976, hydrogenQualityStatus: 'SUSPECT', hydrogenQualityReason: '疑似管路泄压', source: 'GB32960' },
{ vin: 'LTEST000000000001', plate: '粤A12345', date: '2026-07-13', province: '广东省', city: '广州市', region: '华南', locationTime: '2026-07-13 23:00:00', locationStatus: '已解析', startMileageKm: 100, endMileageKm: 188.7, dailyMileageKm: 88.7, pureHydrogenMileageKm: 56.2, pureElectricMileageKm: 32.5, mixedMileageKm: 56.2, hydrogenConsumptionKg: 3.126, hydrogenPhysicalConsumptionKg: 3.1, hydrogenConsumptionKgPer100Km: 5.5, hydrogenSocBalancedKg: 3.126, batterySocDeltaPct: -2, chargeCount: 1, chargeEnergyKWh: 12.5, refuelCount: 1, refuelAmountKg: 8.976, hydrogenQualityStatus: 'SUSPECT', hydrogenQualityReason: '疑似管路泄压', source: 'GB32960' },
{ vin: 'LTEST000000000001', plate: '粤A12345', date: '2026-07-14', startMileageKm: 188.7, endMileageKm: 293.3, dailyMileageKm: 104.6, pureHydrogenMileageKm: 72.2, hydrogenConsumptionKg: 4.2, hydrogenConsumptionKgPer100Km: 5.8, source: 'GB32960' }
],
sources: [
@@ -43,12 +43,12 @@ test('creates a styled numeric mileage workbook with formulas and frozen panes',
expect((sheet as unknown as { conditionalFormattings: unknown[] }).conditionalFormattings).toHaveLength(1);
const hydrogenSheet = workbook.getWorksheet('氢能明细')!;
expect(hydrogenSheet.getRow(1).values).toEqual([
undefined, '日期', '车牌', 'VIN', '品牌', '车型', '每日氢耗 (kg/100km)', '结果状态', '总里程 (km)', '纯电里程 (km)', '混动里程 (km)', '电SOC差值 (百分点)', '充电次数', '充电量 (kWh)', '加氢次数', '加氢量 (kg)', '修正用氢量 (kg)', '原因', '物理用氢量 (kg)', '来源'
undefined, '日期', '车牌', '当天所在省', '当天所在市', '大区', 'VIN', '品牌', '车型', '每日氢耗 (kg/100km)', '结果状态', '总里程 (km)', '纯电里程 (km)', '混动里程 (km)', '电SOC差值 (百分点)', '充电次数', '充电量 (kWh)', '加氢次数', '加氢量 (kg)', '修正用氢量 (kg)', '原因', '物理用氢量 (kg)', '来源', '定位时间(当日最后有效点)', '位置状态'
]);
expect(hydrogenSheet.getRow(2).values).toEqual([
undefined, '2026-07-13', '粤A12345', 'LTEST000000000001', '现代', 'XCIENT', 5.5, 'SUSPECT', 88.7, 32.5, 56.2, -2, 1, 12.5, 1, 8.976, 3.126, '疑似管路泄压', 3.1, 'GB32960'
undefined, '2026-07-13', '粤A12345', '广东省', '广州市', '华南', 'LTEST000000000001', '现代', 'XCIENT', 5.5, 'SUSPECT', 88.7, 32.5, 56.2, -2, 1, 12.5, 1, 8.976, 3.126, '疑似管路泄压', 3.1, 'GB32960', '2026-07-13 23:00:00', '已解析'
]);
expect(hydrogenSheet.autoFilter).toEqual({ from: { row: 1, column: 1 }, to: { row: 3, column: 19 } });
expect(hydrogenSheet.autoFilter).toEqual({ from: { row: 1, column: 1 }, to: { row: 3, column: 24 } });
expect((await workbook.xlsx.writeBuffer()).byteLength).toBeGreaterThan(5_000);
});
@@ -93,18 +93,20 @@ test('terminates the workbook worker when an active export is cancelled', async
dateTo: '2026-07-14',
dates: ['2026-07-13', '2026-07-14'],
vehicles: [],
mileageRows: [{ vin: 'LTEST000000000001', plate: '粤A12345', date: '2026-07-13', startMileageKm: 100, endMileageKm: 188.7, dailyMileageKm: 88.7, source: 'GB32960' }],
mileageRows: [{ vin: 'LTEST000000000001', plate: '粤A12345', date: '2026-07-13', province: '广东省', city: '广州市', region: '华南', locationTime: '2026-07-13 23:00:00', locationStatus: '已解析', startMileageKm: 100, endMileageKm: 188.7, dailyMileageKm: 88.7, source: 'GB32960' }],
sources: []
}, controller.signal);
expect(postMessage).toHaveBeenCalledTimes(3);
expect(postMessage.mock.calls.map(([message]) => message.type)).toEqual(['start', 'rows', 'finish']);
expect(postMessage.mock.calls[1][0].rows).toEqual([{
province: '广东省', city: '广州市', region: '华南', locationTime: '2026-07-13 23:00:00', locationStatus: '已解析',
vin: 'LTEST000000000001',
date: '2026-07-13',
dailyMileageKm: 88.7,
pureHydrogenMileageKm: undefined,
hydrogenConsumptionKg: undefined,
hydrogenPhysicalConsumptionKg: undefined,
hydrogenConsumptionKgPer100Km: undefined,
hydrogenSocBalancedKg: undefined,
pureElectricMileageKm: undefined,
@@ -143,3 +145,10 @@ test('releases a streaming export before finish when the route aborts', async ()
expect(terminate).toHaveBeenCalledTimes(1);
await expect(stream.finish([{ vin: 'VIN001', plate: '粤A00001' }])).rejects.toMatchObject({ name: 'AbortError' });
});
test('hydrogen export opens the hydrogen sheet with geography in the first five columns', async () => {
const workbook = await createMileageWorkbook({ metricView: 'hydrogen', dateFrom: '2026-09-01', dateTo: '2026-09-01', dates: ['2026-09-01'], vehicles: [], mileageRows: [], sources: [] });
expect(workbook.worksheets[0].name).toBe('氢能明细');
expect(Array.from(workbook.worksheets[0].getRow(1).values as unknown[]).slice(1, 6)).toEqual(['日期', '车牌', '当天所在省', '当天所在市', '大区']);
});
@@ -6,6 +6,7 @@ export type MileageExportSource = { protocol: string; label: string; mileageType
export type MileageWorkbookRow = Pick<DailyMileageRow, 'vin' | 'date' | 'dailyMileageKm'> & Partial<Omit<DailyMileageRow, 'vin' | 'date' | 'dailyMileageKm'>>;
export type MileageExportInput = {
metricView?: 'mileage' | 'hydrogen';
dateFrom: string;
dateTo: string;
dates: string[];
@@ -68,7 +69,7 @@ export function createMileageExportStream(input: Omit<MileageExportInput, 'vehic
}
const buffer = event.data.buffer;
const blob = new Blob([buffer], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' });
downloadBlob(blob, `车辆里程查询_${input.dateFrom.split('-').join('')}-${input.dateTo.split('-').join('')}_${finishVehicles.length}辆.xlsx`);
downloadBlob(blob, `车辆${input.metricView === 'hydrogen' ? '氢耗明细' : '里程查询'}_${input.dateFrom.split('-').join('')}-${input.dateTo.split('-').join('')}_${finishVehicles.length}辆.xlsx`);
resolveFinish?.();
dispose();
};
@@ -84,11 +85,13 @@ export function createMileageExportStream(input: Omit<MileageExportInput, 'vehic
post({
type: 'rows',
rows: rows.map((row) => ({
province: row.province, city: row.city, region: row.region, locationTime: row.locationTime, locationStatus: row.locationStatus,
vin: row.vin,
date: row.date,
dailyMileageKm: row.dailyMileageKm,
pureHydrogenMileageKm: row.pureHydrogenMileageKm,
hydrogenConsumptionKg: row.hydrogenConsumptionKg,
hydrogenPhysicalConsumptionKg: row.hydrogenPhysicalConsumptionKg,
hydrogenConsumptionKgPer100Km: row.hydrogenConsumptionKgPer100Km,
hydrogenSocBalancedKg: row.hydrogenSocBalancedKg,
pureElectricMileageKm: row.pureElectricMileageKm,
@@ -126,6 +129,7 @@ export function createMileageExportStream(input: Omit<MileageExportInput, 'vehic
export async function downloadMileageWorkbook(input: MileageExportInput, signal?: AbortSignal) {
const stream = createMileageExportStream({
metricView: input.metricView,
dateFrom: input.dateFrom,
dateTo: input.dateTo,
dates: input.dates,
@@ -26,6 +26,8 @@ export async function createMileageWorkbook(input: MileageExportInput) {
workbook.modified = input.exportedAt ?? new Date();
workbook.calcProperties.fullCalcOnLoad = true;
// Create the selected view first so Excel/WPS opens the requested export.
const primaryHydrogenSheet = input.metricView === 'hydrogen' ? workbook.addWorksheet('氢能明细') : undefined;
const sheet = workbook.addWorksheet('里程查询', {
views: [{ state: 'frozen', xSplit: 4, ySplit: 6, activeCell: 'E7', showGridLines: false }],
pageSetup: { orientation: 'landscape', fitToPage: true, fitToWidth: 1, fitToHeight: 0, paperSize: 9, margins: { left: .25, right: .25, top: .45, bottom: .45, header: .2, footer: .2 } },
@@ -133,14 +135,13 @@ export async function createMileageWorkbook(input: MileageExportInput) {
}
sheet.headerFooter.oddFooter = '&L灵牛车辆数据中台&C第 &P / &N 页&R导出于 ' + localDateTime(exportedAt);
const hydrogenSheet = workbook.addWorksheet('氢能明细', {
views: [{ state: 'frozen', ySplit: 1, activeCell: 'A2', showGridLines: false }],
properties: { defaultRowHeight: 21 }
});
const hydrogenSheet = primaryHydrogenSheet ?? workbook.addWorksheet('氢能明细');
hydrogenSheet.views = [{ state: 'frozen', xSplit: 2, ySplit: 1, activeCell: 'C2', showGridLines: false }];
hydrogenSheet.properties.defaultRowHeight = 21;
const hydrogenHeaders = [
'日期', '车牌', 'VIN', '品牌', '车型', '每日氢耗 (kg/100km)', '结果状态', '总里程 (km)',
'日期', '车牌', '当天所在省', '当天所在市', '大区', 'VIN', '品牌', '车型', '每日氢耗 (kg/100km)', '结果状态', '总里程 (km)',
'纯电里程 (km)', '混动里程 (km)', '电SOC差值 (百分点)', '充电次数', '充电量 (kWh)', '加氢次数', '加氢量 (kg)',
'修正用氢量 (kg)', '原因', '物理用氢量 (kg)', '来源'
'修正用氢量 (kg)', '原因', '物理用氢量 (kg)', '来源', '定位时间(当日最后有效点)', '位置状态'
];
hydrogenSheet.addRow(hydrogenHeaders);
const hydrogenHeader = hydrogenSheet.getRow(1);
@@ -158,6 +159,7 @@ export async function createMileageWorkbook(input: MileageExportInput) {
const row = hydrogenSheet.addRow([
value.date,
vehicle?.plate || value.plate || '未绑定',
value.province || '未知', value.city || '未知', value.region || '未知',
value.vin,
vehicle?.brandName || '待维护',
vehicle?.modelName || '待维护',
@@ -173,20 +175,21 @@ export async function createMileageWorkbook(input: MileageExportInput) {
value.refuelAmountKg ?? null,
value.hydrogenSocBalancedKg ?? null,
value.hydrogenQualityReason || '',
value.hydrogenConsumptionKg ?? null,
value.source ?? ''
value.hydrogenPhysicalConsumptionKg ?? null,
value.source ?? '',
value.locationTime || '', value.locationStatus || '未查询'
]);
row.height = 24;
row.eachCell({ includeEmpty: true }, (cell, columnNumber) => {
cell.font = { name: columnNumber === 3 ? 'Consolas' : 'Microsoft YaHei', size: columnNumber === 3 ? 9 : 10, color: { argb: 'FF34445A' } };
cell.alignment = { vertical: 'middle', horizontal: columnNumber >= 6 && columnNumber <= 16 ? 'right' : 'left' };
cell.font = { name: columnNumber === 6 ? 'Consolas' : 'Microsoft YaHei', size: columnNumber === 6 ? 9 : 10, color: { argb: 'FF34445A' } };
cell.alignment = { vertical: 'middle', horizontal: columnNumber >= 9 && columnNumber <= 19 ? 'right' : 'left' };
cell.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: index % 2 ? 'FFF9FBFD' : 'FFFFFFFF' } };
cell.border = { bottom: { style: 'thin', color: { argb: 'FFE6ECF3' } } };
if ([6, 8, 9, 10, 11, 13, 15, 16, 18].includes(columnNumber)) cell.numFmt = '#,##0.000';
if ([12, 14].includes(columnNumber)) cell.numFmt = '0';
if ([9, 11, 12, 13, 14, 16, 18, 19, 21].includes(columnNumber)) cell.numFmt = '#,##0.000';
if ([15, 17].includes(columnNumber)) cell.numFmt = '0';
});
});
[13, 15, 24, 18, 24, 22, 14, 15, 17, 17, 22, 12, 17, 12, 16, 19, 52, 19, 16].forEach((width, index) => {
[13, 15, 18, 18, 12, 24, 18, 24, 22, 14, 15, 17, 17, 22, 12, 17, 12, 16, 19, 52, 19, 16, 30, 24].forEach((width, index) => {
hydrogenSheet.getColumn(index + 1).width = width;
});
if (energyRows.length) {
@@ -1359,3 +1359,42 @@ test('opens a recent automation run in a focused event inbox', async () => {
expect(screen.getByTestId('alert-route-state')).not.toHaveTextContent('eventId=');
expect(screen.getByTestId('alert-route-state')).not.toHaveTextContent('runFromAutomationId=');
});
test.each([false, true])('displays native GB32960 alarms without automation (mobile=%s)', async (mobile) => {
layout.mobile = mobile;
const event: AlertEvent = {
...alertEvent('native-event', 'NATIVEVIN', '粤A原生01'),
ruleId: 'native-gb32960-alarm', ruleName: '绝缘报警、驱动电机温度报警', ruleVersion: 0,
protocol: 'GB32960', metric: 'alarm_active',
nativeAlarmNames: ['绝缘报警', '驱动电机温度报警'], nativeAlarmReservedBits: [20, 21],
nativeAlarmFields: {
'gb32960.alarm.max_alarm_level': 2,
'gb32960.alarm.general_alarm_flag': '0x00000004',
'gb32960.alarm.battery_faults': ['0x00000001'],
'gb32960.alarm.motor_faults': [],
'gb32960.alarm.engine_faults': ['0x00000002'],
'gb32960.alarm.other_faults': [],
},
};
mocks.alertSummaryV2.mockResolvedValue({ active: 1, unprocessed: 1, processing: 0, recovered: 0, closed: 0, ignored: 0, unreadNotifications: 0, asOf: '' });
mocks.alertEventsV2.mockResolvedValue({ items: [event], total: 1, limit: 20, offset: 0 });
mocks.alertEventV2.mockResolvedValue(event);
mocks.alertRulesV2.mockResolvedValue([]);
mocks.alertNotificationsV2.mockResolvedValue({ items: [], total: 0, limit: 100, offset: 0 });
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
render(<QueryClientProvider client={client}><MemoryRouter future={ROUTER_FUTURE} initialEntries={['/alerts?eventId=native-event']}><AlertsPage /></MemoryRouter></QueryClientProvider>);
const fields = await screen.findByRole('region', { name: '32960 原生告警字段' });
await waitFor(() => expect(fields).toHaveTextContent('2 · 二级故障'));
expect(fields).toHaveTextContent('0x00000004');
expect(fields).toHaveTextContent('可充电储能装置故障码0x00000001');
expect(fields).toHaveTextContent('驱动电机故障码无');
expect(fields).toHaveTextContent('发动机故障码0x00000002');
expect(fields).toHaveTextContent('其他故障码无');
expect(screen.queryByLabelText('事件证据')).not.toBeInTheDocument();
expect(screen.getByLabelText('具体告警信息')).toHaveTextContent('绝缘报警');
expect(screen.getByLabelText('具体告警信息')).toHaveTextContent('驱动电机温度报警');
expect(screen.getByLabelText('具体告警信息')).toHaveTextContent('bit20、bit21');
expect(screen.getByLabelText('事件执行轨迹')).toHaveTextContent('车辆原生告警入库');
expect(screen.getByLabelText('事件执行轨迹')).not.toHaveTextContent('匹配自动化');
});
@@ -6,7 +6,7 @@ import { Link, useSearchParams } from 'react-router-dom';
import { api } from '../../api/client';
import type { AlertEvent, AlertNotification, AlertNotificationChannelCapability, AlertNotificationConfig, AlertNotificationRetryResult, AlertNotificationTarget, AlertQuery, AlertRule, AlertRuleInput, AlertRulePage, AlertRuleRevision, AlertStatus, AlertTriggerType, MetricDefinition, Page } from '../../api/types';
import { actionLabels, alertDeltaText, alertValue, canAct, formatAlertDuration, formatAlertTime, operatorLabels, ruleCondition, thresholdText, triggerTypeLabels } from '../domain/alert';
import { automationEventType, automationSource, eventContract, eventEvidenceHistoryPath, protocolEventSources, toVehicleEvent } from '../domain/event';
import { automationEventType, automationSource, eventContract, eventEvidenceHistoryPath, isNativeAlarm, nativeAlarmDetails, protocolEventSources, toVehicleEvent } from '../domain/event';
import { InlineError, PanelEmpty, PanelError, PanelLoading } from '../shared/AsyncState';
import { MobileFilterSheet, MobileFilterSheetSection } from '../shared/MobileFilterSheet';
import { ProtocolTag } from '../shared/ProtocolTag';
@@ -341,7 +341,7 @@ const AlertEventTable = memo(function AlertEventTable({ rows, selectedID, compac
},
{ title: '协议', dataIndex: 'protocol', className: 'is-protocol', width: 98, render: (value: string) => <ProtocolTag className="v2-alert-protocol-tag" protocol={value} compact /> },
{ title: '发生时间', dataIndex: 'triggeredAt', className: 'is-triggered-at', width: 124, render: (value: string) => <AlertTime value={value} /> },
{ title: '匹配自动化', dataIndex: 'ruleName', className: 'is-rule', width: 154, render: (value: string) => <span className="v2-alert-event-rule" title={value}>{value}</span> },
{ title: '事件来源 / 自动化', dataIndex: 'ruleName', className: 'is-rule', width: 154, render: (value: string, event: AlertEvent) => <span className="v2-alert-event-rule" title={value}>{isNativeAlarm(event) ? '车辆原生告警' : value}</span> },
{ title: '执行状态', dataIndex: 'status', className: 'is-status', width: 92, render: (_: AlertEvent['status'], event: AlertEvent) => <EventExecutionTag event={event} /> },
];
return compact
@@ -390,14 +390,22 @@ function EventInspector({ event, loading = false, detailError, note, acting, act
<div className="v2-alert-focus-facts" aria-label="事件上下文">
<span><small></small><strong><ProtocolTag className="v2-alert-protocol-tag" protocol={event.protocol} compact unknownLabel="未知协议" /></strong></span>
<span><small></small><strong><AlertTime value={normalized.occurredAt} /></strong></span>
<span><small></small><strong>v{event.ruleVersion}</strong></span>
<span><small>{isNativeAlarm(event) ? '事件来源' : '匹配自动化'}</small><strong>{isNativeAlarm(event) ? '车辆原生告警' : `v${event.ruleVersion}`}</strong></span>
</div>
<div className="v2-alert-evidence" aria-label="事件证据">
{isNativeAlarm(event) ? <section aria-label="32960 原生告警字段">
<div className="v2-native-alarm-meaning" aria-label="具体告警信息">
<strong></strong>
{event.nativeAlarmNames?.length ? <ul>{event.nativeAlarmNames.map((name) => <li key={name}>{name}</li>)}</ul> : <p>{event.ruleName}</p>}
{event.nativeAlarmReservedBits?.length ? <p> {event.nativeAlarmReservedBits.map((bit) => `bit${bit}`).join('、')} </p> : null}
</div>
<p></p>
{event.nativeAlarmFields ? <Descriptions className="v2-alert-descriptions" align="left" size="small" data={nativeAlarmDetails(event)} /> : <p>{loading ? '正在读取告警字段…' : '告警字段暂不可用'}</p>}
</section> : <div className="v2-alert-evidence" aria-label="事件证据">
<Card className="v2-alert-evidence-value is-trigger"><small></small><strong>{alertValue(event)}</strong></Card>
<Card className="v2-alert-evidence-value"><small></small><strong>{thresholdText(event)}</strong></Card>
<Card className="v2-alert-evidence-value is-delta"><small></small><strong>{alertDeltaText(event)}</strong></Card>
</div>
<div className="v2-alert-focus-rule"><Tag color="blue" type="light" size="small"></Tag><span>{event.ruleName}</span></div>
</div>}
<div className="v2-alert-focus-rule"><Tag color="blue" type="light" size="small">{isNativeAlarm(event) ? '车辆原生告警' : '自动化'}</Tag><span>{event.ruleName}</span></div>
</Card>
{normalized.execution.requiresAttention ? <Card className="v2-alert-detail-card v2-alert-disposition-card" title={<span className="v2-alert-detail-title"><strong></strong><Tag color={editable ? 'orange' : 'grey'} type="light" size="small">{editable ? '选填' : '只读'}</Tag></span>} headerLine aria-label="事件处置操作">
{editable ? <><TextArea maxCount={200} autosize={{ minRows: 2, maxRows: 4 }} placeholder="补充处理说明(选填)" value={note} onChange={onNote} />{actionError ? <p className="v2-alert-action-error">{actionError}</p> : null}</> : <p className="v2-role-notice"></p>}
@@ -406,8 +414,8 @@ function EventInspector({ event, loading = false, detailError, note, acting, act
<Card className="v2-alert-detail-card v2-alert-progress-card" title={<span className="v2-alert-detail-title"><strong>线</strong><Tag color="blue" type="light" size="small">{normalized.execution.label}</Tag></span>} headerLine>
<Timeline className="v2-alert-timeline v2-event-execution-trace" aria-label="事件执行轨迹">
<Timeline.Item time={<AlertTime value={event.receivedAt} />}><strong></strong><p>{event.protocol} </p></Timeline.Item>
<Timeline.Item time={<AlertTime value={event.triggeredAt} />}><strong></strong><p>{event.ruleName} · v{event.ruleVersion}</p></Timeline.Item>
<Timeline.Item type={normalized.execution.requiresAttention ? 'ongoing' : 'default'} time={<AlertTime value={event.triggeredAt} />}><strong>{normalized.execution.requiresAttention ? '创建待办并通知' : '执行动作并记录'}</strong><p>{normalized.execution.requiresAttention ? '等待人工确认' : normalized.execution.label}</p></Timeline.Item>
<Timeline.Item time={<AlertTime value={event.triggeredAt} />}><strong>{isNativeAlarm(event) ? '车辆原生告警入库' : '匹配自动化'}</strong><p>{event.ruleName}{isNativeAlarm(event) ? '' : ` · v${event.ruleVersion}`}</p></Timeline.Item>
<Timeline.Item type={normalized.execution.requiresAttention ? 'ongoing' : 'default'} time={<AlertTime value={event.triggeredAt} />}><strong>{isNativeAlarm(event) ? '记录告警事件' : normalized.execution.requiresAttention ? '创建待办并通知' : '执行动作并记录'}</strong><p>{normalized.execution.requiresAttention ? '等待人工确认' : normalized.execution.label}</p></Timeline.Item>
{event.actions?.filter((item) => item.action !== 'trigger').map((item) => <Timeline.Item key={item.id} type="default" time={<span>{item.actor} · <AlertTime value={item.createdAt} /></span>}><strong>{actionLabels[item.action] ?? item.action}</strong>{item.note ? <p>{item.note}</p> : null}</Timeline.Item>)}
</Timeline>
</Card>
@@ -420,7 +428,7 @@ function EventInspector({ event, loading = false, detailError, note, acting, act
...eventContract(event).map((item) => ({ key: item.key, value: <code>{item.value || '—'}</code> })),
{ key: 'event.id', value: <code>{event.id}</code> },
{ key: 'source.event_id', value: <code>{event.sourceEventId || '—'}</code> },
{ key: 'automation', value: <code>{event.ruleId} / v{event.ruleVersion}</code> }
{ key: isNativeAlarm(event) ? 'source.kind' : 'automation', value: <code>{isNativeAlarm(event) ? 'native-gb32960' : `${event.ruleId} / v${event.ruleVersion}`}</code> }
]} />
</Collapse.Panel>
</Collapse>
@@ -567,7 +575,7 @@ function EventWorkspace({ filters, draft, setDraft, setFilters, rules, editable,
aria-label={mobileLayout ? '事件列表,可上下滚动' : undefined}
>
{mobileLayout
? <div className="v2-alert-mobile-list">{rows.map((event) => <Card key={event.id} className={`v2-alert-mobile-card${selectedID === event.id ? ' is-selected' : ''}`} bodyStyle={{ padding: 0 }}><Button theme="borderless" type="tertiary" className="v2-alert-mobile-action" aria-pressed={selectedID === event.id} aria-expanded={selectedID === event.id} aria-label={`查看 ${event.plate || event.vin} ${event.ruleName} 事件详情`} onClick={() => selectEvent(event.id)}><span className="v2-alert-mobile-card-content"><header><strong>{event.ruleName}</strong><EventExecutionTag event={event} /></header><p><strong>{event.plate || '未绑定车牌'}</strong><span>{event.vin}</span></p><div className="v2-alert-mobile-facts"><ProtocolTag className="v2-alert-protocol-tag" protocol={event.protocol} compact /><span><AlertTime value={event.triggeredAt} /></span><span className="v2-alert-mobile-condition"><i></i>{thresholdText(event)}</span></div><footer aria-hidden="true"><IconChevronRight /></footer></span></Button></Card>)}</div>
? <div className="v2-alert-mobile-list">{rows.map((event) => <Card key={event.id} className={`v2-alert-mobile-card${selectedID === event.id ? ' is-selected' : ''}`} bodyStyle={{ padding: 0 }}><Button theme="borderless" type="tertiary" className="v2-alert-mobile-action" aria-pressed={selectedID === event.id} aria-expanded={selectedID === event.id} aria-label={`查看 ${event.plate || event.vin} ${event.ruleName} 事件详情`} onClick={() => selectEvent(event.id)}><span className="v2-alert-mobile-card-content"><header><strong>{event.ruleName}</strong><EventExecutionTag event={event} /></header><p><strong>{event.plate || '未绑定车牌'}</strong><span>{event.vin}</span></p><div className="v2-alert-mobile-facts"><ProtocolTag className="v2-alert-protocol-tag" protocol={event.protocol} compact /><span><AlertTime value={event.triggeredAt} /></span><span className="v2-alert-mobile-condition"><i>{isNativeAlarm(event) ? '事件来源' : '匹配条件'}</i>{isNativeAlarm(event) ? '车辆原生告警' : thresholdText(event)}</span></div><footer aria-hidden="true"><IconChevronRight /></footer></span></Button></Card>)}</div>
: <AlertEventTable rows={rows} selectedID={selectedID} compact={Boolean(selectedID)} onSelect={selectEvent} />}
{events.isFetching ? <PanelLoading className="v2-alert-loading" title="正在更新事件…" description="告警列表返回后会自动更新。" compact={Boolean(rows.length)} /> : null}
{!events.isFetching && !events.isError && !rows.length ? <PanelEmpty
@@ -7,7 +7,7 @@ import { buildMonitorPath, withMonitorReturn } from '../routing/monitorContext';
import { ROUTER_FUTURE } from '../routing/routerConfig';
import { buildVehicleDetailPath, withVehicleReturn } from '../routing/vehicleContext';
const mocks = vi.hoisted(() => ({ mileageStatistics: vi.fn(), dailyMileage: vi.fn(), hydrogenDailyEvidence: vi.fn(), vehicles: vi.fn(), vehicleCoverage: vi.fn(), vehicleServiceOverviews: vi.fn() }));
const mocks = vi.hoisted(() => ({ historyLocations: vi.fn(), mileageStatistics: vi.fn(), dailyMileage: vi.fn(), hydrogenDailyEvidence: vi.fn(), vehicles: vi.fn(), vehicleCoverage: vi.fn(), vehicleServiceOverviews: vi.fn() }));
const exportMocks = vi.hoisted(() => ({ createMileageExportStream: vi.fn(), appendRows: vi.fn(), finish: vi.fn(), dispose: vi.fn() }));
const layout = vi.hoisted(() => ({ mobile: false }));
const auth = vi.hoisted(() => ({ role: 'admin' as 'admin' | 'customer', userType: 'admin' as 'admin' | 'customer' }));
@@ -24,6 +24,7 @@ function renderPage(initialEntry = '/statistics') {
}
function prepareData() {
mocks.historyLocations.mockResolvedValue({ items: [], total: 0 });
exportMocks.finish.mockResolvedValue(undefined);
exportMocks.createMileageExportStream.mockReturnValue({ appendRows: exportMocks.appendRows, finish: exportMocks.finish, dispose: exportMocks.dispose });
mocks.mileageStatistics.mockResolvedValue({
@@ -35,8 +36,8 @@ function prepareData() {
asOf: '2026-07-14 13:20:00', evidence: 'production mileage evidence'
});
mocks.dailyMileage.mockResolvedValue({ items: [
{ vin: 'LTEST000000000001', plate: '粤A12345', date: '2026-07-13', startMileageKm: 119731.7, endMileageKm: 119820.4, dailyMileageKm: 88.7, pureHydrogenMileageKm: 56.2, hydrogenConsumptionKg: 3.1, hydrogenConsumptionKgPer100Km: 5.5, hydrogenEvidenceAvailable: true, hydrogenQualityStatus: 'OK', hydrogenAlgorithmVersion: 'PRESSURE_NIST_SEGMENT_MEDIAN_5_SOC_BALANCE_V2', source: 'GB32960' },
{ vin: 'LTEST000000000001', plate: '粤A12345', date: '2026-07-14', startMileageKm: 119820.4, endMileageKm: 119925, dailyMileageKm: 104.6, pureHydrogenMileageKm: 72.2, hydrogenConsumptionKg: 4.2, hydrogenConsumptionKgPer100Km: 5.8, hydrogenEvidenceAvailable: true, hydrogenQualityStatus: 'OK', hydrogenAlgorithmVersion: 'PRESSURE_NIST_SEGMENT_MEDIAN_5_SOC_BALANCE_V2', source: 'GB32960' }
{ vin: 'LTEST000000000001', plate: '粤A12345', date: '2026-07-13', startMileageKm: 119731.7, endMileageKm: 119820.4, dailyMileageKm: 88.7, pureHydrogenMileageKm: 56.2, hydrogenConsumptionKg: 2.9, hydrogenSocBalancedKg: 3.1, hydrogenConsumptionKgPer100Km: 5.5, hydrogenEvidenceAvailable: true, hydrogenQualityStatus: 'OK', hydrogenAlgorithmVersion: 'PRESSURE_NIST_SEGMENT_MEDIAN_5_SOC_BALANCE_V2', source: 'GB32960' },
{ vin: 'LTEST000000000001', plate: '粤A12345', date: '2026-07-14', startMileageKm: 119820.4, endMileageKm: 119925, dailyMileageKm: 104.6, pureHydrogenMileageKm: 72.2, hydrogenConsumptionKg: 4.0, hydrogenSocBalancedKg: 4.2, hydrogenConsumptionKgPer100Km: 5.8, hydrogenEvidenceAvailable: true, hydrogenQualityStatus: 'OK', hydrogenAlgorithmVersion: 'PRESSURE_NIST_SEGMENT_MEDIAN_5_SOC_BALANCE_V2', source: 'GB32960' }
], total: 2, limit: 10000, offset: 0 });
mocks.hydrogenDailyEvidence.mockResolvedValue({
vin: 'LTEST000000000001', plate: '粤A12345', date: '2026-07-13', source: 'factory-a',
@@ -147,14 +148,14 @@ test('renders only the desktop matrix with dates as columns and a period total',
const hydrogenGuide = screen.getByRole('note', { name: '氢耗矩阵读表说明' });
expect(hydrogenGuide).toHaveTextContent('日期子列里程固定展示');
expect(screen.getByRole('group', { name: '日期子列' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: '用氢量' })).toHaveAttribute('aria-pressed', 'true');
expect(screen.getByRole('button', { name: '修正用氢量' })).toHaveAttribute('aria-pressed', 'true');
expect(screen.getByRole('button', { name: '纯电里程' })).toHaveAttribute('aria-pressed', 'false');
expect(screen.getByRole('button', { name: '纯氢里程' })).toHaveAttribute('aria-pressed', 'false');
expect(screen.getByRole('button', { name: '百公里氢耗' })).toHaveAttribute('aria-pressed', 'true');
expect(hydrogenGuide).toHaveTextContent('里程 当日末累计里程-同源日基线');
expect(hydrogenGuide).toHaveTextContent('纯电里程 总里程-纯氢里程');
expect(hydrogenGuide).toHaveTextContent('纯氢里程 运行模式 0x02 的相邻里程差累计');
expect(hydrogenGuide).toHaveTextContent('百公里氢耗 有效用氢量÷纯氢里程×100');
expect(hydrogenGuide).toHaveTextContent('百公里氢耗 修正用氢量÷纯氢里程×100');
expect(screen.getByRole('region', { name: '车辆每日氢耗矩阵,可横向滚动查看日期' })).toBeInTheDocument();
expect(view.container.querySelector('.v2-mileage-summary .v2-workspace-metric-list')).toHaveTextContent('氢耗匹配纯氢里程128.4 km纯氢里程=总里程-纯电模式里程');
expect(view.container.querySelector('.v2-mileage-summary .v2-workspace-metric-list')).toHaveTextContent('区间氢耗7.3 kg5.7 kg/100km · 2 个有效车辆日');
@@ -655,3 +656,16 @@ test('aborts an active export when the mileage route unmounts', async () => {
expect(exportSignal?.aborted).toBe(true);
expect(exportMocks.createMileageExportStream).not.toHaveBeenCalled();
});
test('exports stored daily geography without querying history or geocoding', async () => {
prepareData();
const row = { vin: 'LTEST000000000001', date: '2026-07-13', dailyMileageKm: 100, province: '广东省', city: '广州市', region: '华南', locationTime: '2026-07-13T23:00:00+08:00', locationStatus: '已解析' };
mocks.dailyMileage.mockResolvedValue({ items: [row], total: 1, limit: 10000, offset: 0 });
renderPage('/statistics?vins=LTEST000000000001&dateFrom=2026-07-13&dateTo=2026-07-13');
fireEvent.click(await screen.findByRole('button', { name: '氢耗' }));
fireEvent.click(screen.getByRole('button', { name: '导出 Excel' }));
await waitFor(() => expect(exportMocks.finish).toHaveBeenCalledTimes(1));
expect(exportMocks.appendRows).toHaveBeenCalledWith([row]);
expect(mocks.historyLocations).not.toHaveBeenCalled();
});
@@ -47,7 +47,7 @@ type HydrogenMatrixMetric = 'consumption' | 'pureElectric' | 'pureHydrogen' | 'r
type MatrixMetric = 'mileage' | HydrogenMatrixMetric;
const HYDROGEN_MATRIX_METRICS: Array<{ key: HydrogenMatrixMetric; label: string }> = [
{ key: 'consumption', label: '用氢量' },
{ key: 'consumption', label: '修正用氢量' },
{ key: 'pureElectric', label: '纯电里程' },
{ key: 'pureHydrogen', label: '纯氢里程' },
{ key: 'rate', label: '百公里氢耗' }
@@ -55,7 +55,7 @@ const HYDROGEN_MATRIX_METRICS: Array<{ key: HydrogenMatrixMetric; label: string
const DEFAULT_HYDROGEN_MATRIX_METRICS: HydrogenMatrixMetric[] = ['consumption', 'rate'];
const MATRIX_METRIC_META: Record<MatrixMetric, { label: string; unit: string; width: number }> = {
mileage: { label: '里程', unit: 'km', width: 92 },
consumption: { label: '用氢量', unit: 'kg', width: 88 },
consumption: { label: '修正用氢量', unit: 'kg', width: 88 },
pureElectric: { label: '纯电里程', unit: 'km', width: 96 },
pureHydrogen: { label: '纯氢里程', unit: 'km', width: 96 },
rate: { label: '百公里氢耗', unit: 'kg/100km', width: 112 }
@@ -633,7 +633,8 @@ function SummaryRail({ data, criteria, fleetTotal, loading, view, unavailable =
}
type HydrogenDayMetric = {
consumptionKg: number;
consumptionKg?: number;
matchedMileageKm: number;
rateKgPer100Km?: number;
socBalancedKg?: number;
socBalancedRateKgPer100Km?: number;
@@ -690,10 +691,10 @@ function MileageMatrixGuide({
</div>
<div className="v2-mileage-metric-logic" aria-label="指标取值逻辑">
<span><strong></strong> 线</span>
<span><strong></strong> </span>
<span><strong></strong> SOC变化折算用氢量</span>
<span><strong></strong> </span>
<span><strong></strong> 0x02 </span>
<span><strong></strong> ÷×100</span>
<span><strong></strong> ÷×100</span>
<span><strong></strong> </span>
</div>
</aside>;
@@ -872,7 +873,8 @@ function HydrogenEvidenceSheet({ target, onClose }: { target?: HydrogenEvidenceT
badgeColor={data?.qualityStatus === 'OK' ? 'green' : data?.qualityStatus === 'SUSPECT' ? 'orange' : 'grey'}
width="min(760px, 94vw)"
summaryItems={data ? [
{ label: '物理耗氢', value: `${evidenceNumber(data.rawConsumptionKg)} kg`, detail: 'NIST压力—质量差分段累计', tone: 'primary' },
{ label: '百公里氢耗', value: data.consumptionKgPer100Km == null ? '未计算' : `${evidenceNumber(data.consumptionKgPer100Km)} kg/100km`, detail: '修正用氢量÷匹配里程×100', tone: 'primary' },
{ label: '物理耗氢', value: `${evidenceNumber(data.rawConsumptionKg)} kg`, detail: '原始计算量,供核对', tone: 'neutral' },
{ label: 'SOC平衡氢耗', value: data.socBalancedConsumptionKg == null ? '未计算' : `${evidenceNumber(data.socBalancedConsumptionKg)} kg`, detail: data.parameters.batteryCapacityKWh > 0 ? `电池 ${evidenceNumber(data.parameters.batteryCapacityKWh, 2)} kWh` : '缺少经确认的电池容量', tone: 'warning' },
{ label: '混动里程', value: `${evidenceNumber(data.mixedMileageKm)} km`, detail: `${data.validSegmentCount} 个有效区间`, tone: 'success' }
] : []}
@@ -1044,9 +1046,10 @@ export default function StatisticsPage() {
const pureHydrogenMileage = Math.max(0, Math.min(row.dailyMileageKm, row.pureHydrogenMileageKm ?? 0));
entry.pureHydrogenDays.set(row.date, pureHydrogenMileage);
entry.pureElectricDays.set(row.date, Math.max(0, row.dailyMileageKm - pureHydrogenMileage));
if (row.hydrogenConsumptionKg != null) {
if (row.hydrogenConsumptionKg != null || row.hydrogenSocBalancedKg != null) {
entry.hydrogenDays.set(row.date, {
consumptionKg: row.hydrogenConsumptionKg,
consumptionKg: row.hydrogenSocBalancedKg ?? undefined,
matchedMileageKm: row.mixedMileageKm != null && row.mixedMileageKm > 0 ? row.mixedMileageKm : row.pureHydrogenMileageKm ?? 0,
rateKgPer100Km: row.hydrogenConsumptionKgPer100Km ?? undefined,
socBalancedKg: row.hydrogenSocBalancedKg ?? undefined,
socBalancedRateKgPer100Km: row.hydrogenSocBalancedKgPer100Km ?? undefined,
@@ -1070,17 +1073,21 @@ export default function StatisticsPage() {
const sources = daily?.sources ?? new Map<string, string>();
let dailyTotal = 0;
let hydrogenTotal = 0;
let hydrogenRatedTotal = 0;
let correctedDays = 0;
let hydrogenRatedMileage = 0;
let pureElectricTotal = 0;
let pureHydrogenTotal = 0;
for (const value of days.values()) dailyTotal += value;
for (const value of pureElectricDays.values()) pureElectricTotal += value;
for (const value of pureHydrogenDays.values()) pureHydrogenTotal += value;
for (const [date, value] of hydrogenDays) {
const matchedMileage = pureHydrogenDays.get(date) ?? 0;
if (matchedMileage <= 0) continue;
for (const value of hydrogenDays.values()) {
if (value.consumptionKg == null) continue;
hydrogenTotal += value.consumptionKg;
hydrogenRatedMileage += matchedMileage;
correctedDays++;
if (value.matchedMileageKm <= 0 || value.qualityStatus !== 'OK' || value.rateKgPer100Km == null) continue;
hydrogenRatedTotal += value.consumptionKg;
hydrogenRatedMileage += value.matchedMileageKm;
}
return {
...vehicle,
@@ -1093,8 +1100,8 @@ export default function StatisticsPage() {
totalMileageKm: days.size ? ranking?.mileageKm ?? dailyTotal : undefined,
totalPureElectricMileageKm: pureElectricDays.size ? pureElectricTotal : undefined,
totalPureHydrogenMileageKm: pureHydrogenDays.size ? pureHydrogenTotal : undefined,
totalHydrogenConsumptionKg: hydrogenDays.size ? hydrogenTotal : undefined,
totalHydrogenRateKgPer100Km: hydrogenDays.size && hydrogenRatedMileage > 0 ? hydrogenTotal * 100 / hydrogenRatedMileage : undefined
totalHydrogenConsumptionKg: correctedDays ? hydrogenTotal : undefined,
totalHydrogenRateKgPer100Km: hydrogenDays.size && hydrogenRatedMileage > 0 ? hydrogenRatedTotal * 100 / hydrogenRatedMileage : undefined
};
}), [displayVehicles, mileageByVin, rankingByVin]);
const totalVehicles = hasVehicles ? criteria.vehicles.length : fleetVehicles.data?.total ?? 0;
@@ -1204,6 +1211,7 @@ export default function StatisticsPage() {
}
const plateByVin = new Map(vehicles.filter((vehicle) => vehicle.plate).map((vehicle) => [vehicle.vin, vehicle.plate]));
exportStream = createMileageExportStream({
metricView,
dateFrom: criteria.dateFrom,
dateTo: criteria.dateTo,
dates,