feat(platform): copy vehicle diagnostic summary

This commit is contained in:
lingniu
2026-07-04 13:52:57 +08:00
parent c4d25593c1
commit 1365ce2a30
2 changed files with 184 additions and 4 deletions

View File

@@ -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<string, string>) => 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 <Tag color="green"></Tag>;
@@ -404,6 +444,7 @@ export function VehicleDetail({
<Button icon={<IconSearch />} htmlType="submit" theme="solid" type="primary"></Button>
<Button icon={<IconRefresh />} onClick={() => load(query)} loading={loading}></Button>
<Button icon={<IconCopy />} onClick={copyVehicleServiceURL}></Button>
<Button disabled={!detail} icon={<IconCopy />} onClick={copyVehicleDiagnosticSummary}></Button>
<Button disabled={!detail} icon={<IconDownload />} onClick={exportVehicleReport}> CSV</Button>
<Button disabled={!hasResolvedVIN} onClick={() => onOpenRealtime(resolvedVIN, activeProtocol)}></Button>
<Button disabled={!hasResolvedVIN} onClick={() => onOpenHistory(resolvedVIN, activeProtocol)}></Button>

View File

@@ -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<void>>(() => 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(<App />);
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('RAWhttp://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');