feat: build vehicle data platform and production pipeline
This commit is contained in:
25
vehicle-data-platform/apps/web/src/v2/domain/access.test.ts
Normal file
25
vehicle-data-platform/apps/web/src/v2/domain/access.test.ts
Normal 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('"示范企业"');
|
||||
});
|
||||
});
|
||||
45
vehicle-data-platform/apps/web/src/v2/domain/access.ts
Normal file
45
vehicle-data-platform/apps/web/src/v2/domain/access.ts
Normal 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')}`;
|
||||
}
|
||||
26
vehicle-data-platform/apps/web/src/v2/domain/alert.test.ts
Normal file
26
vehicle-data-platform/apps/web/src/v2/domain/alert.test.ts
Normal 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('区间内 20–80 %,持续 30 秒');
|
||||
expect(ruleCondition({ valueType: 'boolean', metric: 'alarm_active', operator: 'changed', durationSec: 0 } as never)).toBe('协议告警位 状态变化');
|
||||
});
|
||||
});
|
||||
36
vehicle-data-platform/apps/web/src/v2/domain/alert.ts
Normal file
36
vehicle-data-platform/apps/web/src/v2/domain/alert.ts
Normal 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';
|
||||
}
|
||||
44
vehicle-data-platform/apps/web/src/v2/domain/history.test.ts
Normal file
44
vehicle-data-platform/apps/web/src/v2/domain/history.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
87
vehicle-data-platform/apps/web/src/v2/domain/history.ts
Normal file
87
vehicle-data-platform/apps/web/src/v2/domain/history.ts
Normal 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`;
|
||||
}
|
||||
40
vehicle-data-platform/apps/web/src/v2/domain/monitor.test.ts
Normal file
40
vehicle-data-platform/apps/web/src/v2/domain/monitor.test.ts
Normal 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('行驶');
|
||||
});
|
||||
});
|
||||
35
vehicle-data-platform/apps/web/src/v2/domain/monitor.ts
Normal file
35
vehicle-data-platform/apps/web/src/v2/domain/monitor.ts
Normal 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);
|
||||
}
|
||||
@@ -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(/引号结束/);
|
||||
});
|
||||
});
|
||||
70
vehicle-data-platform/apps/web/src/v2/domain/profileSync.ts
Normal file
70
vehicle-data-platform/apps/web/src/v2/domain/profileSync.ts
Normal 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;
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
19
vehicle-data-platform/apps/web/src/v2/domain/telemetry.ts
Normal file
19
vehicle-data-platform/apps/web/src/v2/domain/telemetry.ts
Normal 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;
|
||||
}
|
||||
21
vehicle-data-platform/apps/web/src/v2/domain/track.test.ts
Normal file
21
vehicle-data-platform/apps/web/src/v2/domain/track.test.ts
Normal 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"');
|
||||
});
|
||||
});
|
||||
42
vehicle-data-platform/apps/web/src/v2/domain/track.ts
Normal file
42
vehicle-data-platform/apps/web/src/v2/domain/track.ts
Normal 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);
|
||||
}
|
||||
Reference in New Issue
Block a user