diff --git a/vehicle-data-platform/apps/web/src/pages/VehicleDetail.tsx b/vehicle-data-platform/apps/web/src/pages/VehicleDetail.tsx index dfa4b778..92d1f488 100644 --- a/vehicle-data-platform/apps/web/src/pages/VehicleDetail.tsx +++ b/vehicle-data-platform/apps/web/src/pages/VehicleDetail.tsx @@ -522,6 +522,65 @@ export function VehicleDetail({ } return actions; }, [activeProtocol, detail?.sourceStatus, displayLookupKey, hasResolvedVIN, missingProtocols, onOpenQuality, onOpenRealtime, onOpenVehicles, qualityCount, resolvedVIN]); + const serviceAssetItems = [ + { + label: '实时', + value: formatCompactNumber(overview?.realtimeCount ?? detail?.realtime?.length ?? 0), + detail: evidenceSourceCount > 0 ? `${evidenceOnlineSourceCount}/${evidenceSourceCount} 来源在线` : '暂无来源', + color: evidenceOnlineSourceCount > 0 ? 'green' as const : 'orange' as const + }, + { + label: '轨迹', + value: formatCompactNumber(overview?.historyCount ?? detail?.history?.total ?? 0), + detail: evidenceLocatedSourceCount > 0 ? `${evidenceLocatedSourceCount} 个位置来源` : '暂无位置', + color: (overview?.historyCount ?? detail?.history?.total ?? 0) > 0 ? 'blue' as const : 'grey' as const + }, + { + label: 'RAW', + value: formatCompactNumber(overview?.rawCount ?? detail?.raw?.total ?? 0), + detail: latestRaw?.frameType || '解析字段', + color: (overview?.rawCount ?? detail?.raw?.total ?? 0) > 0 ? 'blue' as const : 'grey' as const + }, + { + label: '里程', + value: formatCompactNumber(overview?.mileageCount ?? detail?.mileage?.total ?? 0), + detail: formatCompactNumber(evidenceMileageDelta, ' km 差异'), + color: isFiniteNumber(evidenceMileageDelta) && Math.abs(evidenceMileageDelta) > 1 ? 'orange' as const : 'green' as const + }, + { + label: '告警', + value: qualityCount.toLocaleString(), + detail: priorityQualityIssue ? qualityIssueLabel(priorityQualityIssue.issueType) : '暂无告警', + color: qualityCount > 0 ? 'orange' as const : 'green' as const + } + ]; + const copyVehicleServiceDossier = () => { + if (!detail) { + Toast.warning('当前没有可复制的车辆服务档案'); + return; + } + const vehicleName = [archivePlate, displayVIN].filter((item) => item && item !== '-').join(' / ') || displayLookupKey; + const lines = [ + '【车辆服务档案】', + `车辆:${vehicleName}`, + `查询关键词:${displayLookupKey}`, + `当前范围:${scopeText}`, + `档案完整度:${archiveCompleteness}${archiveMissingLabels.length > 0 ? `(${archiveMissingLabels.join('、')})` : ''}`, + `主来源:${overview?.primaryProtocol || summary?.primaryProtocol || '-'}`, + `来源在线:${evidenceSourceCount > 0 ? `${evidenceOnlineSourceCount}/${evidenceSourceCount}` : '-'}`, + `服务状态:${serviceStatus?.title ?? serviceConclusion.status}`, + `服务说明:${serviceStatus?.detail ?? serviceConclusion.risk}`, + `资产概览:${serviceAssetItems.map((item) => `${item.label} ${item.value}`).join(' / ')}`, + `一致性:${consistencyTitle}`, + `下一步:${serviceActions[0]?.label ?? serviceConclusion.nextStep}`, + `车辆服务:${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 copyVehicleOperationsSummary = () => { if (!detail) { Toast.warning('当前没有可复制的运营摘要'); @@ -777,6 +836,56 @@ export function VehicleDetail({ ) : null} + 车辆服务档案总览} + style={{ marginTop: 16 }} + > +
+
+ + {hasResolvedVIN ? '已解析 VIN' : '待维护身份'} + {scopeText} + {serviceStatus?.title ?? serviceConclusion.status} + + + {[archivePlate, displayVIN].filter((item) => item && item !== '-').join(' / ') || displayLookupKey} + +
+ {[ + { label: '手机号', value: archivePhone || '-' }, + { label: 'OEM', value: archiveOEM || '-' }, + { label: '主来源', value: overview?.primaryProtocol || summary?.primaryProtocol || '-' }, + { label: '最后上报', value: lastSeen }, + { label: '档案完整度', value: archiveCompleteness }, + { label: '下一步', value: serviceActions[0]?.label ?? serviceConclusion.nextStep } + ].map((item) => ( +
+ {item.label} + {item.value} +
+ ))} +
+
+
+ {serviceAssetItems.map((item) => ( +
+ {item.label} + {item.value} + {item.detail} +
+ ))} +
+
+ {archiveMissingLabels.length > 0 ? ( +
+ {archiveMissingLabels.map((label) => ( + {label} + ))} +
+ ) : null} +
+ {overview ? ( { render(); expect(await screen.findByText('车辆服务状态')).toBeInTheDocument(); - expect(screen.getByText('来源不完整')).toBeInTheDocument(); + expect(screen.getAllByText('来源不完整').length).toBeGreaterThan(0); expect(screen.getByText('39')).toBeInTheDocument(); }); @@ -1664,7 +1664,7 @@ test('shows vehicle service result summary on vehicle list filters', async () => expect(screen.getByRole('button', { name: '补齐 YUTONG_MQTT 来源 181' })).toBeInTheDocument(); expect(screen.getByText('档案完整度')).toBeInTheDocument(); expect(screen.getByText('4/4')).toBeInTheDocument(); - expect(screen.getByText('2/4')).toBeInTheDocument(); + expect(screen.getAllByText('2/4').length).toBeGreaterThan(0); expect(screen.getByText('缺OEM')).toBeInTheDocument(); expect(screen.getByText('VIN-MULTI-001')).toBeInTheDocument(); expect(screen.getByText('VIN-MISSING-ARCHIVE')).toBeInTheDocument(); @@ -9643,7 +9643,7 @@ test('shows vehicle service status in vehicle list', async () => { expect(await screen.findByText('VIN-DEGRADED-001')).toBeInTheDocument(); expect(screen.getByText('车辆服务状态')).toBeInTheDocument(); - expect(screen.getByText('来源不完整')).toBeInTheDocument(); + expect(screen.getAllByText('来源不完整').length).toBeGreaterThan(0); }); test('shows source consistency diagnosis in vehicle service list', async () => { @@ -10278,7 +10278,7 @@ test('switches vehicle detail to a source from the source matrix', async () => { render(); - expect(await screen.findByText('VIN001')).toBeInTheDocument(); + expect((await screen.findAllByText('VIN001')).length).toBeGreaterThan(0); expect(screen.getAllByText('GB32960 在线').length).toBeGreaterThanOrEqual(1); expect(screen.getAllByText('JT808 在线').length).toBeGreaterThanOrEqual(1); expect(screen.getAllByText('YUTONG_MQTT 未接入').length).toBeGreaterThanOrEqual(1); @@ -10375,8 +10375,16 @@ test('shows unified service overview on vehicle detail', async () => { render(); expect(await screen.findByText('车辆服务概览')).toBeInTheDocument(); + expect(screen.getByText('车辆服务档案总览')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /复制档案/ })).toBeInTheDocument(); expect(screen.getByText('单车服务作业台')).toBeInTheDocument(); expect(screen.getByText('单车服务闭环Runbook')).toBeInTheDocument(); + expect(screen.getAllByText('档案完整度').length).toBeGreaterThanOrEqual(1); + expect(screen.getByText('实时')).toBeInTheDocument(); + expect(screen.getAllByText('轨迹').length).toBeGreaterThanOrEqual(1); + expect(screen.getAllByText('RAW').length).toBeGreaterThanOrEqual(1); + expect(screen.getAllByText('里程').length).toBeGreaterThanOrEqual(1); + expect(screen.getAllByText('告警').length).toBeGreaterThanOrEqual(1); expect(screen.getByText('实时定位')).toBeInTheDocument(); expect(screen.getAllByText('轨迹回放').length).toBeGreaterThanOrEqual(1); expect(screen.getAllByText('RAW 证据').length).toBeGreaterThan(0); @@ -10880,6 +10888,19 @@ test('copies vehicle service diagnostic summary', async () => { render(); expect(await screen.findByText('车辆服务结论')).toBeInTheDocument(); + expect(screen.getByText('车辆服务档案总览')).toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: /复制档案/ })); + await waitFor(() => { + expect(writeText.mock.calls.some((call) => String(call[0]).includes('【车辆服务档案】'))).toBe(true); + }); + const dossierCopied = String(writeText.mock.lastCall?.[0] ?? ''); + expect(dossierCopied).toContain('【车辆服务档案】'); + expect(dossierCopied).toContain('车辆:粤A诊断1 / VIN-SUMMARY-001'); + expect(dossierCopied).toContain('当前范围:单一来源:JT808'); + expect(dossierCopied).toContain('主来源:JT808'); + expect(dossierCopied).toContain('来源在线:1/2'); + expect(dossierCopied).toContain('资产概览:实时 2 / 轨迹 12 / RAW 34 / 里程 3 / 告警 1'); + expect(dossierCopied).toContain('车辆服务:http://localhost:3000/#/detail?keyword=VIN-SUMMARY-001&protocol=JT808'); fireEvent.click(screen.getByRole('button', { name: /复制诊断摘要/ })); await waitFor(() => { @@ -11541,9 +11562,9 @@ test('shows selected source scope on vehicle detail', async () => { render(); expect(await screen.findByText('当前查看范围')).toBeInTheDocument(); - expect(screen.getByText('单一来源:JT808')).toBeInTheDocument(); + expect(screen.getAllByText('单一来源:JT808').length).toBeGreaterThan(0); expect(screen.getByText('车辆服务状态')).toBeInTheDocument(); - expect(screen.getByText('来源不完整')).toBeInTheDocument(); + expect(screen.getAllByText('来源不完整').length).toBeGreaterThan(0); expect(fetchMock).toHaveBeenCalledWith(expect.stringContaining('/api/vehicle-service?keyword=VIN001&protocol=JT808'), undefined); }); @@ -11940,10 +11961,10 @@ test('shows canonical vehicle archive on detail', async () => { expect((await screen.findAllByText('车辆档案')).length).toBeGreaterThan(0); expect(screen.getByText('车辆主键')).toBeInTheDocument(); expect(screen.getAllByText('VIN-ARCHIVE-001').length).toBeGreaterThan(0); - expect(screen.getByText('档案完整度')).toBeInTheDocument(); - expect(screen.getByText('2/4')).toBeInTheDocument(); - expect(screen.getByText('缺手机号')).toBeInTheDocument(); - expect(screen.getByText('缺OEM')).toBeInTheDocument(); + expect(screen.getAllByText('档案完整度').length).toBeGreaterThan(0); + expect(screen.getAllByText('2/4').length).toBeGreaterThan(0); + expect(screen.getAllByText('缺手机号').length).toBeGreaterThan(0); + expect(screen.getAllByText('缺OEM').length).toBeGreaterThan(0); expect(screen.getByText('归并来源数')).toBeInTheDocument(); expect(screen.getAllByText('3').length).toBeGreaterThan(0); });