From 6803d51b78eec69a58a5f403971f86518b44b63a Mon Sep 17 00:00:00 2001 From: lingniu Date: Sat, 4 Jul 2026 22:35:05 +0800 Subject: [PATCH] feat(platform): add mileage impact scope board --- .../apps/web/src/pages/Mileage.tsx | 131 ++++++++++++++++++ .../apps/web/src/styles/global.css | 44 ++++++ .../apps/web/src/test/App.test.tsx | 24 +++- 3 files changed, 197 insertions(+), 2 deletions(-) diff --git a/vehicle-data-platform/apps/web/src/pages/Mileage.tsx b/vehicle-data-platform/apps/web/src/pages/Mileage.tsx index 39fba41e..755244df 100644 --- a/vehicle-data-platform/apps/web/src/pages/Mileage.tsx +++ b/vehicle-data-platform/apps/web/src/pages/Mileage.tsx @@ -228,6 +228,54 @@ function mileageBIPublishText({ ].join('\n'); } +function statisticsImpactText({ + filters, + summary, + onlineSummary, + closureStatus, + confidenceStatus, + anomalyCount, + pageCoverageRate, + publishAuditConclusion, + statisticsURL, + vehicleURL +}: { + filters: Record; + summary: MileageSummary; + onlineSummary: OnlineStatisticsSummary; + closureStatus: string; + confidenceStatus: string; + anomalyCount: number; + pageCoverageRate: number; + publishAuditConclusion: string; + statisticsURL: string; + vehicleURL?: string; +}) { + const keyword = filters.keyword?.trim() || '全部车辆'; + const protocol = filters.protocol?.trim() || '全部来源'; + const dateFrom = filters.dateFrom?.trim(); + const dateTo = filters.dateTo?.trim(); + const dateRange = dateFrom || dateTo ? `${dateFrom || '-'} 至 ${dateTo || '-'}` : '全部日期'; + return [ + '【统计影响范围】', + `车辆范围:${keyword}`, + `数据来源:${protocol}`, + `日期范围:${dateRange}`, + `影响车辆:${summary.vehicleCount.toLocaleString()} 辆`, + `统计记录:${summary.recordCount.toLocaleString()} 条`, + `累计里程:${formatKm(summary.totalMileageKm)} km`, + `在线状态:${onlineSummary.onlineVehicleCount.toLocaleString()} 在线 / ${onlineSummary.offlineVehicleCount.toLocaleString()} 离线 / 在线率 ${formatPercent(onlineSummary.onlineRatePercent)}`, + `闭合状态:${closureStatus}`, + `发布判断:${publishAuditConclusion}`, + `可信度:${confidenceStatus}`, + `异常记录:${anomalyCount.toLocaleString()}`, + `当前页覆盖:${formatPercent(pageCoverageRate)}`, + `业务影响:${publishAuditConclusion === '可发布' ? '当前统计范围可进入运营和BI口径' : '当前统计范围需要先复核轨迹、RAW和离线车辆影响'}`, + `统计查询:${statisticsURL}`, + ...(vehicleURL ? [`车辆服务:${vehicleURL}`] : []) + ].join('\n'); +} + type MileagePublishAuditItem = { key: string; label: string; @@ -472,6 +520,40 @@ export function Mileage({ const publishReviewCount = publishAuditItems.filter((item) => item.status === 'review').length; const publishAuditConclusion = publishBlockedCount > 0 ? '阻断发布' : publishReviewCount > 0 ? '复核后发布' : '可发布'; const publishAuditColor = publishBlockedCount > 0 ? 'red' as const : publishReviewCount > 0 ? 'orange' as const : 'green' as const; + const statisticsImpactColor = publishAuditColor; + const statisticsImpactLevel = publishAuditConclusion === '可发布' ? '可运营发布' : publishAuditConclusion === '阻断发布' ? '发布阻断' : '影响待复核'; + const statisticsImpactItems = [ + { + label: '影响车辆', + value: `${summary.vehicleCount.toLocaleString()} 辆`, + detail: `${onlineVehicleCount.toLocaleString()} 在线 / ${offlineVehicleCount.toLocaleString()} 离线`, + color: offlineVehicleCount > 0 ? 'orange' as const : 'green' as const + }, + { + label: '统计规模', + value: `${summary.recordCount.toLocaleString()} 条`, + detail: `${summary.sourceCount.toLocaleString()} 个来源,累计 ${formatKm(summary.totalMileageKm)} km`, + color: summary.recordCount > 0 ? 'blue' as const : 'grey' as const + }, + { + label: '闭合影响', + value: closureStatus, + detail: closureActionText, + color: closureStatusColor + }, + { + label: '异常影响', + value: `${anomalyRows.length.toLocaleString()} 条`, + detail: anomalyRows.length > 0 ? '异常会影响BI与运营日报,需要回到证据复核。' : '当前明细未命中异常标记。', + color: anomalyRows.length > 0 ? 'orange' as const : 'green' as const + }, + { + label: '发布判断', + value: publishAuditConclusion, + detail: publishAuditConclusion === '可发布' ? '统计范围闭合且审计项通过。' : '发布前需要补齐审计项证据。', + color: publishAuditColor + } + ]; const maxDateMileage = Math.max(...dateSeries.map((item) => item.value), 0); const maxSourceMileage = Math.max(...sourceSeries.map((item) => item.value), 0); const filterSummary = [ @@ -610,6 +692,26 @@ export function Mileage({ '统计发布审计' ); }; + const copyStatisticsImpact = () => { + const vehicleURL = currentVehicleKeyword + ? appURL(buildAppHash({ page: 'detail', keyword: currentVehicleKeyword, protocol: currentProtocol })) + : undefined; + copyText( + statisticsImpactText({ + filters, + summary, + onlineSummary, + closureStatus, + confidenceStatus, + anomalyCount: anomalyRows.length, + pageCoverageRate, + publishAuditConclusion, + statisticsURL: appURL(window.location.hash || buildAppHash({ page: 'mileage', keyword: currentVehicleKeyword, protocol: currentProtocol })), + vehicleURL + }), + '统计影响范围' + ); + }; const copyOnlineStatusHandoff = () => { copyText( onlineStatusHandoffText({ @@ -689,6 +791,35 @@ export function Mileage({ ))} + 统计影响范围} + style={{ marginTop: 16 }} + > +
+
+ {statisticsImpactLevel} +
{summary.vehicleCount.toLocaleString()} 辆车
+ + 当前筛选会影响 {summary.recordCount.toLocaleString()} 条里程记录、{formatKm(summary.totalMileageKm)} km 统计口径和 {offlineVehicleCount.toLocaleString()} 辆离线车辆判断。 + + + {confidenceStatus} + {closureStatus} + {publishAuditConclusion} + +
+
+ {statisticsImpactItems.map((item) => ( +
+ {item.label} + {item.value} + {item.detail} +
+ ))} +
+
+
{ const nextFilters = values as Record; diff --git a/vehicle-data-platform/apps/web/src/styles/global.css b/vehicle-data-platform/apps/web/src/styles/global.css index 91b12648..ccdb976f 100644 --- a/vehicle-data-platform/apps/web/src/styles/global.css +++ b/vehicle-data-platform/apps/web/src/styles/global.css @@ -1070,6 +1070,48 @@ button.vp-realtime-command-item:focus-visible { gap: 12px; } +.vp-stat-impact-board { + display: grid; + grid-template-columns: minmax(260px, 0.72fr) minmax(0, 1.28fr); + gap: 16px; +} + +.vp-stat-impact-summary { + min-height: 156px; + padding: 12px; + border: 1px solid rgba(22, 100, 255, 0.28); + border-radius: var(--vp-radius); + background: #f5f9ff; + display: grid; + gap: 10px; + align-content: start; +} + +.vp-stat-impact-grid { + display: grid; + grid-template-columns: repeat(5, minmax(0, 1fr)); + gap: 12px; +} + +.vp-stat-impact-item { + min-height: 156px; + padding: 12px; + border: 1px solid var(--vp-border); + border-radius: var(--vp-radius); + background: #fbfcff; + display: grid; + gap: 10px; + align-content: start; +} + +.vp-stat-impact-item strong { + color: var(--vp-text); + font-size: 20px; + line-height: 26px; + font-weight: 700; + word-break: break-word; +} + .vp-stat-closure-item { min-height: 112px; padding: 12px; @@ -2038,6 +2080,8 @@ button.vp-realtime-command-item:focus-visible { .vp-stat-workspace, .vp-stat-insight-grid, .vp-stat-definition-grid, + .vp-stat-impact-board, + .vp-stat-impact-grid, .vp-stat-closure-board, .vp-bi-publish-board, .vp-bi-publish-grid, 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 460e3489..eae70440 100644 --- a/vehicle-data-platform/apps/web/src/test/App.test.tsx +++ b/vehicle-data-platform/apps/web/src/test/App.test.tsx @@ -7250,12 +7250,12 @@ test('shows mileage statistics workspace with trend source and definition', asyn expect(screen.getByText('闭合差值')).toBeInTheDocument(); expect(screen.getByText('记录覆盖率')).toBeInTheDocument(); expect(screen.getAllByText('闭合通过').length).toBeGreaterThanOrEqual(1); - expect(screen.getByText('当前筛选范围汇总总里程与明细合计一致,可作为统计证据。')).toBeInTheDocument(); + expect(screen.getAllByText('当前筛选范围汇总总里程与明细合计一致,可作为统计证据。').length).toBeGreaterThanOrEqual(1); expect(screen.getByRole('button', { name: '复制闭合摘要' })).toBeInTheDocument(); expect(screen.getByRole('button', { name: '闭合复核轨迹' })).toBeInTheDocument(); expect(screen.getByRole('button', { name: '闭合复核 RAW' })).toBeInTheDocument(); expect(screen.getByText('统计发布审计')).toBeInTheDocument(); - expect(screen.getByText('复核后发布')).toBeInTheDocument(); + expect(screen.getAllByText('复核后发布').length).toBeGreaterThanOrEqual(1); expect(screen.getByText('区间闭合')).toBeInTheDocument(); expect(screen.getAllByText('记录覆盖').length).toBeGreaterThanOrEqual(1); expect(screen.getByText('在线证据')).toBeInTheDocument(); @@ -7398,10 +7398,30 @@ test('shows vehicle-first statistics domains for one vehicle service', async () expect(screen.getByText('三类数据源最终汇总为一个车辆服务统计视图')).toBeInTheDocument(); expect(screen.getByText('当前统计证据')).toBeInTheDocument(); expect(screen.getByText('3 车 / 2 来源 / 3 条明细')).toBeInTheDocument(); + expect(screen.getByText('统计影响范围')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: '复制影响范围' })).toBeInTheDocument(); + expect(screen.getByText('影响待复核')).toBeInTheDocument(); + expect(screen.getByText('3 辆车')).toBeInTheDocument(); + expect(screen.getByText('当前筛选会影响 6 条里程记录、210 km 统计口径和 1 辆离线车辆判断。')).toBeInTheDocument(); + expect(screen.getByText('影响车辆')).toBeInTheDocument(); + expect(screen.getByText('统计规模')).toBeInTheDocument(); + expect(screen.getByText('闭合影响')).toBeInTheDocument(); + expect(screen.getByText('异常影响')).toBeInTheDocument(); + expect(screen.getAllByText('发布判断').length).toBeGreaterThanOrEqual(1); expect(await screen.findByText('75%')).toBeInTheDocument(); 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); + fireEvent.click(screen.getByRole('button', { name: '复制影响范围' })); + expect(writeText).toHaveBeenCalledWith(expect.stringContaining('【统计影响范围】')); + expect(writeText).toHaveBeenCalledWith(expect.stringContaining('车辆范围:VIN-STATS-SERVICE')); + expect(writeText).toHaveBeenCalledWith(expect.stringContaining('数据来源:JT808')); + expect(writeText).toHaveBeenCalledWith(expect.stringContaining('影响车辆:3 辆')); + expect(writeText).toHaveBeenCalledWith(expect.stringContaining('统计记录:6 条')); + expect(writeText).toHaveBeenCalledWith(expect.stringContaining('累计里程:210 km')); + expect(writeText).toHaveBeenCalledWith(expect.stringContaining('在线状态:3 在线 / 1 离线 / 在线率 75%')); + expect(writeText).toHaveBeenCalledWith(expect.stringContaining('发布判断:复核后发布')); + expect(writeText).toHaveBeenCalledWith(expect.stringContaining('业务影响:当前统计范围需要先复核轨迹、RAW和离线车辆影响')); expect(await screen.findByText('在线状态明细')).toBeInTheDocument(); expect(await screen.findByText('VIN-STATS-SERVICE-OFFLINE')).toBeInTheDocument(); expect(screen.getByText('粤A离线1')).toBeInTheDocument();