chore: snapshot production code before Apple Design UI refinement
This commit is contained in:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user