diff --git a/vehicle-data-platform/apps/web/src/pages/Mileage.tsx b/vehicle-data-platform/apps/web/src/pages/Mileage.tsx index df02a521..940876fc 100644 --- a/vehicle-data-platform/apps/web/src/pages/Mileage.tsx +++ b/vehicle-data-platform/apps/web/src/pages/Mileage.tsx @@ -177,6 +177,69 @@ function mileageSummaryText({ ].join('\n'); } +function mileageDeliveryPackageText({ + filters, + summary, + pageMileageTotal, + pageCoverageRate, + anomalyCount, + closureStatus, + closureDeltaKm, + confidenceStatus, + publishAuditConclusion, + onlineSummary, + peakMileage, + statisticsURL, + vehicleURL +}: { + filters: Record; + summary: MileageSummary; + pageMileageTotal: number; + pageCoverageRate: number; + anomalyCount: number; + closureStatus: string; + closureDeltaKm?: number; + confidenceStatus: string; + publishAuditConclusion: string; + onlineSummary: OnlineStatisticsSummary; + peakMileage?: DailyMileageRow; + 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 || '-'}` : '全部日期'; + const peak = peakMileage + ? `${peakMileage.plate || peakMileage.vin} / ${peakMileage.date} / ${formatKm(peakMileage.dailyMileageKm)} km` + : '-'; + const deliveryState = publishAuditConclusion === '可发布' && confidenceStatus === '可用于 BI' + ? '可交付' + : publishAuditConclusion === '阻断发布' ? '暂缓交付' : '复核后交付'; + return [ + '【客户里程复核交付包】', + `交付状态:${deliveryState}`, + `车辆范围:${keyword}`, + `数据来源:${protocol}`, + `日期范围:${dateRange}`, + `累计里程:${formatKm(summary.totalMileageKm)} km`, + `当前页明细合计:${formatKm(pageMileageTotal)} km`, + `闭合状态:${closureStatus}${closureDeltaKm == null ? '' : `,差值 ${formatKm(Math.abs(closureDeltaKm))} km`}`, + `记录覆盖率:${formatPercent(pageCoverageRate)}`, + `异常记录:${anomalyCount.toLocaleString()}`, + `最大单日:${peak}`, + `在线影响:${onlineSummary.onlineVehicleCount.toLocaleString()} 在线 / ${onlineSummary.offlineVehicleCount.toLocaleString()} 离线 / 在线率 ${formatPercent(onlineSummary.onlineRatePercent)}`, + `发布判断:${publishAuditConclusion}`, + `可信度:${confidenceStatus}`, + '统计口径:日里程按首末总里程差值计算,区间总值应等于每日里程之和', + `里程统计:${statisticsURL}`, + `轨迹复核:${appURL(buildAppHash({ page: 'history', keyword: filters.keyword?.trim() || '', protocol: filters.protocol?.trim() || '', filters: { ...(dateFrom ? { dateFrom } : {}), ...(dateTo ? { dateTo } : {}) } }))}`, + `RAW证据:${appURL(buildAppHash({ page: 'history-query', keyword: filters.keyword?.trim() || '', protocol: filters.protocol?.trim() || '', filters: { ...(dateFrom ? { dateFrom } : {}), ...(dateTo ? { dateTo } : {}), tab: 'raw', includeFields: 'true' } }))}`, + ...(vehicleURL ? [`车辆服务:${vehicleURL}`] : []) + ].join('\n'); +} + function mileageBIPublishText({ filters, summary, @@ -576,6 +639,10 @@ export function Mileage({ const publishAuditColor = publishBlockedCount > 0 ? 'red' as const : publishReviewCount > 0 ? 'orange' as const : 'green' as const; const statisticsImpactColor = publishAuditColor; const statisticsImpactLevel = publishAuditConclusion === '可发布' ? '可运营发布' : publishAuditConclusion === '阻断发布' ? '发布阻断' : '影响待复核'; + const mileageDeliveryState = publishAuditConclusion === '可发布' && confidenceStatus === '可用于 BI' + ? '可交付' + : publishAuditConclusion === '阻断发布' ? '暂缓交付' : '复核后交付'; + const mileageDeliveryColor = mileageDeliveryState === '可交付' ? 'green' as const : mileageDeliveryState === '暂缓交付' ? 'red' as const : 'orange' as const; const statisticsImpactItems = [ { label: '影响车辆', @@ -765,6 +832,29 @@ export function Mileage({ '统计摘要' ); }; + const copyMileageDeliveryPackage = () => { + const vehicleURL = currentVehicleKeyword + ? appURL(buildAppHash({ page: 'detail', keyword: currentVehicleKeyword, protocol: currentProtocol })) + : undefined; + copyText( + mileageDeliveryPackageText({ + filters, + summary, + pageMileageTotal, + pageCoverageRate, + anomalyCount: anomalyRows.length, + closureStatus, + closureDeltaKm, + confidenceStatus, + publishAuditConclusion, + onlineSummary, + peakMileage, + statisticsURL: appURL(window.location.hash || buildAppHash({ page: 'mileage', keyword: currentVehicleKeyword, protocol: currentProtocol })), + vehicleURL + }), + '客户里程复核交付包' + ); + }; const copyBIPublishText = () => { const vehicleURL = currentVehicleKeyword ? appURL(buildAppHash({ page: 'detail', keyword: currentVehicleKeyword, protocol: currentProtocol })) @@ -966,6 +1056,82 @@ export function Mileage({ ))} + +
+
+ + {mileageDeliveryState} + {closureStatus} + +
{formatKm(summary.totalMileageKm)} km
+ + 面向客户交付里程时,先确认区间里程、闭合状态、异常记录和在线影响,再导出明细或进入轨迹与 RAW 证据复核。 + + + + + + +
+
+ {[ + { + label: '区间里程', + value: `${formatKm(summary.totalMileageKm)} km`, + detail: `范围 ${mileageRangeText},当前页明细 ${formatKm(pageMileageTotal)} km。`, + color: 'blue' as const, + action: '复制交付', + onClick: copyMileageDeliveryPackage + }, + { + label: '闭合校验', + value: closureStatus, + detail: closureDeltaKm == null ? '当前页未覆盖全量记录,交付前需导出或缩小范围复核。' : `闭合差值 ${formatKm(Math.abs(closureDeltaKm))} km。`, + color: closureStatusColor, + action: '闭合摘要', + onClick: copyStatisticsSummary + }, + { + label: '异常记录', + value: anomalyRows.length.toLocaleString(), + detail: anomalyRows[0] ? `${anomalyRows[0].plate || anomalyRows[0].vin} / ${anomalyRows[0].date} 需要复核。` : '当前页没有异常里程记录。', + color: anomalyRows.length > 0 ? 'orange' as const : 'green' as const, + action: '异常证据', + onClick: () => anomalyRows[0] ? copyMileageEvidence(anomalyRows[0]) : copyMileageDeliveryPackage + }, + { + label: '在线影响', + value: formatPercent(onlineRatePercent), + detail: `${onlineVehicleCount.toLocaleString()} 在线 / ${offlineVehicleCount.toLocaleString()} 离线,影响当日里程完整性判断。`, + color: onlineRatePercent >= 90 ? 'green' as const : onlineRatePercent >= 60 ? 'orange' as const : 'red' as const, + action: '在线态势', + onClick: copyOnlineOperationsReport + } + ].map((item) => ( + + ))} +
+
+
统计影响范围} diff --git a/vehicle-data-platform/apps/web/src/styles/global.css b/vehicle-data-platform/apps/web/src/styles/global.css index 8db0a2a8..eb6adc73 100644 --- a/vehicle-data-platform/apps/web/src/styles/global.css +++ b/vehicle-data-platform/apps/web/src/styles/global.css @@ -2024,6 +2024,72 @@ button.vp-realtime-command-item:focus-visible { gap: 16px; } +.vp-mileage-delivery-board { + display: grid; + grid-template-columns: minmax(280px, 0.64fr) minmax(0, 1.36fr); + gap: 16px; +} + +.vp-mileage-delivery-summary { + min-height: 186px; + padding: 14px; + border: 1px solid rgba(22, 100, 255, 0.2); + border-radius: var(--vp-radius); + background: #f6f9ff; + display: grid; + gap: 10px; + align-content: start; +} + +.vp-mileage-delivery-grid { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 12px; +} + +.vp-mileage-delivery-item { + min-width: 0; + min-height: 186px; + padding: 12px; + border: 1px solid var(--vp-border); + border-radius: var(--vp-radius); + background: #fbfcff; + color: inherit; + text-align: left; + display: grid; + gap: 10px; + align-content: start; + cursor: pointer; +} + +.vp-mileage-delivery-item:hover, +.vp-mileage-delivery-item:focus-visible { + border-color: rgba(22, 100, 255, 0.42); + box-shadow: 0 10px 24px rgba(24, 39, 75, 0.08); + outline: none; +} + +.vp-mileage-delivery-item span { + min-width: 0; + display: grid; + gap: 8px; +} + +.vp-mileage-delivery-item strong { + color: var(--vp-text); + font-size: 20px; + line-height: 26px; + font-weight: 700; + word-break: break-word; +} + +.vp-mileage-delivery-item em { + color: var(--vp-primary); + font-size: 12px; + font-style: normal; + font-weight: 700; +} + .vp-mileage-console-main { min-width: 0; display: grid; @@ -3721,6 +3787,8 @@ button.vp-realtime-command-item:focus-visible { .vp-source-fusion-grid, .vp-source-fusion-facts, .vp-vehicle-map-layout, + .vp-mileage-delivery-board, + .vp-mileage-delivery-grid, .vp-mileage-console, .vp-mileage-kpi-grid, .vp-mileage-task-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 edb7cadf..9a9c485d 100644 --- a/vehicle-data-platform/apps/web/src/test/App.test.tsx +++ b/vehicle-data-platform/apps/web/src/test/App.test.tsx @@ -7883,6 +7883,12 @@ 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.getByText('面向客户交付里程时,先确认区间里程、闭合状态、异常记录和在线影响,再导出明细或进入轨迹与 RAW 证据复核。')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: '里程交付 区间里程 复制交付' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: '里程交付 闭合校验 闭合摘要' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: '里程交付 异常记录 异常证据' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: '里程交付 在线影响 在线态势' })).toBeInTheDocument(); expect(screen.getByText('统计影响范围')).toBeInTheDocument(); expect(screen.getByRole('button', { name: '复制影响范围' })).toBeInTheDocument(); expect(screen.getByText('影响待复核')).toBeInTheDocument(); @@ -7892,7 +7898,7 @@ test('shows vehicle-first statistics domains for one vehicle service', async () expect(screen.getAllByText('发布判断').length).toBeGreaterThanOrEqual(1); expect(screen.getByText('区间复核')).toBeInTheDocument(); expect(screen.getByText('异常处理')).toBeInTheDocument(); - expect(screen.getByText('数据交付')).toBeInTheDocument(); + expect(screen.getAllByText('数据交付').length).toBeGreaterThanOrEqual(1); expect(screen.getByRole('button', { name: '里程服务任务 发布判断 BI口径' })).toBeInTheDocument(); expect(screen.getByRole('button', { name: '里程服务任务 区间复核 轨迹证据' })).toBeInTheDocument(); expect(screen.getByRole('button', { name: '里程服务任务 异常处理 复制证据' })).toBeInTheDocument(); @@ -7933,6 +7939,12 @@ test('shows vehicle-first statistics domains for one vehicle service', async () expect(writeText).toHaveBeenCalledWith(expect.stringContaining('最长离线:粤A离线1 / 2 小时 / 2026-07-03 18:12:10')); expect(writeText).toHaveBeenCalledWith(expect.stringContaining('1. JT808:2/3,在线率 66.7%')); expect(writeText).toHaveBeenCalledWith(expect.stringContaining('2. 离线车辆优先打开车辆服务,核对实时、轨迹、RAW 和告警证据')); + fireEvent.click(screen.getByRole('button', { name: '里程交付 区间里程 复制交付' })); + expect(writeText).toHaveBeenCalledWith(expect.stringContaining('【客户里程复核交付包】')); + expect(writeText).toHaveBeenCalledWith(expect.stringContaining('车辆范围:VIN-STATS-SERVICE')); + expect(writeText).toHaveBeenCalledWith(expect.stringContaining('累计里程:210 km')); + expect(writeText).toHaveBeenCalledWith(expect.stringContaining('发布判断:复核后发布')); + expect(writeText).toHaveBeenCalledWith(expect.stringContaining('统计口径:日里程按首末总里程差值计算,区间总值应等于每日里程之和')); expect(await screen.findByText('在线状态明细')).toBeInTheDocument(); expect(await screen.findByText('VIN-STATS-SERVICE-OFFLINE')).toBeInTheDocument(); expect(screen.getByText('粤A离线1')).toBeInTheDocument();