diff --git a/vehicle-data-platform/apps/web/src/pages/VehicleDetail.tsx b/vehicle-data-platform/apps/web/src/pages/VehicleDetail.tsx index 4d8500e5..dfa4b778 100644 --- a/vehicle-data-platform/apps/web/src/pages/VehicleDetail.tsx +++ b/vehicle-data-platform/apps/web/src/pages/VehicleDetail.tsx @@ -66,6 +66,31 @@ function sourceServiceRole(source: VehicleSourceStatus, primaryProtocol?: string return { label: '离线证据', color: 'grey' as const }; } +function sourceFusionRow(source: VehicleSourceStatus, realtime: RealtimeLocationRow | undefined, primaryProtocol?: string): SourceFusionRow { + const hasLocation = !!realtime && isFiniteNumber(realtime.longitude) && isFiniteNumber(realtime.latitude) && realtime.longitude !== 0 && realtime.latitude !== 0; + const hasMileage = isFiniteNumber(realtime?.totalMileageKm) || source.hasMileage; + const contribution = [ + source.protocol === primaryProtocol ? '主实时来源' : '', + source.online ? '在线心跳' : '', + source.hasRealtime || realtime ? '实时字段' : '', + hasLocation ? '定位证据' : '', + source.hasHistory ? '轨迹回放' : '', + source.hasRaw ? 'RAW证据' : '', + hasMileage ? '统计口径' : '' + ].filter(Boolean); + return { + source, + realtime, + role: sourceServiceRole(source, primaryProtocol), + freshness: source.online ? `在线 / ${source.lastSeen || realtime?.lastSeen || '-'}` : source.lastSeen ? `离线 / ${source.lastSeen}` : '未接入', + realtimeText: source.hasRealtime || realtime ? `速度 ${formatCompactNumber(realtime?.speedKmh, ' km/h')} / SOC ${formatCompactNumber(realtime?.socPercent, '%')}` : '无实时字段', + locationText: hasLocation ? `${realtime?.longitude}, ${realtime?.latitude}` : '无有效坐标', + rawText: source.hasRaw ? '可查 RAW' : '无 RAW 证据', + mileageText: hasMileage ? formatCompactNumber(realtime?.totalMileageKm, ' km') : '无里程证据', + contribution: contribution.length > 0 ? contribution : ['待补来源'] + }; +} + type VehicleServiceAction = { key: string; label: string; @@ -74,6 +99,18 @@ type VehicleServiceAction = { onClick: () => void; }; +type SourceFusionRow = { + source: VehicleSourceStatus; + realtime?: RealtimeLocationRow; + role: { label: string; color: 'green' | 'orange' | 'red' | 'blue' | 'grey' }; + freshness: string; + realtimeText: string; + locationText: string; + rawText: string; + mileageText: string; + contribution: string[]; +}; + type VehicleReportRow = { section: string; item: string; @@ -243,6 +280,9 @@ export function VehicleDetail({ } return next; }, [realtimeRows]); + const sourceFusionRows = useMemo(() => ( + (detail?.sourceStatus ?? []).map((source) => sourceFusionRow(source, latestRealtimeByProtocol.get(source.protocol), summary?.primaryProtocol)) + ), [detail?.sourceStatus, latestRealtimeByProtocol, summary?.primaryProtocol]); const realtimeSources = new Set(realtimeRows.map((row) => row.protocol).filter(Boolean)); const sourceCount = Math.max(detail?.sourceStatus?.length ?? 0, realtimeSources.size, summary?.sourceCount ?? 0, protocols.length); const onlineSourceCount = detail?.sourceStatus?.length @@ -385,6 +425,38 @@ export function VehicleDetail({ ]; copyText(lines.join('\n'), '诊断摘要'); }; + const copySourceFusionReport = () => { + if (!detail || sourceFusionRows.length === 0) { + Toast.warning('当前没有可复制的多源归并矩阵'); + return; + } + const vehicleName = [archivePlate, displayVIN].filter((item) => item && item !== '-').join(' / ') || displayLookupKey; + const lines = [ + '【单车多源归并矩阵】', + `车辆:${vehicleName}`, + `当前范围:${scopeText}`, + `主来源:${overview?.primaryProtocol || summary?.primaryProtocol || '-'}`, + `来源覆盖:${evidenceSourceCount > 0 ? `${evidenceOnlineSourceCount}/${evidenceSourceCount} 在线` : '-'}`, + `一致性:${consistencyTitle}`, + '', + ...sourceFusionRows.map((row, index) => [ + `${index + 1}. ${row.source.protocol} / ${row.role.label}`, + ` 新鲜度:${row.freshness}`, + ` 贡献:${row.contribution.join('、')}`, + ` 实时:${row.realtimeText}`, + ` 定位:${row.locationText}`, + ` RAW:${row.rawText}`, + ` 里程:${row.mileageText}` + ].join('\n')), + '', + `车辆服务:${vehicleServiceURL(vehicleServiceHash('detail'))}`, + `实时监控:${vehicleServiceURL(vehicleServiceHash('realtime'))}`, + `轨迹回放:${vehicleServiceURL(vehicleServiceHash('history'))}`, + `RAW证据:${vehicleServiceURL(vehicleServiceHash('history-query', { tab: 'raw', includeFields: 'true' }))}`, + `里程统计:${vehicleServiceURL(vehicleServiceHash('mileage'))}` + ]; + copyText(lines.join('\n'), '多源归并矩阵'); + }; const qualityIssueAction = (count: number) => { if (count <= 0) { return ; @@ -818,6 +890,62 @@ export function VehicleDetail({ ) : null} + {hasResolvedVIN && sourceFusionRows.length > 0 ? ( + 多源归并矩阵} + style={{ marginTop: 16 }} + > +
+ {sourceFusionRows.map((row) => ( +
+
+ + {row.source.protocol} + {row.freshness} + + {row.role.label} +
+
+ {row.contribution.map((item) => ( + {item} + ))} +
+
+ {[ + { label: '实时', value: row.realtimeText }, + { label: '定位', value: row.locationText }, + { label: 'RAW', value: row.rawText }, + { label: '里程', value: row.mileageText } + ].map((item) => ( +
+ {item.label} + {item.value} +
+ ))} +
+ + + + + + + +
+ ))} +
+
+ ) : null} + {hasResolvedVIN && vehicleMapPoints.length > 0 ? (
diff --git a/vehicle-data-platform/apps/web/src/styles/global.css b/vehicle-data-platform/apps/web/src/styles/global.css index 49647d59..ae28d406 100644 --- a/vehicle-data-platform/apps/web/src/styles/global.css +++ b/vehicle-data-platform/apps/web/src/styles/global.css @@ -1548,6 +1548,69 @@ button.vp-realtime-command-item:focus-visible { gap: 16px; } +.vp-source-fusion-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 12px; +} + +.vp-source-fusion-item { + min-height: 250px; + padding: 14px; + border: 1px solid var(--vp-border); + border-radius: var(--vp-radius); + background: #fbfcff; + display: grid; + gap: 12px; + align-content: space-between; +} + +.vp-source-fusion-head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 10px; +} + +.vp-source-fusion-contribution { + min-height: 52px; + display: flex; + flex-wrap: wrap; + align-content: flex-start; + gap: 6px; +} + +.vp-source-fusion-facts { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 8px; +} + +.vp-source-fusion-fact { + min-height: 64px; + padding: 9px 10px; + border: 1px solid var(--vp-border); + border-radius: 6px; + background: #fff; + display: grid; + gap: 5px; + align-content: start; +} + +.vp-source-fusion-fact span { + color: var(--vp-text-muted); + font-size: 12px; + line-height: 18px; +} + +.vp-source-fusion-fact strong { + color: var(--vp-text); + font-size: 13px; + line-height: 19px; + font-weight: 600; + word-break: break-word; +} + .vp-vehicle-detail-map { height: 300px; } @@ -1647,6 +1710,8 @@ button.vp-realtime-command-item:focus-visible { .vp-bi-publish-board, .vp-bi-publish-grid, .vp-trajectory-anomaly-grid, + .vp-source-fusion-grid, + .vp-source-fusion-facts, .vp-vehicle-map-layout, .vp-playback-layout, .vp-playback-timeline { 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 7b2ebe6b..9a618050 100644 --- a/vehicle-data-platform/apps/web/src/test/App.test.tsx +++ b/vehicle-data-platform/apps/web/src/test/App.test.tsx @@ -10100,6 +10100,11 @@ test('filters vehicle list by service status', async () => { test('switches vehicle detail to a source from the source matrix', async () => { window.history.replaceState(null, '', '/#/detail?keyword=VIN001'); + const writeText = vi.fn(() => Promise.resolve()); + Object.defineProperty(navigator, 'clipboard', { + configurable: true, + value: { writeText } + }); const fetchMock = vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => { const path = String(input); if (path.includes('/api/vehicle-service')) { @@ -10194,10 +10199,20 @@ test('switches vehicle detail to a source from the source matrix', async () => { expect(screen.getAllByText('GB32960 在线').length).toBeGreaterThanOrEqual(1); expect(screen.getAllByText('JT808 在线').length).toBeGreaterThanOrEqual(1); expect(screen.getAllByText('YUTONG_MQTT 未接入').length).toBeGreaterThanOrEqual(1); + expect(screen.getByText('多源归并矩阵')).toBeInTheDocument(); + expect(screen.getByText('主实时来源')).toBeInTheDocument(); + expect(screen.getAllByText('定位证据').length).toBeGreaterThanOrEqual(1); + expect(screen.getAllByText('统计口径').length).toBeGreaterThanOrEqual(1); + expect(screen.getByText('无实时字段')).toBeInTheDocument(); + expect(screen.getByText('无 RAW 证据')).toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: '复制归并矩阵' })); + expect(writeText).toHaveBeenCalledWith(expect.stringContaining('【单车多源归并矩阵】')); + expect(writeText).toHaveBeenCalledWith(expect.stringContaining('1. GB32960 / 主来源')); + expect(writeText).toHaveBeenCalledWith(expect.stringContaining('贡献:主实时来源、在线心跳、实时字段、定位证据、轨迹回放、RAW证据、统计口径')); expect(screen.getByText('车辆服务角色')).toBeInTheDocument(); - expect(screen.getByText('主来源')).toBeInTheDocument(); - expect(screen.getByText('在线证据')).toBeInTheDocument(); - expect(screen.getByText('暂无来源')).toBeInTheDocument(); + expect(screen.getAllByText('主来源').length).toBeGreaterThanOrEqual(1); + expect(screen.getAllByText('在线证据').length).toBeGreaterThanOrEqual(1); + expect(screen.getAllByText('暂无来源').length).toBeGreaterThanOrEqual(1); expect(screen.getByText('最新位置')).toBeInTheDocument(); expect(screen.getAllByText('113.2, 23.1').length).toBeGreaterThan(0); expect(screen.getAllByText(/100\s*km/).length).toBeGreaterThan(0);