diff --git a/vehicle-data-platform/apps/web/src/pages/Mileage.tsx b/vehicle-data-platform/apps/web/src/pages/Mileage.tsx index be465cc8..08e4ccf0 100644 --- a/vehicle-data-platform/apps/web/src/pages/Mileage.tsx +++ b/vehicle-data-platform/apps/web/src/pages/Mileage.tsx @@ -616,15 +616,70 @@ export function Mileage({ filters.dateFrom?.trim() ? `开始日期:${filters.dateFrom.trim()}` : '', filters.dateTo?.trim() ? `结束日期:${filters.dateTo.trim()}` : '' ].filter(Boolean); + const mileageRangeText = filters.dateFrom?.trim() || filters.dateTo?.trim() + ? `${filters.dateFrom?.trim() || '-'} 至 ${filters.dateTo?.trim() || '-'}` + : '全部日期'; + const mileageCustomerKpis = [ + { + label: '区间总里程', + value: `${formatKm(summary.totalMileageKm)} km`, + color: 'blue' as const, + detail: `范围:${mileageRangeText}` + }, + { + label: '统计车辆', + value: `${summary.vehicleCount.toLocaleString()} 辆`, + color: summary.vehicleCount > 0 ? 'green' as const : 'grey' as const, + detail: `${summary.recordCount.toLocaleString()} 条日里程记录` + }, + { + label: '异常记录', + value: anomalyRows.length.toLocaleString(), + color: anomalyRows.length > 0 ? 'orange' as const : 'green' as const, + detail: anomalyRows.length > 0 ? '建议先核对轨迹和原始记录' : '当前页未发现异常' + }, + { + label: '在线影响', + value: formatPercent(onlineRatePercent), + color: onlineRatePercent >= 90 ? 'green' as const : onlineRatePercent >= 60 ? 'orange' as const : 'red' as const, + detail: `${onlineVehicleCount.toLocaleString()} 在线 / ${offlineVehicleCount.toLocaleString()} 离线` + } + ]; + const mileageCustomerActions = [ + { + label: '导出当前明细', + disabled: rows.length === 0, + action: () => exportMileage() + }, + { + label: '查看轨迹', + disabled: !currentVehicleKeyword || !onOpenHistory, + action: () => onOpenHistory?.({ keyword: currentVehicleKeyword, protocol: currentProtocol, dateFrom: filters.dateFrom ?? '', dateTo: filters.dateTo ?? '' }) + }, + { + label: '查看原始记录', + disabled: !currentVehicleKeyword || !onOpenRaw, + action: () => onOpenRaw?.({ keyword: currentVehicleKeyword, protocol: currentProtocol, dateFrom: filters.dateFrom ?? '', dateTo: filters.dateTo ?? '', includeFields: 'true' }) + }, + { + label: '车辆档案', + disabled: !currentVehicleKeyword, + action: () => onOpenVehicle(currentVehicleKeyword, currentProtocol) + } + ]; const loadSummary = (values: Record = filters) => { setSummaryLoading(true); api.mileageSummary(mileageParams(values)) - .then(setSummary) + .then((nextSummary) => setSummary(nextSummary ?? emptySummary)) .catch((error: Error) => Toast.error(error.message)) .finally(() => setSummaryLoading(false)); api.onlineStatisticsSummary(mileageParams(values)) - .then(setOnlineSummary) + .then((nextSummary) => setOnlineSummary({ + ...emptyOnlineSummary, + ...(nextSummary ?? {}), + protocolStats: Array.isArray(nextSummary?.protocolStats) ? nextSummary.protocolStats : [] + })) .catch((error: Error) => Toast.error(error.message)); }; @@ -922,6 +977,96 @@ export function Mileage({ ) : null} + +
+
+
+
+ 车辆里程核对 + + 面向车辆运营查看区间里程、异常记录、在线影响和复核动作。 + +
+ + {closureStatus} + {publishAuditConclusion} + +
+
+ {mileageCustomerKpis.map((item) => ( + + ))} +
+
+
+
日里程趋势
+
+ {dateSeries.length > 0 ? dateSeries.slice(0, 10).map((item) => ( +
+ {item.date} +
0 ? Math.max(4, (item.value / maxDateMileage) * 100) : 0}%` }} />
+ {formatKm(item.value)} km +
+ )) : 暂无日里程趋势。} +
+
+
+
来源贡献
+
+ {sourceSeries.length > 0 ? sourceSeries.slice(0, 5).map((item) => ( +
+ {item.source} +
0 ? Math.max(4, (item.value / maxSourceMileage) * 100) : 0}%` }} />
+ {formatKm(item.value)} km +
+ )) : 暂无来源贡献。} +
+
+
+
+ +
+
{[ { label: '车辆数', value: summary.vehicleCount.toLocaleString() }, diff --git a/vehicle-data-platform/apps/web/src/styles/global.css b/vehicle-data-platform/apps/web/src/styles/global.css index fc972dfb..83266c1a 100644 --- a/vehicle-data-platform/apps/web/src/styles/global.css +++ b/vehicle-data-platform/apps/web/src/styles/global.css @@ -1496,6 +1496,160 @@ button.vp-realtime-command-item:focus-visible { margin-top: 16px; } +.vp-mileage-console { + display: grid; + grid-template-columns: minmax(0, 1fr) 300px; + gap: 16px; +} + +.vp-mileage-console-main { + min-width: 0; + display: grid; + gap: 14px; +} + +.vp-mileage-console-head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; +} + +.vp-mileage-kpi-grid { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 12px; +} + +.vp-mileage-kpi-item { + min-height: 112px; + padding: 14px; + border: 1px solid var(--vp-border); + border-radius: var(--vp-radius); + background: #fbfcff; + color: inherit; + text-align: left; + font: inherit; + cursor: pointer; + display: grid; + align-content: space-between; + gap: 8px; +} + +.vp-mileage-kpi-item:hover, +.vp-mileage-kpi-item:focus-visible { + border-color: rgba(22, 100, 255, 0.42); + background: #f5f9ff; + outline: none; +} + +.vp-mileage-kpi-item strong { + color: var(--vp-text); + font-size: 24px; + line-height: 30px; + font-weight: 700; + word-break: break-word; +} + +.vp-mileage-kpi-item span { + color: var(--vp-text-muted); + font-size: 12px; + line-height: 18px; +} + +.vp-mileage-mini-charts { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 12px; +} + +.vp-mileage-mini-title { + margin-bottom: 8px; + color: var(--vp-text); + font-size: 13px; + line-height: 20px; + font-weight: 700; +} + +.vp-mileage-mini-chart { + min-height: 170px; + padding: 12px; + border: 1px solid var(--vp-border); + border-radius: var(--vp-radius); + background: #fbfcff; + display: grid; + gap: 8px; + align-content: start; +} + +.vp-mileage-mini-row { + display: grid; + grid-template-columns: 92px minmax(0, 1fr) 86px; + gap: 8px; + align-items: center; + color: var(--vp-text-muted); + font-size: 12px; + line-height: 18px; +} + +.vp-mileage-mini-row span, +.vp-mileage-mini-row strong { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.vp-mileage-mini-row strong { + color: var(--vp-text); + font-weight: 600; + text-align: right; +} + +.vp-mileage-mini-row div { + height: 8px; + border-radius: 999px; + background: rgba(22, 100, 255, 0.08); + overflow: hidden; +} + +.vp-mileage-mini-row i { + display: block; + height: 100%; + border-radius: inherit; + background: var(--vp-primary); +} + +.vp-mileage-mini-row-green { + background: var(--vp-success) !important; +} + +.vp-mileage-console-side { + min-width: 0; + display: grid; + gap: 12px; + align-content: start; +} + +.vp-mileage-side-card { + padding: 12px; + border: 1px solid var(--vp-border); + border-radius: var(--vp-radius); + background: #fbfcff; + display: grid; + gap: 10px; +} + +.vp-mileage-side-card .semi-button { + justify-self: start; +} + +.vp-mileage-side-title { + color: var(--vp-text); + font-size: 13px; + line-height: 20px; + font-weight: 700; +} + .vp-online-ops-board { display: grid; grid-template-columns: minmax(260px, 0.72fr) minmax(0, 1.28fr); @@ -2810,12 +2964,16 @@ button.vp-realtime-command-item:focus-visible { .vp-source-fusion-grid, .vp-source-fusion-facts, .vp-vehicle-map-layout, + .vp-mileage-console, + .vp-mileage-kpi-grid, + .vp-mileage-mini-charts, .vp-playback-layout, .vp-playback-timeline { grid-template-columns: 1fr; } .vp-map-commandbar, + .vp-mileage-console-head, .vp-map-canvas-toolbar { align-items: flex-start; flex-direction: column; 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 9750adce..d736bc7c 100644 --- a/vehicle-data-platform/apps/web/src/test/App.test.tsx +++ b/vehicle-data-platform/apps/web/src/test/App.test.tsx @@ -7571,7 +7571,7 @@ test('shows mileage anomaly action guidance', async () => { expect(await screen.findByText('VIN-MILEAGE-ANOMALY')).toBeInTheDocument(); expect(screen.getByText('核对里程来源')).toBeInTheDocument(); expect(screen.getByText('日里程异常,优先核对当天首末总里程、来源断链和补传数据。')).toBeInTheDocument(); - fireEvent.click(screen.getByRole('button', { name: '复制证据' })); + fireEvent.click(screen.getAllByRole('button', { name: '复制证据' })[0]); expect(writeText).toHaveBeenCalledWith(expect.stringContaining('【里程异常证据包】')); expect(writeText).toHaveBeenCalledWith(expect.stringContaining('车辆:粤A异常1 / VIN-MILEAGE-ANOMALY')); @@ -7642,7 +7642,7 @@ test('shows mileage statistics workspace with trend source and definition', asyn render(); expect(await screen.findByText('区间趋势')).toBeInTheDocument(); - expect(screen.getByText('来源贡献')).toBeInTheDocument(); + expect(screen.getAllByText('来源贡献').length).toBeGreaterThanOrEqual(1); 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); @@ -7668,7 +7668,7 @@ test('shows mileage statistics workspace with trend source and definition', asyn expect(screen.getAllByText('异常记录').length).toBeGreaterThanOrEqual(1); expect(screen.getAllByText('1').length).toBeGreaterThanOrEqual(1); expect(screen.getByText('最大单日')).toBeInTheDocument(); - expect(screen.getByText('60 km')).toBeInTheDocument(); + expect(screen.getAllByText('60 km').length).toBeGreaterThanOrEqual(1); expect(screen.getByText('统计可信度')).toBeInTheDocument(); expect(screen.getByText('异常率')).toBeInTheDocument(); expect(screen.getByText('25%')).toBeInTheDocument(); @@ -7678,7 +7678,7 @@ test('shows mileage statistics workspace with trend source and definition', asyn expect(screen.getAllByText('100%').length).toBeGreaterThanOrEqual(1); expect(screen.getByText('闭合校验')).toBeInTheDocument(); expect(screen.getAllByText('0 km').length).toBeGreaterThanOrEqual(1); - expect(screen.getByText('统计口径')).toBeInTheDocument(); + expect(screen.getAllByText('统计口径').length).toBeGreaterThanOrEqual(1); expect(screen.getByText((content) => content.includes('区间总值应等于各日里程之和'))).toBeInTheDocument(); }); @@ -7820,7 +7820,7 @@ test('shows vehicle-first statistics domains for one vehicle service', async () expect(screen.getByText('当前页离线')).toBeInTheDocument(); expect(screen.getByText('最长离线')).toBeInTheDocument(); expect(screen.getByText('统计证据')).toBeInTheDocument(); - expect(await screen.findByText('75%')).toBeInTheDocument(); + expect((await screen.findAllByText('75%')).length).toBeGreaterThanOrEqual(1); expect(screen.getAllByText((content) => content.includes('离线 1 车')).length).toBeGreaterThanOrEqual(1); expect(screen.getAllByText((content) => content.includes('Redis 在线 92 key')).length).toBeGreaterThanOrEqual(1); expect(screen.getAllByText((content) => content.includes('由车辆服务覆盖汇总、实时来源状态和 Redis 在线 key 计算')).length).toBeGreaterThanOrEqual(1);