From 312382c95d4274e64c4b098bb5eabdf22ce18925 Mon Sep 17 00:00:00 2001 From: lingniu Date: Sat, 4 Jul 2026 10:56:10 +0800 Subject: [PATCH] feat(platform): enhance mileage statistics workspace --- .../apps/web/src/pages/Mileage.tsx | 100 ++++++++++++++++++ .../apps/web/src/styles/global.css | 82 ++++++++++++++ .../apps/web/src/test/App.test.tsx | 72 +++++++++++++ 3 files changed, 254 insertions(+) diff --git a/vehicle-data-platform/apps/web/src/pages/Mileage.tsx b/vehicle-data-platform/apps/web/src/pages/Mileage.tsx index 08f8180b..3846e3c7 100644 --- a/vehicle-data-platform/apps/web/src/pages/Mileage.tsx +++ b/vehicle-data-platform/apps/web/src/pages/Mileage.tsx @@ -27,6 +27,10 @@ function formatKm(value: number) { return Number.isFinite(value) ? value.toLocaleString(undefined, { maximumFractionDigits: 1 }) : '0'; } +function formatPercent(value: number) { + return Number.isFinite(value) ? `${value.toLocaleString(undefined, { maximumFractionDigits: 1 })}%` : '0%'; +} + function canOpenVehicle(vin?: string) { const value = vin?.trim(); return Boolean(value && value !== 'unknown'); @@ -55,6 +59,33 @@ function mergeInitialFilters(initialVin: string, initialProtocol?: string, initi }; } +function groupMileageByDate(rows: DailyMileageRow[]) { + const bucket = new Map(); + rows.forEach((row) => { + bucket.set(row.date || '-', (bucket.get(row.date || '-') ?? 0) + row.dailyMileageKm); + }); + return [...bucket.entries()] + .map(([date, value]) => ({ date, value })) + .sort((a, b) => a.date.localeCompare(b.date)); +} + +function groupMileageBySource(rows: DailyMileageRow[]) { + const bucket = new Map(); + rows.forEach((row) => { + bucket.set(row.source || 'UNKNOWN', (bucket.get(row.source || 'UNKNOWN') ?? 0) + row.dailyMileageKm); + }); + return [...bucket.entries()] + .map(([source, value]) => ({ source, value })) + .sort((a, b) => b.value - a.value); +} + +function maxMileageRow(rows: DailyMileageRow[]) { + return rows.reduce((max, row) => { + if (!max || row.dailyMileageKm > max.dailyMileageKm) return row; + return max; + }, undefined); +} + export function Mileage({ initialVin, initialProtocol, @@ -76,6 +107,13 @@ export function Mileage({ const [pagination, setPagination] = useState({ currentPage: 1, pageSize: 20, total: 0 }); const currentVehicleKeyword = filters.keyword?.trim() ?? ''; const currentProtocol = filters.protocol?.trim() ?? ''; + const dateSeries = groupMileageByDate(rows); + const sourceSeries = groupMileageBySource(rows); + const anomalyRows = rows.filter((row) => row.anomalySeverity); + const peakMileage = maxMileageRow(rows); + const pageMileageTotal = rows.reduce((total, row) => total + row.dailyMileageKm, 0); + const maxDateMileage = Math.max(...dateSeries.map((item) => item.value), 0); + const maxSourceMileage = Math.max(...sourceSeries.map((item) => item.value), 0); const filterSummary = [ currentVehicleKeyword ? `车辆:${currentVehicleKeyword}` : '', currentProtocol ? `数据来源:${currentProtocol}` : '', @@ -181,6 +219,68 @@ export function Mileage({ ))} +
+ +
+ {dateSeries.length > 0 ? dateSeries.map((item) => ( +
+ {item.date} +
+ 0 ? Math.max(4, (item.value / maxDateMileage) * 100) : 0}%` }} /> +
+ {formatKm(item.value)} km +
+ )) : ( + 当前筛选范围暂无可展示趋势。 + )} +
+
+ +
+ {sourceSeries.length > 0 ? sourceSeries.map((item) => ( +
+ {item.source} +
+ 0 ? Math.max(4, (item.value / maxSourceMileage) * 100) : 0}%` }} /> +
+ {formatKm(item.value)} km +
+ )) : ( + 当前筛选范围暂无来源贡献。 + )} +
+
+
+
+ {[ + { label: '当前页里程', value: `${formatKm(pageMileageTotal)} km`, color: 'blue' as const, detail: '用于快速核对当前分页明细合计。' }, + { label: '异常记录', value: anomalyRows.length.toLocaleString(), color: anomalyRows.length > 0 ? 'orange' as const : 'green' as const, detail: '来自异常标记,优先核对首末总里程和断链。' }, + { label: '最大单日', value: peakMileage ? `${formatKm(peakMileage.dailyMileageKm)} km` : '-', color: 'blue' as const, detail: peakMileage ? `${peakMileage.plate || peakMileage.vin} / ${peakMileage.date}` : '暂无明细。' }, + { label: '统计覆盖率', value: summary.recordCount > 0 ? formatPercent((rows.length / summary.recordCount) * 100) : '0%', color: 'grey' as const, detail: '当前分页记录数 / 全量统计记录数。' } + ].map((item) => ( + + {item.label} +
{item.value}
+ {item.detail} +
+ ))} +
+ +
+
+ 日里程 +

按车辆、来源、日期聚合,以总里程差值作为日统计结果,避免中间断链导致分段累计不闭合。

+
+
+ 区间里程 +

按当前筛选范围汇总日里程,区间总值应等于各日里程之和,用于 BI 和运营报表口径。

+
+
+ 异常复核 +

异常优先检查补传、来源切换、总里程回退和跨协议差异,必要时回到车辆服务查看 RAW 证据。

+
+
+
div { + min-height: 104px; + padding: 12px; + border: 1px solid var(--vp-border); + border-radius: var(--vp-radius); + background: #fbfcff; +} + +.vp-stat-definition-grid p { + margin: 10px 0 0; + color: var(--vp-text-muted); + font-size: 13px; + line-height: 20px; +} + .vp-playback-layout { display: grid; grid-template-columns: minmax(0, 1fr) 260px; @@ -603,6 +682,9 @@ body { .vp-monitor-layout, .vp-alert-flow, .vp-alert-ops-grid, + .vp-stat-workspace, + .vp-stat-insight-grid, + .vp-stat-definition-grid, .vp-playback-layout, .vp-playback-timeline { grid-template-columns: 1fr; diff --git a/vehicle-data-platform/apps/web/src/test/App.test.tsx b/vehicle-data-platform/apps/web/src/test/App.test.tsx index 57620e5d..b5511bb6 100644 --- a/vehicle-data-platform/apps/web/src/test/App.test.tsx +++ b/vehicle-data-platform/apps/web/src/test/App.test.tsx @@ -3440,6 +3440,78 @@ test('shows mileage anomaly action guidance', async () => { expect(screen.getByText('日里程异常,优先核对当天首末总里程、来源断链和补传数据。')).toBeInTheDocument(); }); +test('shows mileage statistics workspace with trend source and definition', async () => { + window.history.replaceState(null, '', '/#/mileage?keyword=VIN-MILEAGE-STATS'); + vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => { + const path = String(input); + if (path.includes('/api/ops/health')) { + return { + ok: true, + json: async () => ({ + data: { linkHealth: [], kafkaLag: 0, redisOnlineKeys: 0, tdengineWritable: true, mysqlWritable: true, runtime: { requestTimeoutMs: 5000 } }, + traceId: 'trace-test', + timestamp: 1783094400000 + }) + } as Response; + } + if (path.includes('/api/mileage/summary')) { + return { + ok: true, + json: async () => ({ + data: { vehicleCount: 2, recordCount: 4, sourceCount: 2, totalMileageKm: 140, averageMileagePerVin: 70 }, + traceId: 'trace-test', + timestamp: 1783094400000 + }) + } as Response; + } + if (path.includes('/api/mileage/daily')) { + return { + ok: true, + json: async () => ({ + data: { + items: [ + { vin: 'VIN-MILEAGE-STATS', plate: '粤A统计1', source: 'JT808', date: '2026-07-01', startMileageKm: 100, endMileageKm: 140, dailyMileageKm: 40, anomalySeverity: '' }, + { vin: 'VIN-MILEAGE-STATS', plate: '粤A统计1', source: 'GB32960', date: '2026-07-01', startMileageKm: 200, endMileageKm: 230, dailyMileageKm: 30, anomalySeverity: '' }, + { vin: 'VIN-MILEAGE-STATS-2', plate: '粤A统计2', source: 'JT808', date: '2026-07-02', startMileageKm: 300, endMileageKm: 360, dailyMileageKm: 60, anomalySeverity: 'warning' }, + { vin: 'VIN-MILEAGE-STATS-2', plate: '粤A统计2', source: 'GB32960', date: '2026-07-02', startMileageKm: 400, endMileageKm: 410, dailyMileageKm: 10, anomalySeverity: '' } + ], + total: 4, + limit: 20, + offset: 0 + }, + traceId: 'trace-test', + timestamp: 1783094400000 + }) + } as Response; + } + return { + ok: true, + json: async () => ({ + data: { items: [], total: 0, limit: 20, offset: 0 }, + traceId: 'trace-test', + timestamp: 1783094400000 + }) + } as Response; + }); + + render(); + + expect(await screen.findByText('区间趋势')).toBeInTheDocument(); + expect(screen.getByText('来源贡献')).toBeInTheDocument(); + expect(screen.getAllByText('2026-07-01').length).toBeGreaterThanOrEqual(1); + expect(screen.getAllByText('2026-07-02').length).toBeGreaterThanOrEqual(1); + expect(screen.getAllByText('JT808').length).toBeGreaterThanOrEqual(1); + expect(screen.getAllByText('GB32960').length).toBeGreaterThanOrEqual(1); + expect(screen.getByText('当前页里程')).toBeInTheDocument(); + expect(screen.getByText('140 km')).toBeInTheDocument(); + expect(screen.getByText('异常记录')).toBeInTheDocument(); + expect(screen.getAllByText('1').length).toBeGreaterThanOrEqual(1); + expect(screen.getByText('最大单日')).toBeInTheDocument(); + expect(screen.getByText('60 km')).toBeInTheDocument(); + expect(screen.getByText('统计口径')).toBeInTheDocument(); + expect(screen.getByText((content) => content.includes('区间总值应等于各日里程之和'))).toBeInTheDocument(); +}); + test('opens vehicle service from history results with row source evidence', async () => { window.history.replaceState(null, '', '/#/history?keyword=%E7%B2%A4AG18312&protocol=JT808'); vi.spyOn(globalThis, 'fetch').mockImplementation(async (input, init) => {