feat: build vehicle data platform and production pipeline

This commit is contained in:
lingniu
2026-07-14 12:35:33 +08:00
parent b452be3b94
commit bb59303a4b
270 changed files with 88016 additions and 1975 deletions

View File

@@ -0,0 +1,25 @@
import { describe, expect, it } from 'vitest';
import { accessRowsToCSV, formatSeconds, thresholdForProtocol, updateProtocolThreshold } from './access';
describe('access domain helpers', () => {
it('formats duration without hiding sign or long offline windows', () => {
expect(formatSeconds(45)).toBe('45 秒');
expect(formatSeconds(3720)).toBe('1 小时 2 分');
expect(formatSeconds(-3)).toBe('-3 秒');
expect(formatSeconds(null)).toBe('—');
});
it('uses protocol override and updates without duplicates', () => {
const config = { version: 1, defaultThresholdSec: 300, delayThresholdSec: 30, longOfflineSec: 1800, updatedBy: '', updatedAt: '', audit: [], protocols: [{ protocol: 'JT808', thresholdSec: 60 }] };
expect(thresholdForProtocol(config, 'JT808')).toBe(60);
expect(thresholdForProtocol(config, 'GB32960')).toBe(300);
expect(updateProtocolThreshold(config.protocols, 'JT808', 120)).toEqual([{ protocol: 'JT808', thresholdSec: 120 }]);
});
it('exports explicit state and evidence fields', () => {
const csv = accessRowsToCSV([{ vin: 'VIN1', plate: '粤A1', oem: '', model: '', company: '示范企业', protocol: 'JT808', provider: '', source: '', firstSeenAt: '', latestEventAt: '', latestReceivedAt: '', reportIntervalSec: null, dataDelaySec: 2, freshnessSec: 3, onlineState: 'online', thresholdSec: 60, latestMessageType: '位置,数据', latestEventId: '', latestError: '', delayAbnormal: false, firstSeenEvidence: '', firstSeenSource: '', reportIntervalEvidence: '', reportSampleCount: 2 }]);
expect(csv).toContain('在线');
expect(csv).toContain('"位置,数据"');
expect(csv).toContain('"示范企业"');
});
});

View File

@@ -0,0 +1,45 @@
import type { AccessProtocolThreshold, AccessThresholdConfig, AccessVehicleRow } from '../../api/types';
export const accessStateLabels: Record<AccessVehicleRow['onlineState'], string> = {
online: '在线',
offline: '离线',
never_reported: '从未上报',
unknown: '未知'
};
export function formatSeconds(value: number | null | undefined) {
if (value === null || value === undefined || !Number.isFinite(value)) return '—';
const sign = value < 0 ? '-' : '';
const seconds = Math.abs(Math.round(value));
if (seconds < 60) return `${sign}${seconds}`;
if (seconds < 3600) return `${sign}${Math.floor(seconds / 60)}${seconds % 60}`;
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
return `${sign}${hours} 小时${minutes ? ` ${minutes}` : ''}`;
}
export function formatAccessTime(value: string) {
if (!value) return '—';
const parsed = new Date(value);
if (Number.isNaN(parsed.getTime())) return '—';
return new Intl.DateTimeFormat('zh-CN', {
year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false
}).format(parsed).replace(/\//g, '-');
}
export function thresholdForProtocol(config: AccessThresholdConfig, protocol: string) {
return config.protocols.find((item) => item.protocol === protocol)?.thresholdSec ?? config.defaultThresholdSec;
}
export function updateProtocolThreshold(items: AccessProtocolThreshold[], protocol: string, thresholdSec: number) {
const next = items.filter((item) => item.protocol !== protocol);
next.push({ protocol, thresholdSec });
return next.sort((a, b) => a.protocol.localeCompare(b.protocol));
}
export function accessRowsToCSV(rows: AccessVehicleRow[]) {
const columns = ['在线状态', '车牌', 'VIN', '厂家', '车型', '企业', '协议', '接入厂家', '首次接入', '首次接入证据', '最新事件时间', '最新接收时间', '上报间隔(秒)', '持久样本数', '上报间隔证据', '数据延迟(秒)', '动态阈值(秒)', '最新消息类型', '最近错误'];
const quote = (value: unknown) => `"${String(value ?? '').replace(/"/g, '""')}"`;
const lines = rows.map((row) => [accessStateLabels[row.onlineState], row.plate, row.vin, row.oem, row.model, row.company, row.protocol, row.provider, row.firstSeenAt, row.firstSeenEvidence, row.latestEventAt, row.latestReceivedAt, row.reportIntervalSec, row.reportSampleCount, row.reportIntervalEvidence, row.dataDelaySec, row.thresholdSec, row.latestMessageType, row.latestError].map(quote).join(','));
return `\uFEFF${columns.map(quote).join(',')}\n${lines.join('\n')}`;
}

View File

@@ -0,0 +1,26 @@
import { describe, expect, it } from 'vitest';
import { alertValue, canAct, ruleCondition, thresholdText } from './alert';
describe('alert domain helpers', () => {
it('keeps trigger evidence and duration explicit', () => {
const event = { triggerValue: 96, threshold: 80, thresholdHigh: 0, operator: 'gt', unit: 'km/h', durationSec: 60 };
expect(alertValue(event)).toBe('96 km/h');
expect(thresholdText(event)).toBe('> 80 km/h持续 60 秒');
});
it('enforces valid workflow transitions', () => {
expect(canAct('unprocessed', 'acknowledge')).toBe(true);
expect(canAct('processing', 'acknowledge')).toBe(false);
expect(canAct('recovered', 'close')).toBe(true);
expect(canAct('closed', 'ignore')).toBe(false);
});
it('renders boolean rules without numeric fiction', () => {
expect(ruleCondition({ valueType: 'boolean', booleanThreshold: true, metric: 'alarm_active', durationSec: 0 } as never)).toBe('协议告警位 是');
});
it('renders range and state-change semantics', () => {
expect(thresholdText({ threshold: 20, thresholdHigh: 80, operator: 'between', unit: '%', durationSec: 30 })).toBe('区间内 2080 %,持续 30 秒');
expect(ruleCondition({ valueType: 'boolean', metric: 'alarm_active', operator: 'changed', durationSec: 0 } as never)).toBe('协议告警位 状态变化');
});
});

View File

@@ -0,0 +1,36 @@
import type { AlertEvent, AlertRule, AlertSeverity, AlertStatus } from '../../api/types';
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 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 function formatAlertTime(value: string) {
if (!value) return '—';
const date = new Date(value);
if (Number.isNaN(date.getTime())) return value.replace('T', ' ').slice(0, 19);
return new Intl.DateTimeFormat('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false }).format(date).replace(/\//g, '-');
}
export function alertValue(event: Pick<AlertEvent, 'triggerValue' | 'unit'>) {
return `${Number(event.triggerValue.toFixed(2)).toLocaleString('zh-CN')} ${event.unit}`.trim();
}
export function thresholdText(event: Pick<AlertEvent, 'operator' | 'threshold' | 'thresholdHigh' | 'unit' | 'durationSec'>) {
const duration = event.durationSec > 0 ? `,持续 ${event.durationSec}` : '';
if (event.operator === 'between' || event.operator === 'outside') return `${operatorLabels[event.operator]} ${event.threshold}${event.thresholdHigh} ${event.unit}${duration}`.trim();
if (event.operator === 'changed') return `状态发生变化${duration}`;
return `${operatorLabels[event.operator] ?? event.operator} ${Number(event.threshold.toFixed(2)).toLocaleString('zh-CN')} ${event.unit}${duration}`.trim();
}
export function ruleCondition(rule: AlertRule, labels: Record<string, string> = metricLabels) {
const threshold = rule.operator === 'changed' ? '状态变化' : rule.operator === 'between' || rule.operator === 'outside' ? `${operatorLabels[rule.operator]} ${rule.threshold}${rule.thresholdHigh}` : rule.valueType === 'boolean' ? (rule.booleanThreshold ? '是' : '否') : `${operatorLabels[rule.operator] ?? rule.operator} ${rule.threshold}`;
return `${labels[rule.metric] ?? rule.metric} ${threshold}${rule.durationSec ? ` · ${rule.durationSec}` : ''}`;
}
export function canAct(status: AlertStatus, action: 'acknowledge' | 'close' | 'ignore') {
if (action === 'acknowledge') return status === 'unprocessed';
if (action === 'close') return status === 'unprocessed' || status === 'processing' || status === 'recovered';
return status === 'unprocessed' || status === 'processing';
}

View File

@@ -0,0 +1,44 @@
import { describe, expect, it } from 'vitest';
import { buildHistoryChartSeries, buildHistorySeriesPanels, formatExportFileSize, formatHistoryValue, parseHistoryKeywords } from './history';
describe('history domain', () => {
it('parses and bounds multi-vehicle input', () => {
expect(parseHistoryKeywords('粤A1, 粤A1;VIN2\nVIN3')).toEqual(['粤A1', 'VIN2', 'VIN3']);
expect(parseHistoryKeywords('1,2,3,4,5,6')).toHaveLength(5);
});
it('formats units without fabricating missing values', () => {
expect(formatHistoryValue(undefined)).toBe('—');
expect(formatHistoryValue(42.5, { unit: 'km/h' } as never)).toBe('42.5 km/h');
});
it('builds only numeric series with at least two evidence points', () => {
const rows = [
{ values: { speedKmh: 20 }, deviceTime: '2' },
{ values: { speedKmh: 10 }, deviceTime: '1' }
] as never;
const series = buildHistoryChartSeries(rows, [{ key: 'speedKmh', label: '速度', unit: 'km/h' }] as never);
expect(series).toHaveLength(1);
expect(series[0].path).toContain('M');
});
it('builds unit-separated server aggregate panels and breaks lines across missing buckets', () => {
const response = {
dateFrom: '2026-07-13T16:00:00Z', dateTo: '2026-07-13T17:00:00Z',
summary: { grainSeconds: 60 },
series: [
{ vin: 'VIN1', plate: '粤A1', protocol: 'GB32960', metric: 'speedKmh', label: '速度', unit: 'km/h', points: [{ time: '2026-07-13T16:00:00Z', value: 10 }, { time: '2026-07-13T16:01:00Z', value: 20 }, { time: '2026-07-13T16:10:00Z', value: 30 }] },
{ vin: 'VIN1', plate: '粤A1', protocol: 'GB32960', metric: 'totalMileageKm', label: '总里程', unit: 'km', points: [{ time: '2026-07-13T16:00:00Z', value: 100 }, { time: '2026-07-13T16:01:00Z', value: 101 }] }
]
} as never;
const panels = buildHistorySeriesPanels(response);
expect(panels.map((panel) => panel.unit)).toEqual(['km/h', 'km']);
expect(panels[0].lines[0].paths).toHaveLength(2);
});
it('formats persisted export file sizes compactly', () => {
expect(formatExportFileSize(0)).toBe('—');
expect(formatExportFileSize(1536)).toBe('1.5 KB');
expect(formatExportFileSize(5 * 1024 * 1024)).toBe('5.0 MB');
});
});

View File

@@ -0,0 +1,87 @@
import type { HistoryDataRow, HistoryMetricDefinition, HistorySeries, HistorySeriesResponse } from '../../api/types';
export function parseHistoryKeywords(value: string) {
const seen = new Set<string>();
return value.split(/[,;\n]/).map((item) => item.trim()).filter((item) => {
const key = item.toLowerCase();
if (!item || seen.has(key)) return false;
seen.add(key);
return true;
}).slice(0, 5);
}
export function formatHistoryValue(value: unknown, metric?: HistoryMetricDefinition) {
if (value == null || value === '') return '—';
const formatted = typeof value === 'number' ? new Intl.NumberFormat('zh-CN', { maximumFractionDigits: 6 }).format(value) : String(value);
return metric?.unit ? `${formatted} ${metric.unit}` : formatted;
}
export type ChartSeries = { key: string; label: string; color: string; path: string; points: number };
const chartColors = ['#1268f3', '#12a46f', '#8b5cf6', '#f59e0b'];
export function buildHistoryChartSeries(rows: HistoryDataRow[], metrics: HistoryMetricDefinition[], width = 800, height = 150): ChartSeries[] {
const ordered = [...rows].reverse();
return metrics.slice(0, 4).flatMap((metric, seriesIndex) => {
const values = ordered.map((row, rowIndex) => ({ rowIndex, value: row.values[metric.key] })).filter((item): item is { rowIndex: number; value: number } => typeof item.value === 'number' && Number.isFinite(item.value));
if (values.length < 2) return [];
let min = values[0].value;
let max = values[0].value;
for (const item of values) { if (item.value < min) min = item.value; if (item.value > max) max = item.value; }
const range = max - min || 1;
const usableWidth = width - 24;
const usableHeight = height - 24;
const path = values.map((item, pointIndex) => {
const x = 12 + (ordered.length <= 1 ? 0 : item.rowIndex / (ordered.length - 1)) * usableWidth;
const y = 12 + (1 - (item.value - min) / range) * usableHeight;
return `${pointIndex ? 'L' : 'M'}${x.toFixed(1)},${y.toFixed(1)}`;
}).join(' ');
return [{ key: metric.key, label: metric.unit ? `${metric.label} (${metric.unit})` : metric.label, color: chartColors[seriesIndex], path, points: values.length }];
});
}
export type HistorySeriesLine = { key: string; label: string; color: string; paths: string[]; points: number };
export type HistorySeriesPanel = { key: string; label: string; unit: string; minimum: number; maximum: number; start: string; end: string; lines: HistorySeriesLine[] };
export function buildHistorySeriesPanels(response?: HistorySeriesResponse, width = 800, height = 116): HistorySeriesPanel[] {
if (!response) return [];
const byMetric = new Map<string, HistorySeries[]>();
response.series.forEach((series) => byMetric.set(series.metric, [...(byMetric.get(series.metric) ?? []), series]));
return [...byMetric.entries()].flatMap(([metric, seriesList], panelIndex) => {
const values = seriesList.flatMap((series) => series.points.map((point) => point.value).filter((value): value is number => typeof value === 'number' && Number.isFinite(value)));
if (!values.length) return [];
let minimum = Math.min(...values); let maximum = Math.max(...values);
if (minimum === maximum) { const padding = Math.max(Math.abs(minimum) * 0.05, 1); minimum -= padding; maximum += padding; }
const startMs = new Date(response.dateFrom).getTime(); const endMs = new Date(response.dateTo).getTime();
const timeRange = Math.max(1, endMs - startMs); const valueRange = maximum - minimum;
const lines = seriesList.map((series, seriesIndex) => {
const paths: string[] = []; let current = ''; let previousMs: number | undefined;
series.points.forEach((point) => {
if (typeof point.value !== 'number' || !Number.isFinite(point.value)) { if (current) paths.push(current); current = ''; previousMs = undefined; return; }
const time = new Date(point.time.replace(' ', 'T')).getTime();
if (!Number.isFinite(time)) return;
if (previousMs != null && time - previousMs > response.summary.grainSeconds * 1500) { if (current) paths.push(current); current = ''; }
const x = 54 + Math.max(0, Math.min(1, (time - startMs) / timeRange)) * (width - 68);
const y = 10 + (1 - (point.value - minimum) / valueRange) * (height - 30);
current += `${current ? ' L' : 'M'}${x.toFixed(1)},${y.toFixed(1)}`; previousMs = time;
});
if (current) paths.push(current);
return { key: `${series.vin}-${series.protocol}-${metric}`, label: `${series.plate || series.vin} · ${series.protocol}`, color: chartColors[(panelIndex * 2 + seriesIndex) % chartColors.length], paths, points: series.points.length };
});
const first = seriesList[0];
return [{ key: metric, label: first.label, unit: first.unit, minimum, maximum, start: response.dateFrom, end: response.dateTo, lines }];
});
}
export function formatSeriesGrain(seconds: number) {
if (seconds < 60) return `${seconds}`;
if (seconds < 3600) return `${seconds / 60} 分钟`;
if (seconds < 86400) return `${seconds / 3600} 小时`;
return `${seconds / 86400}`;
}
export function formatExportFileSize(bytes: number) {
if (!Number.isFinite(bytes) || bytes <= 0) return '—';
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
}

View File

@@ -0,0 +1,40 @@
import type { VehicleRealtimeRow } from '../../api/types';
import { describe, expect, it } from 'vitest';
import { formatNumber, statusLabel, vehicleStatus } from './monitor';
function vehicle(overrides: Partial<VehicleRealtimeRow> = {}): VehicleRealtimeRow {
return {
vin: 'LTEST000000000001',
plate: '粤A00001',
phone: '',
oem: '',
protocols: ['JT808'],
sourceStatus: [],
sourceCount: 1,
onlineSourceCount: 1,
online: true,
bindingStatus: 'bound',
primaryProtocol: 'JT808',
longitude: 113.2,
latitude: 23.1,
speedKmh: 0,
socPercent: 80,
totalMileageKm: 10,
lastSeen: '2026-07-14 01:00:00',
...overrides
};
}
describe('monitor domain', () => {
it('keeps online, motion and unknown status semantics separate', () => {
expect(vehicleStatus(vehicle({ online: false }))).toBe('offline');
expect(vehicleStatus(vehicle({ speedKmh: 32 }))).toBe('driving');
expect(vehicleStatus(vehicle({ speedKmh: 0 }))).toBe('idle');
expect(vehicleStatus(vehicle({ lastSeen: '' }))).toBe('unknown');
});
it('formats dense monitor values consistently', () => {
expect(formatNumber(12560)).toBe('12,560');
expect(statusLabel('driving')).toBe('行驶');
});
});

View File

@@ -0,0 +1,35 @@
import type { VehicleRealtimeRow } from '../../api/types';
export type FleetStatus = 'online' | 'offline' | 'driving' | 'idle' | 'alert' | 'unknown';
export function vehicleStatus(vehicle: VehicleRealtimeRow): FleetStatus {
if (!vehicle.lastSeen) return 'unknown';
if (!vehicle.online) return 'offline';
if (vehicle.speedKmh > 3) return 'driving';
return 'idle';
}
export function statusLabel(status: FleetStatus) {
return {
online: '在线',
offline: '离线',
driving: '行驶',
idle: '静止',
alert: '告警',
unknown: '未知'
}[status];
}
export function relativeFreshness(value: string) {
const time = Date.parse(value);
if (!Number.isFinite(time)) return '时间未知';
const seconds = Math.max(0, Math.round((Date.now() - time) / 1000));
if (seconds < 60) return `${seconds} 秒前`;
if (seconds < 3600) return `${Math.floor(seconds / 60)} 分钟前`;
if (seconds < 86400) return `${Math.floor(seconds / 3600)} 小时前`;
return `${Math.floor(seconds / 86400)} 天前`;
}
export function formatNumber(value: number, maximumFractionDigits = 0) {
return new Intl.NumberFormat('zh-CN', { maximumFractionDigits }).format(value);
}

View File

@@ -0,0 +1,17 @@
import { describe, expect, it } from 'vitest';
import { parseVehicleProfileSyncCSV, vehicleProfileSyncCSVHeader } from './profileSync';
describe('parseVehicleProfileSyncCSV', () => {
it('parses quoted values, BOM, CRLF and normalizes VIN/status', () => {
const rows = parseVehicleProfileSyncCSV(`\uFEFF${vehicleProfileSyncCSVHeader}\r\nvin001,"车型,一",重卡,示范物流,ACTIVE,车厂平台,2026-07-01T08:30:00+08:00,3600\r\n`);
expect(rows).toEqual([expect.objectContaining({ vin: 'VIN001', modelName: '车型,一', operationStatus: 'active', runtimeSeconds: 3600 })]);
});
it('rejects duplicate VINs and malformed source rows before upload', () => {
const duplicate = `${vehicleProfileSyncCSVHeader}\nVIN001,,,,unknown,,,\nvin001,,,,unknown,,,`;
expect(() => parseVehicleProfileSyncCSV(duplicate)).toThrow(/VIN 重复/);
expect(() => parseVehicleProfileSyncCSV(`${vehicleProfileSyncCSVHeader}\nVIN001,,,,active,,,1.5`)).toThrow(/累计运行秒数/);
expect(() => parseVehicleProfileSyncCSV('vin,modelName\nVIN001,车型')).toThrow(/表头/);
expect(() => parseVehicleProfileSyncCSV(`${vehicleProfileSyncCSVHeader}\nVIN001,"车型"x,,,active,,,`)).toThrow(/引号结束/);
});
});

View File

@@ -0,0 +1,70 @@
import type { VehicleProfileSyncItem } from '../../api/types';
const headers = ['vin', 'modelName', 'vehicleType', 'companyName', 'operationStatus', 'accessProvider', 'firstAccessAt', 'runtimeSeconds'] as const;
const allowedStatuses = new Set(['', 'unknown', 'active', 'inactive', 'maintenance', 'retired']);
export const vehicleProfileSyncCSVHeader = headers.join(',');
export function parseVehicleProfileSyncCSV(text: string): VehicleProfileSyncItem[] {
const rows = parseCSVRows(text.replace(/^\uFEFF/, ''));
if (rows.length < 2) throw new Error('CSV 至少需要表头和一行车辆数据');
const actualHeaders = rows[0].map((value) => value.trim());
if (actualHeaders.length !== headers.length || actualHeaders.some((value, index) => value !== headers[index])) {
throw new Error(`CSV 表头必须为:${vehicleProfileSyncCSVHeader}`);
}
const dataRows = rows.slice(1).filter((row) => row.some((value) => value.trim() !== ''));
if (dataRows.length === 0 || dataRows.length > 500) throw new Error('单个 CSV 必须包含 1 至 500 辆车');
const seen = new Set<string>();
return dataRows.map((row, index) => {
const line = index + 2;
if (row.length !== headers.length) throw new Error(`CSV 第 ${line} 行列数不正确`);
const [rawVIN, modelName, vehicleType, companyName, rawStatus, accessProvider, firstAccessAt, rawRuntime] = row.map((value) => value.trim());
const vin = rawVIN.toUpperCase();
if (!vin || vin.length > 32) throw new Error(`CSV 第 ${line} 行 VIN 无效`);
if (seen.has(vin)) throw new Error(`CSV 第 ${line} 行 VIN 重复:${vin}`);
seen.add(vin);
const operationStatus = rawStatus.toLowerCase();
if (!allowedStatuses.has(operationStatus)) throw new Error(`CSV 第 ${line} 行运营状态无效`);
const runtimeSeconds = rawRuntime === '' ? null : Number(rawRuntime);
if (runtimeSeconds !== null && (!Number.isSafeInteger(runtimeSeconds) || runtimeSeconds < 0)) throw new Error(`CSV 第 ${line} 行累计运行秒数无效`);
return {
vin, modelName, vehicleType, companyName,
operationStatus: (operationStatus || 'unknown') as VehicleProfileSyncItem['operationStatus'],
accessProvider, firstAccessAt, runtimeSeconds
};
});
}
function parseCSVRows(text: string): string[][] {
const rows: string[][] = [];
let row: string[] = [];
let field = '';
let quoted = false;
let closedQuote = false;
for (let index = 0; index < text.length; index += 1) {
const char = text[index];
if (quoted) {
if (char === '"') {
if (text[index + 1] === '"') { field += '"'; index += 1; } else { quoted = false; closedQuote = true; }
} else {
field += char;
}
continue;
}
if (closedQuote && char !== ',' && char !== '\n' && char !== '\r') throw new Error('CSV 引号结束后存在无效字符');
if (char === '"') {
if (field !== '') throw new Error('CSV 引号格式无效');
quoted = true;
} else if (char === ',') {
row.push(field); field = ''; closedQuote = false;
} else if (char === '\n' || char === '\r') {
if (char === '\r' && text[index + 1] === '\n') index += 1;
row.push(field); rows.push(row); row = []; field = ''; closedQuote = false;
} else {
field += char;
}
}
if (quoted) throw new Error('CSV 存在未闭合的引号');
if (field !== '' || row.length > 0) { row.push(field); rows.push(row); }
return rows;
}

View File

@@ -0,0 +1,21 @@
import { describe, expect, it } from 'vitest';
import { formatTelemetryTime, formatTelemetryValue, telemetryQualityLabel } from './telemetry';
describe('latest telemetry presentation', () => {
it('formats server-authoritative scalar values without inferring metadata', () => {
expect(formatTelemetryValue(42.567)).toBe('42.57');
expect(formatTelemetryValue(true)).toBe('是');
expect(formatTelemetryValue(null)).toBe('—');
});
it('translates server quality states', () => {
expect(telemetryQualityLabel('good')).toBe('正常');
expect(telemetryQualityLabel('stale')).toBe('陈旧');
expect(telemetryQualityLabel('warning')).toBe('异常');
});
it('keeps telemetry timestamps compact for local and RFC3339 values', () => {
expect(formatTelemetryTime('2026-07-14T09:24:34+08:00')).toBe('09:24:34');
expect(formatTelemetryTime('2026-07-14 09:24:34')).toBe('09:24:34');
});
});

View File

@@ -0,0 +1,19 @@
export function formatTelemetryValue(value: unknown) {
if (typeof value === 'number' && Number.isFinite(value)) {
return new Intl.NumberFormat('zh-CN', { maximumFractionDigits: 2 }).format(value);
}
if (typeof value === 'boolean') return value ? '是' : '否';
if (typeof value === 'string') return value || '—';
return value == null ? '—' : String(value);
}
export function telemetryQualityLabel(quality: string) {
if (quality === 'good') return '正常';
if (quality === 'stale') return '陈旧';
return '异常';
}
export function formatTelemetryTime(value?: string) {
if (!value) return '—';
return value.match(/[T ](\d{2}:\d{2}:\d{2})/)?.[1] ?? value;
}

View File

@@ -0,0 +1,21 @@
import { describe, expect, it } from 'vitest';
import type { TrackPlaybackResponse } from '../../api/types';
import { formatDuration, sampledEventIndex, trackCsv } from './track';
describe('track domain', () => {
it('formats durations and maps original event indices to sampled points', () => {
expect(formatDuration(3671)).toBe('01:01:11');
expect(sampledEventIndex({ index: 50 } as never, 11, 101)).toBe(5);
expect(sampledEventIndex({ index: 50, sampledIndex: 7 } as never, 11, 101)).toBe(7);
});
it('exports the current result with UTF-8 BOM and escaped values', () => {
const track = {
plate: '粤A,001', vin: 'VIN', summary: { startTime: '2026-07-03 10:00:00' },
points: [{ vin: 'VIN', plate: '粤A,001', protocol: 'JT808', deviceTime: '2026-07-03 10:00:00', serverTime: '2026-07-03 10:00:01', longitude: 113.1, latitude: 23.1, speedKmh: 10, totalMileageKm: 100 }]
} as TrackPlaybackResponse;
const csv = trackCsv(track);
expect(csv.startsWith('\uFEFFVIN,')).toBe(true);
expect(csv).toContain('"粤A,001"');
});
});

View File

@@ -0,0 +1,42 @@
import type { HistoryLocationRow, TrackPlaybackEvent, TrackPlaybackResponse } from '../../api/types';
export function formatDuration(seconds: number) {
const safe = Math.max(0, Math.floor(seconds || 0));
const hours = Math.floor(safe / 3600);
const minutes = Math.floor((safe % 3600) / 60);
const remainder = safe % 60;
return [hours, minutes, remainder].map((value) => String(value).padStart(2, '0')).join(':');
}
export function sampledEventIndex(event: TrackPlaybackEvent, sampledCount: number, originalCount: number) {
if (sampledCount <= 1 || originalCount <= 1) return 0;
if (Number.isInteger(event.sampledIndex) && event.sampledIndex >= 0) {
return Math.min(sampledCount - 1, event.sampledIndex);
}
return Math.max(0, Math.min(sampledCount - 1, Math.round(event.index * (sampledCount - 1) / (originalCount - 1))));
}
function csvCell(value: unknown) {
const text = String(value ?? '');
return /[",\n]/.test(text) ? `"${text.replace(/"/g, '""')}"` : text;
}
export function trackCsv(track: TrackPlaybackResponse) {
const headers = ['VIN', '车牌', '协议', '设备时间', '服务时间', '经度', '纬度', '速度(km/h)', '总里程(km)'];
const rows = track.points.map((point) => [point.vin, point.plate, point.protocol, point.deviceTime, point.serverTime, point.longitude, point.latitude, point.speedKmh, point.totalMileageKm]);
return `\uFEFF${[headers, ...rows].map((row) => row.map(csvCell).join(',')).join('\n')}`;
}
export function downloadTrackCsv(track: TrackPlaybackResponse) {
const blob = new Blob([trackCsv(track)], { type: 'text/csv;charset=utf-8' });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = `track-${track.plate || track.vin}-${track.summary.startTime.slice(0, 10) || 'latest'}.csv`;
link.click();
URL.revokeObjectURL(url);
}
export function validTrackPoints(points: HistoryLocationRow[]) {
return points.filter((point) => Number.isFinite(point.longitude) && Number.isFinite(point.latitude) && point.longitude >= 73 && point.longitude <= 135 && point.latitude >= 18 && point.latitude <= 54);
}