diff --git a/vehicle-data-platform/apps/web/src/pages/Mileage.tsx b/vehicle-data-platform/apps/web/src/pages/Mileage.tsx index a53c494a..39fba41e 100644 --- a/vehicle-data-platform/apps/web/src/pages/Mileage.tsx +++ b/vehicle-data-platform/apps/web/src/pages/Mileage.tsx @@ -228,6 +228,64 @@ function mileageBIPublishText({ ].join('\n'); } +type MileagePublishAuditItem = { + key: string; + label: string; + status: 'pass' | 'review' | 'blocked'; + value: string; + detail: string; + action: string; +}; + +function auditStatusColor(status: MileagePublishAuditItem['status']): 'green' | 'orange' | 'red' { + if (status === 'pass') return 'green'; + if (status === 'blocked') return 'red'; + return 'orange'; +} + +function auditStatusLabel(status: MileagePublishAuditItem['status']) { + if (status === 'pass') return '通过'; + if (status === 'blocked') return '阻断'; + return '复核'; +} + +function mileagePublishAuditReport({ + filters, + items, + summary, + statisticsURL, + vehicleURL +}: { + filters: Record; + items: MileagePublishAuditItem[]; + summary: MileageSummary; + statisticsURL: string; + vehicleURL?: string; +}) { + const blocked = items.filter((item) => item.status === 'blocked').length; + const review = items.filter((item) => item.status === 'review').length; + const pass = items.filter((item) => item.status === 'pass').length; + const keyword = filters.keyword?.trim() || '全部车辆'; + const protocol = filters.protocol?.trim() || '全部来源'; + return [ + '【统计发布审计】', + `发布结论:${blocked > 0 ? '阻断发布' : review > 0 ? '复核后发布' : '可发布'}`, + `车辆范围:${keyword}`, + `数据来源:${protocol}`, + `统计规模:${summary.vehicleCount.toLocaleString()} 车 / ${summary.recordCount.toLocaleString()} 条 / ${summary.sourceCount.toLocaleString()} 来源`, + `审计结果:通过 ${pass.toLocaleString()} / 复核 ${review.toLocaleString()} / 阻断 ${blocked.toLocaleString()}`, + '', + ...items.map((item, index) => [ + `${index + 1}. [${auditStatusLabel(item.status)}] ${item.label}:${item.value}`, + ` 说明:${item.detail}`, + ` 动作:${item.action}` + ].join('\n')), + '', + `统计查询:${statisticsURL}`, + ...(vehicleURL ? [`车辆服务:${vehicleURL}`] : []) + ].join('\n'); +} + function mileageEvidencePackageText(row: DailyMileageRow) { const dateFrom = row.date?.trim() ?? ''; const dateTo = nextDate(dateFrom); @@ -368,6 +426,52 @@ export function Mileage({ redisOnlineKeyText, onlineSummary.evidence ].filter(Boolean).join(','); + const publishAuditItems: MileagePublishAuditItem[] = [ + { + key: 'closure', + label: '区间闭合', + status: closureDeltaKm == null ? 'review' : closurePassed ? 'pass' : 'blocked', + value: closureStatus, + detail: closureDeltaKm == null ? '当前分页未覆盖全量记录,无法直接证明区间总值等于每日之和。' : `闭合差值 ${formatKm(Math.abs(closureDeltaKm))} km。`, + action: closurePassed ? '保留当前统计证据,可继续审计异常和来源。' : '缩小筛选范围、翻页或导出后重新核验闭合。' + }, + { + key: 'coverage', + label: '记录覆盖', + status: pageCoverageRate >= 100 ? 'pass' : summary.recordCount > rows.length ? 'review' : 'pass', + value: formatPercent(pageCoverageRate), + detail: `${rows.length.toLocaleString()} / ${summary.recordCount.toLocaleString()} 条明细在当前页。`, + action: pageCoverageRate >= 100 ? '当前明细已覆盖统计范围。' : '发布前需要导出或拉齐全量明细。' + }, + { + key: 'anomaly', + label: '异常记录', + status: anomalyRows.length > 0 ? 'review' : 'pass', + value: anomalyRows.length.toLocaleString(), + detail: anomalyRows.length > 0 ? '存在日里程异常标记,需核对首末总里程、补传和断链。' : '当前筛选范围未发现异常里程记录。', + action: anomalyRows.length > 0 ? '优先打开异常行的轨迹和 RAW 证据复核。' : '继续检查来源一致性和在线证据。' + }, + { + key: 'source', + label: '来源一致性', + status: summary.sourceCount > 1 ? 'pass' : summary.sourceCount === 1 ? 'review' : 'blocked', + value: sourceConsistencyText, + detail: summary.sourceCount > 1 ? '可使用多协议来源交叉验证车辆统计。' : summary.sourceCount === 1 ? '只有单一来源,统计可用但可信度低于多来源。' : '当前统计缺少来源证据。', + action: summary.sourceCount > 1 ? '保留来源贡献作为审计附件。' : '核对 32960、808、MQTT 是否有缺失来源。' + }, + { + key: 'online', + label: '在线证据', + status: onlineVehicleCount > 0 ? 'pass' : offlineVehicleCount > 0 ? 'review' : 'review', + value: `${onlineVehicleCount.toLocaleString()} 在线 / ${offlineVehicleCount.toLocaleString()} 离线`, + detail: onlineStatsDetail || '等待在线统计证据。', + action: onlineVehicleCount > 0 ? '实时在线证据可辅助统计发布。' : '统计发布前建议检查离线时长和平台转发状态。' + } + ]; + const publishBlockedCount = publishAuditItems.filter((item) => item.status === 'blocked').length; + 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 maxDateMileage = Math.max(...dateSeries.map((item) => item.value), 0); const maxSourceMileage = Math.max(...sourceSeries.map((item) => item.value), 0); const filterSummary = [ @@ -491,6 +595,21 @@ export function Mileage({ 'BI里程发布口径' ); }; + const copyPublishAudit = () => { + const vehicleURL = currentVehicleKeyword + ? appURL(buildAppHash({ page: 'detail', keyword: currentVehicleKeyword, protocol: currentProtocol })) + : undefined; + copyText( + mileagePublishAuditReport({ + filters, + items: publishAuditItems, + summary, + statisticsURL: appURL(window.location.hash || buildAppHash({ page: 'mileage', keyword: currentVehicleKeyword, protocol: currentProtocol })), + vehicleURL + }), + '统计发布审计' + ); + }; const copyOnlineStatusHandoff = () => { copyText( onlineStatusHandoffText({ @@ -677,6 +796,36 @@ export function Mileage({ + +
+
+ {publishAuditConclusion} +
+ 通过 {publishAuditItems.length - publishReviewCount - publishBlockedCount} / 复核 {publishReviewCount} / 阻断 {publishBlockedCount} +
+ + 发布前统一审计闭合、覆盖、异常、来源和在线证据;不满足时先回到轨迹和 RAW 证据复核。 + + + + + +
+
+ {publishAuditItems.map((item) => ( +
+ + {auditStatusLabel(item.status)} + {item.label} + + {item.value} + {item.detail} +
{item.action}
+
+ ))} +
+
+
diff --git a/vehicle-data-platform/apps/web/src/styles/global.css b/vehicle-data-platform/apps/web/src/styles/global.css index 78745c24..e61f867d 100644 --- a/vehicle-data-platform/apps/web/src/styles/global.css +++ b/vehicle-data-platform/apps/web/src/styles/global.css @@ -1093,6 +1093,54 @@ button.vp-realtime-command-item:focus-visible { line-height: 22px; } +.vp-stat-audit-board { + display: grid; + grid-template-columns: minmax(260px, 0.7fr) minmax(0, 1.3fr); + gap: 16px; +} + +.vp-stat-audit-summary { + min-height: 168px; + 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-audit-grid { + display: grid; + grid-template-columns: repeat(5, minmax(0, 1fr)); + gap: 12px; +} + +.vp-stat-audit-item { + min-height: 184px; + padding: 12px; + border: 1px solid var(--vp-border); + border-radius: var(--vp-radius); + background: #fbfcff; + display: grid; + gap: 9px; + align-content: start; +} + +.vp-stat-audit-item strong { + color: var(--vp-text); + font-size: 18px; + line-height: 24px; +} + +.vp-stat-audit-action { + padding-top: 8px; + border-top: 1px solid var(--vp-border); + color: var(--vp-text-muted); + font-size: 12px; + line-height: 18px; +} + .vp-trajectory-anomaly-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); @@ -1808,6 +1856,8 @@ button.vp-realtime-command-item:focus-visible { .vp-stat-closure-board, .vp-bi-publish-board, .vp-bi-publish-grid, + .vp-stat-audit-board, + .vp-stat-audit-grid, .vp-trajectory-anomaly-grid, .vp-source-fusion-grid, .vp-source-fusion-facts, 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 464215a7..39690f12 100644 --- a/vehicle-data-platform/apps/web/src/test/App.test.tsx +++ b/vehicle-data-platform/apps/web/src/test/App.test.tsx @@ -6920,7 +6920,7 @@ test('opens vehicle service from mileage row with row source evidence', async () render(); expect(await screen.findByText('VIN-MILEAGE-ROW')).toBeInTheDocument(); - fireEvent.click(screen.getByRole('button', { name: '车辆服务' })); + fireEvent.click(screen.getAllByRole('button', { name: '车辆服务' })[0]); await waitFor(() => { expect(window.location.hash).toBe('#/detail?keyword=VIN-MILEAGE-ROW&protocol=JT808'); @@ -7236,6 +7236,12 @@ test('shows mileage statistics workspace with trend source and definition', asyn 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.getByText('区间闭合')).toBeInTheDocument(); + expect(screen.getAllByText('记录覆盖').length).toBeGreaterThanOrEqual(1); + expect(screen.getByText('在线证据')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: '复制统计发布审计' })).toBeInTheDocument(); expect(screen.getAllByText('异常记录').length).toBeGreaterThanOrEqual(1); expect(screen.getAllByText('1').length).toBeGreaterThanOrEqual(1); expect(screen.getByText('最大单日')).toBeInTheDocument(); @@ -7376,8 +7382,8 @@ test('shows vehicle-first statistics domains for one vehicle service', async () expect(screen.getByText('3 车 / 2 来源 / 3 条明细')).toBeInTheDocument(); expect(await screen.findByText('75%')).toBeInTheDocument(); expect(screen.getAllByText((content) => content.includes('离线 1 车')).length).toBeGreaterThanOrEqual(1); - expect(screen.getByText((content) => content.includes('Redis 在线 92 key'))).toBeInTheDocument(); - expect(screen.getByText((content) => content.includes('由车辆服务覆盖汇总、实时来源状态和 Redis 在线 key 计算'))).toBeInTheDocument(); + expect(screen.getAllByText((content) => content.includes('Redis 在线 92 key')).length).toBeGreaterThanOrEqual(1); + expect(screen.getAllByText((content) => content.includes('由车辆服务覆盖汇总、实时来源状态和 Redis 在线 key 计算')).length).toBeGreaterThanOrEqual(1); expect(await screen.findByText('在线状态明细')).toBeInTheDocument(); expect(await screen.findByText('VIN-STATS-SERVICE-OFFLINE')).toBeInTheDocument(); expect(screen.getByText('粤A离线1')).toBeInTheDocument(); @@ -7453,6 +7459,7 @@ test('copies mileage statistics summary for operations reporting', async () => { expect(await screen.findByText('区间趋势')).toBeInTheDocument(); expect(screen.getByText('BI发布口径')).toBeInTheDocument(); expect(screen.getByRole('button', { name: '复制BI发布口径' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: '复制统计发布审计' })).toBeInTheDocument(); fireEvent.click(screen.getAllByRole('button', { name: '复制统计摘要' })[0]); expect(writeText).toHaveBeenCalledWith(expect.stringContaining('【里程统计摘要】')); @@ -7475,6 +7482,12 @@ test('copies mileage statistics summary for operations reporting', async () => { expect(writeText).toHaveBeenCalledWith(expect.stringContaining('异常记录:1')); expect(writeText).toHaveBeenCalledWith(expect.stringContaining('来源一致性:可做跨来源核对')); expect(writeText).toHaveBeenCalledWith(expect.stringContaining('发布风险:存在分页未全量、异常记录或闭合差值,需要先复核轨迹和 RAW 证据')); + fireEvent.click(screen.getByRole('button', { name: '复制统计发布审计' })); + expect(writeText).toHaveBeenCalledWith(expect.stringContaining('【统计发布审计】')); + expect(writeText).toHaveBeenCalledWith(expect.stringContaining('发布结论:复核后发布')); + expect(writeText).toHaveBeenCalledWith(expect.stringContaining('审计结果:通过 1 / 复核 4 / 阻断 0')); + expect(writeText).toHaveBeenCalledWith(expect.stringContaining('[复核] 记录覆盖:50%')); + expect(writeText).toHaveBeenCalledWith(expect.stringContaining('[复核] 异常记录:1')); }); test('exports current mileage page as CSV', async () => {