diff --git a/vehicle-data-platform/apps/web/src/pages/VehicleDetail.tsx b/vehicle-data-platform/apps/web/src/pages/VehicleDetail.tsx index d27f92a9..256c62b6 100644 --- a/vehicle-data-platform/apps/web/src/pages/VehicleDetail.tsx +++ b/vehicle-data-platform/apps/web/src/pages/VehicleDetail.tsx @@ -6,6 +6,7 @@ import type { QualityIssueRow, RealtimeLocationRow, VehicleDetail as VehicleDeta import { PageHeader } from '../components/PageHeader'; import { SourceStatusTags } from '../components/SourceStatusTags'; import { StatusTag } from '../components/StatusTag'; +import { buildAppHash } from '../domain/appRoute'; import { buildCsv, downloadCsv, type CsvColumn } from '../domain/csvExport'; import { isLikelyVIN } from '../domain/vehicleLookup'; import { summarizeVehicleService } from '../domain/vehicleService'; @@ -93,12 +94,12 @@ function vehicleReportFileName(vin: string, protocol?: string) { return `vehicle-service-${vin || 'unknown'}-${protocol || 'all-source'}.csv`; } -async function copyVehicleServiceURL() { +async function copyText(value: string, label: string) { try { - await navigator.clipboard.writeText(`${window.location.origin}${window.location.pathname}${window.location.hash}`); - Toast.success('已复制服务链接'); + await navigator.clipboard.writeText(value); + Toast.success(`已复制${label}`); } catch { - Toast.error('复制服务链接失败'); + Toast.error(`复制${label}失败`); } } @@ -266,6 +267,45 @@ export function VehicleDetail({ keyword: resolvedVIN, ...(activeProtocol ? { protocol: activeProtocol } : {}) }); + const vehicleServiceURL = (hash: string) => `${window.location.origin}${window.location.pathname}${hash}`; + const vehicleServiceHash = (page: 'detail' | 'realtime' | 'history' | 'mileage', filters?: Record) => buildAppHash({ + page, + keyword: resolvedVIN, + protocol: activeProtocol, + filters + }); + const copyVehicleServiceURL = () => copyText(vehicleServiceURL(window.location.hash), '服务链接'); + const copyVehicleDiagnosticSummary = () => { + if (!detail) { + Toast.warning('当前没有可复制的诊断摘要'); + return; + } + const vehicleName = [archivePlate, displayVIN].filter((item) => item && item !== '-').join(' / ') || displayLookupKey; + const lines = [ + '【车辆服务诊断摘要】', + `车辆:${vehicleName}`, + `查询关键词:${reportText(displayLookupKey)}`, + `当前范围:${scopeText}`, + `服务状态:${serviceStatus?.title ?? serviceConclusion.status}`, + `服务说明:${serviceStatus?.detail ?? '-'}`, + `服务判断:${serviceConclusion.status}`, + `主要风险:${serviceConclusion.risk}`, + `下一步:${serviceConclusion.nextStep}`, + `来源证据:${evidenceSourceCount > 0 ? `${evidenceOnlineSourceCount}/${evidenceSourceCount} 来源在线` : '-'}`, + `定位证据:${evidenceLocatedSourceCount > 0 ? `${evidenceLocatedSourceCount} 个来源有位置` : '暂无位置'}`, + `里程差异:${formatCompactNumber(evidenceMileageDelta, ' km')}`, + `时间差异:${formatSeconds(evidenceTimeDeltaSeconds)}`, + `数据规模:实时 ${overview?.realtimeCount ?? detail.realtime.length} / 历史 ${overview?.historyCount ?? detail.history.total ?? 0} / RAW ${overview?.rawCount ?? detail.raw.total ?? 0} / 里程 ${overview?.mileageCount ?? detail.mileage.total ?? 0} / 质量 ${overview?.qualityIssueCount ?? qualityCount}`, + `一致性:${consistencyTitle}`, + `一致性说明:${consistencyDetail}`, + `车辆服务:${vehicleServiceURL(vehicleServiceHash('detail'))}`, + `实时:${vehicleServiceURL(vehicleServiceHash('realtime'))}`, + `轨迹:${vehicleServiceURL(vehicleServiceHash('history'))}`, + `RAW:${vehicleServiceURL(vehicleServiceHash('history', { tab: 'raw', includeFields: 'true' }))}`, + `里程:${vehicleServiceURL(vehicleServiceHash('mileage'))}` + ]; + copyText(lines.join('\n'), '诊断摘要'); + }; const qualityIssueAction = (count: number) => { if (count <= 0) { return ; @@ -404,6 +444,7 @@ export function VehicleDetail({ + 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 adde10a3..b8ab30ba 100644 --- a/vehicle-data-platform/apps/web/src/test/App.test.tsx +++ b/vehicle-data-platform/apps/web/src/test/App.test.tsx @@ -8317,6 +8317,145 @@ test('copies shareable vehicle service detail link', async () => { expect(writeText).toHaveBeenCalledWith(expect.stringContaining('/#/detail?keyword=VIN001&protocol=JT808')); }); +test('copies vehicle service diagnostic summary', async () => { + window.history.replaceState(null, '', '/#/detail?keyword=VIN-SUMMARY-001&protocol=JT808'); + const writeText = vi.fn<(value: string) => Promise>(() => Promise.resolve()); + Object.defineProperty(navigator, 'clipboard', { + configurable: true, + value: { writeText } + }); + vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => { + const path = String(input); + if (path.includes('/api/vehicle-service')) { + const serviceStatus = { + status: 'degraded', + severity: 'warning', + title: '来源不完整', + detail: 'JT808 在线,GB32960 离线', + sourceCount: 2, + onlineSourceCount: 1 + }; + const sourceConsistency = { + sourceCount: 2, + onlineSourceCount: 1, + locatedSourceCount: 2, + missingProtocols: ['YUTONG_MQTT'], + mileageDeltaKm: 12.6, + sourceTimeDeltaSeconds: 90, + scope: 'all_sources', + status: 'degraded', + severity: 'warning', + title: '来源不完整', + detail: 'JT808 在线,GB32960 离线,需要补齐 MQTT' + }; + return { + ok: true, + json: async () => ({ + data: { + vin: 'VIN-SUMMARY-001', + lookupKey: 'VIN-SUMMARY-001', + lookupResolved: true, + identity: { + vin: 'VIN-SUMMARY-001', + plate: '粤A诊断1', + phone: '13307795425', + oem: 'G7s', + online: true, + lastSeen: '2026-07-03 20:12:10' + }, + realtimeSummary: { + vin: 'VIN-SUMMARY-001', + plate: '粤A诊断1', + phone: '13307795425', + oem: 'G7s', + protocols: ['JT808'], + sourceCount: 2, + onlineSourceCount: 1, + online: true, + primaryProtocol: 'JT808', + lastSeen: '2026-07-03 20:12:10' + }, + serviceOverview: { + vin: 'VIN-SUMMARY-001', + plate: '粤A诊断1', + phone: '13307795425', + oem: 'G7s', + protocols: ['GB32960', 'JT808'], + primaryProtocol: 'JT808', + coverageStatus: 'partial', + sourceCount: 2, + onlineSourceCount: 1, + lastSeen: '2026-07-03 20:12:10', + realtimeCount: 2, + historyCount: 12, + rawCount: 34, + mileageCount: 3, + qualityIssueCount: 1 + }, + sources: ['GB32960', 'JT808'], + sourceStatus: [ + { protocol: 'JT808', online: true, lastSeen: '2026-07-03 20:12:10', hasRealtime: true, hasHistory: true, hasRaw: true, hasMileage: true }, + { protocol: 'GB32960', online: false, lastSeen: '2026-07-03 19:55:10', hasRealtime: true, hasHistory: true, hasRaw: true, hasMileage: true } + ], + realtime: [ + { vin: 'VIN-SUMMARY-001', plate: '粤A诊断1', protocol: 'JT808', longitude: 113.2, latitude: 23.1, speedKmh: 32, totalMileageKm: 1200.5, lastSeen: '2026-07-03 20:12:10' } + ], + history: { items: [], total: 12, limit: 20, offset: 0 }, + raw: { items: [], total: 34, limit: 10, offset: 0 }, + mileage: { items: [], total: 3, limit: 20, offset: 0 }, + quality: { + items: [ + { vin: 'VIN-SUMMARY-001', plate: '粤A诊断1', protocol: 'JT808', issueType: 'LINK_GAP', severity: 'warning', lastSeen: '2026-07-03 20:12:10', detail: 'JT808 间断' } + ], + total: 1, + limit: 20, + offset: 0 + }, + serviceStatus, + sourceConsistency + }, + traceId: 'trace-test', + timestamp: 1783094400000 + }) + } as Response; + } + return { + ok: true, + json: async () => ({ + data: path.includes('/api/ops/health') + ? { linkHealth: [], kafkaLag: 0, redisOnlineKeys: 0, tdengineWritable: true, mysqlWritable: true, runtime: { requestTimeoutMs: 5000 } } + : { items: [], total: 0, limit: 10, offset: 0 }, + traceId: 'trace-test', + timestamp: 1783094400000 + }) + } as Response; + }); + + render(); + + expect(await screen.findByText('车辆服务结论')).toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: /复制诊断摘要/ })); + + expect(writeText).toHaveBeenCalledWith(expect.any(String)); + const copied = String(writeText.mock.lastCall?.[0] ?? ''); + expect(copied).toContain('【车辆服务诊断摘要】'); + expect(copied).toContain('车辆:粤A诊断1 / VIN-SUMMARY-001'); + expect(copied).toContain('当前范围:单一来源:JT808'); + expect(copied).toContain('服务状态:来源不完整'); + expect(copied).toContain('来源证据:1/2 来源在线'); + expect(copied).toContain('定位证据:2 个来源有位置'); + expect(copied).toContain('里程差异:12.6 km'); + expect(copied).toContain('时间差异:90 秒'); + expect(copied).toContain('数据规模:实时 2 / 历史 12 / RAW 34 / 里程 3 / 质量 1'); + expect(copied).toContain('一致性:来源不完整'); + expect(copied).toContain('下一步:'); + expect(copied).toContain('车辆服务:http://localhost:3000/#/detail?keyword=VIN-SUMMARY-001&protocol=JT808'); + expect(copied).toContain('实时:http://localhost:3000/#/realtime?keyword=VIN-SUMMARY-001&protocol=JT808'); + expect(copied).toContain('轨迹:http://localhost:3000/#/history?keyword=VIN-SUMMARY-001&protocol=JT808'); + expect(copied).toContain('RAW:http://localhost:3000/#/history?keyword=VIN-SUMMARY-001&protocol=JT808&tab=raw&includeFields=true'); + expect(copied).toContain('里程:http://localhost:3000/#/mileage?keyword=VIN-SUMMARY-001&protocol=JT808'); +}); + test('exports vehicle detail service dossier as csv', async () => { window.history.replaceState(null, '', '/#/detail?keyword=VIN-REPORT-001&protocol=GB32960'); const createObjectURL = vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:vehicle-report');