diff --git a/vehicle-data-platform/apps/web/src/pages/Mileage.tsx b/vehicle-data-platform/apps/web/src/pages/Mileage.tsx index 31dec0ff..a53c494a 100644 --- a/vehicle-data-platform/apps/web/src/pages/Mileage.tsx +++ b/vehicle-data-platform/apps/web/src/pages/Mileage.tsx @@ -177,6 +177,57 @@ function mileageSummaryText({ ].join('\n'); } +function mileageBIPublishText({ + filters, + summary, + pageMileageTotal, + pageCoverageRate, + anomalyCount, + closureStatus, + closureDeltaKm, + confidenceStatus, + sourceConsistencyText, + statisticsURL, + vehicleURL +}: { + filters: Record; + summary: MileageSummary; + pageMileageTotal: number; + pageCoverageRate: number; + anomalyCount: number; + closureStatus: string; + closureDeltaKm?: number; + confidenceStatus: string; + sourceConsistencyText: 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 || '-'}` : '全部日期'; + const publishable = confidenceStatus === '可用于 BI' && closureStatus === '闭合通过'; + return [ + '【BI里程发布口径】', + `发布结论:${publishable ? '可发布' : '需复核后发布'}`, + `车辆范围:${keyword}`, + `数据来源:${protocol}`, + `日期范围:${dateRange}`, + `统计口径:日里程按首末总里程差值计算,区间总值等于每日里程之和`, + `累计里程:${formatKm(summary.totalMileageKm)} km`, + `明细合计:${formatKm(pageMileageTotal)} km`, + `闭合状态:${closureStatus}${closureDeltaKm == null ? '' : `,差值 ${formatKm(Math.abs(closureDeltaKm))} km`}`, + `记录覆盖率:${formatPercent(pageCoverageRate)}`, + `异常记录:${anomalyCount.toLocaleString()}`, + `来源一致性:${sourceConsistencyText}`, + `统计车辆:${summary.vehicleCount.toLocaleString()},记录:${summary.recordCount.toLocaleString()},来源:${summary.sourceCount.toLocaleString()}`, + `发布风险:${publishable ? '当前筛选范围闭合且无异常记录' : '存在分页未全量、异常记录或闭合差值,需要先复核轨迹和 RAW 证据'}`, + `统计查询:${statisticsURL}`, + ...(vehicleURL ? [`车辆服务:${vehicleURL}`] : []) + ].join('\n'); +} + function mileageEvidencePackageText(row: DailyMileageRow) { const dateFrom = row.date?.trim() ?? ''; const dateTo = nextDate(dateFrom); @@ -215,9 +266,11 @@ function onlineStatusHandoffText({ }) { const keyword = filters.keyword?.trim() || '全部车辆'; const protocol = filters.protocol?.trim() || '全部来源'; - const offlineRows = rows.filter((row) => !row.online); - const protocolLines = (summary.protocolStats ?? []).length > 0 - ? summary.protocolStats.map((item) => `${item.protocol} ${item.online.toLocaleString()}/${item.total.toLocaleString()}`).join(';') + const handoffRows = Array.isArray(rows) ? rows : []; + const offlineRows = handoffRows.filter((row) => !row.online); + const protocolStats = Array.isArray(summary.protocolStats) ? summary.protocolStats : []; + const protocolLines = protocolStats.length > 0 + ? protocolStats.map((item) => `${item.protocol} ${item.online.toLocaleString()}/${item.total.toLocaleString()}`).join(';') : '-'; return [ '【车辆在线状态交接】', @@ -339,7 +392,7 @@ export function Mileage({ setLoading(true); api.dailyMileage(mileageParams(values, pageSize, (page - 1) * pageSize)) .then((nextPage) => { - setRows(nextPage.items); + setRows(Array.isArray(nextPage.items) ? nextPage.items : []); setPagination({ currentPage: page, pageSize, total: nextPage.total }); }) .catch((error: Error) => Toast.error(error.message)) @@ -350,7 +403,7 @@ export function Mileage({ setOnlineLoading(true); api.onlineVehicleStatuses(mileageParams(values, pageSize, (page - 1) * pageSize)) .then((nextPage) => { - setOnlineRows(nextPage.items); + setOnlineRows(Array.isArray(nextPage.items) ? nextPage.items : []); setOnlinePagination({ currentPage: page, pageSize, total: nextPage.total }); }) .catch((error: Error) => Toast.error(error.message)) @@ -417,6 +470,27 @@ export function Mileage({ '统计摘要' ); }; + const copyBIPublishText = () => { + const vehicleURL = currentVehicleKeyword + ? appURL(buildAppHash({ page: 'detail', keyword: currentVehicleKeyword, protocol: currentProtocol })) + : undefined; + copyText( + mileageBIPublishText({ + filters, + summary, + pageMileageTotal, + pageCoverageRate, + anomalyCount: anomalyRows.length, + closureStatus, + closureDeltaKm, + confidenceStatus, + sourceConsistencyText, + statisticsURL: appURL(window.location.hash || buildAppHash({ page: 'mileage', keyword: currentVehicleKeyword, protocol: currentProtocol })), + vehicleURL + }), + 'BI里程发布口径' + ); + }; const copyOnlineStatusHandoff = () => { copyText( onlineStatusHandoffText({ @@ -580,6 +654,29 @@ export function Mileage({ + +
+
+ {confidenceStatus} +
+ {confidenceStatus === '可用于 BI' && closureStatus === '闭合通过' ? '可发布' : '需复核后发布'} +
+ + 日里程按首末总里程差值计算,区间总值必须等于每日里程之和;发布前同步检查异常记录、分页覆盖和来源一致性。 + +
+
+
闭合状态{closureStatus}
+
记录覆盖{formatPercent(pageCoverageRate)}
+
异常记录{anomalyRows.length.toLocaleString()}
+
来源一致性{sourceConsistencyText}
+
+ + + + +
+
diff --git a/vehicle-data-platform/apps/web/src/styles/global.css b/vehicle-data-platform/apps/web/src/styles/global.css index 85585241..92e44124 100644 --- a/vehicle-data-platform/apps/web/src/styles/global.css +++ b/vehicle-data-platform/apps/web/src/styles/global.css @@ -991,6 +991,47 @@ button.vp-realtime-command-item:focus-visible { gap: 12px; } +.vp-bi-publish-board { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(360px, 0.9fr) auto; + gap: 16px; + align-items: center; +} + +.vp-bi-publish-main { + display: grid; + gap: 8px; + align-content: start; +} + +.vp-bi-publish-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 10px; +} + +.vp-bi-publish-grid div { + min-height: 68px; + padding: 10px; + border: 1px solid var(--vp-border); + border-radius: var(--vp-radius); + background: #fbfcff; + display: grid; + gap: 6px; +} + +.vp-bi-publish-grid span { + color: var(--vp-text-muted); + font-size: 12px; + line-height: 18px; +} + +.vp-bi-publish-grid strong { + color: var(--vp-text); + font-size: 15px; + line-height: 22px; +} + .vp-trajectory-anomaly-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); @@ -1516,6 +1557,8 @@ button.vp-realtime-command-item:focus-visible { .vp-stat-insight-grid, .vp-stat-definition-grid, .vp-stat-closure-board, + .vp-bi-publish-board, + .vp-bi-publish-grid, .vp-trajectory-anomaly-grid, .vp-vehicle-map-layout, .vp-playback-layout, 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 71644a7e..77ffd73d 100644 --- a/vehicle-data-platform/apps/web/src/test/App.test.tsx +++ b/vehicle-data-platform/apps/web/src/test/App.test.tsx @@ -7060,12 +7060,12 @@ test('shows mileage statistics workspace with trend source and definition', asyn expect(screen.getByText('明细合计')).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.getByRole('button', { name: '闭合复核轨迹' })).toBeInTheDocument(); expect(screen.getByRole('button', { name: '闭合复核 RAW' })).toBeInTheDocument(); - expect(screen.getByText('异常记录')).toBeInTheDocument(); + expect(screen.getAllByText('异常记录').length).toBeGreaterThanOrEqual(1); expect(screen.getAllByText('1').length).toBeGreaterThanOrEqual(1); expect(screen.getByText('最大单日')).toBeInTheDocument(); expect(screen.getByText('60 km')).toBeInTheDocument(); @@ -7199,7 +7199,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.getByText('三类数据源最终汇总为一个车辆服务统计视图')).toBeInTheDocument(); expect(screen.getByText('当前统计证据')).toBeInTheDocument(); expect(screen.getByText('3 车 / 2 来源 / 3 条明细')).toBeInTheDocument(); @@ -7280,7 +7280,9 @@ test('copies mileage statistics summary for operations reporting', async () => { render(); expect(await screen.findByText('区间趋势')).toBeInTheDocument(); - fireEvent.click(screen.getByRole('button', { name: '复制统计摘要' })); + expect(screen.getByText('BI发布口径')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: '复制BI发布口径' })).toBeInTheDocument(); + fireEvent.click(screen.getAllByRole('button', { name: '复制统计摘要' })[0]); expect(writeText).toHaveBeenCalledWith(expect.stringContaining('【里程统计摘要】')); expect(writeText).toHaveBeenCalledWith(expect.stringContaining('车辆范围:VIN-MILEAGE-COPY')); @@ -7293,6 +7295,15 @@ test('copies mileage statistics summary for operations reporting', async () => { expect(writeText).toHaveBeenCalledWith(expect.stringContaining('统计口径:区间总值等于各日里程之和')); expect(writeText).toHaveBeenCalledWith(expect.stringContaining('统计查询:http://localhost:3000/#/mileage?keyword=VIN-MILEAGE-COPY&protocol=JT808&dateFrom=2026-07-01&dateTo=2026-07-03')); expect(writeText).toHaveBeenCalledWith(expect.stringContaining('车辆服务:http://localhost:3000/#/detail?keyword=VIN-MILEAGE-COPY&protocol=JT808')); + fireEvent.click(screen.getByRole('button', { name: '复制BI发布口径' })); + expect(writeText).toHaveBeenCalledWith(expect.stringContaining('【BI里程发布口径】')); + expect(writeText).toHaveBeenCalledWith(expect.stringContaining('发布结论:需复核后发布')); + expect(writeText).toHaveBeenCalledWith(expect.stringContaining('统计口径:日里程按首末总里程差值计算,区间总值等于每日里程之和')); + expect(writeText).toHaveBeenCalledWith(expect.stringContaining('闭合状态:分页抽样')); + expect(writeText).toHaveBeenCalledWith(expect.stringContaining('记录覆盖率:50%')); + expect(writeText).toHaveBeenCalledWith(expect.stringContaining('异常记录:1')); + expect(writeText).toHaveBeenCalledWith(expect.stringContaining('来源一致性:可做跨来源核对')); + expect(writeText).toHaveBeenCalledWith(expect.stringContaining('发布风险:存在分页未全量、异常记录或闭合差值,需要先复核轨迹和 RAW 证据')); }); test('exports current mileage page as CSV', async () => {