diff --git a/vehicle-data-platform/apps/web/src/pages/History.tsx b/vehicle-data-platform/apps/web/src/pages/History.tsx
index 37677f31..7d5cece3 100644
--- a/vehicle-data-platform/apps/web/src/pages/History.tsx
+++ b/vehicle-data-platform/apps/web/src/pages/History.tsx
@@ -335,6 +335,75 @@ function historyEvidencePackageText({
].join('\n');
}
+function trajectoryReviewPackageText({
+ filters,
+ locationCount,
+ rawCount,
+ fieldCount,
+ validPointCount,
+ coverageRate,
+ playbackSpanMinutes,
+ playbackIntervalMinutes,
+ mileageDelta,
+ maxSpeed,
+ firstLocation,
+ lastLocation,
+ currentPlayback,
+ currentPlaybackIndex,
+ playbackCount,
+ anomalySummary
+}: {
+ filters: HistoryFilters;
+ locationCount: number;
+ rawCount: number;
+ fieldCount: number;
+ validPointCount: number;
+ coverageRate?: number;
+ playbackSpanMinutes?: number;
+ playbackIntervalMinutes?: number;
+ mileageDelta?: number;
+ maxSpeed?: number;
+ firstLocation?: HistoryLocationRow;
+ lastLocation?: HistoryLocationRow;
+ currentPlayback?: HistoryLocationRow;
+ currentPlaybackIndex: number;
+ playbackCount: number;
+ anomalySummary: TrajectoryAnomalySummary;
+}) {
+ const vehicle = filters.keyword?.trim() || '';
+ const protocol = filters.protocol?.trim() || '';
+ const evidenceFilters = {
+ ...(filters.dateFrom?.trim() ? { dateFrom: filters.dateFrom.trim() } : {}),
+ ...(filters.dateTo?.trim() ? { dateTo: filters.dateTo.trim() } : {})
+ };
+ const anomalyAction = anomalySummary.gapCount > 0 || anomalySummary.mileageRollbackCount > 0 || anomalySummary.overspeedCount > 0
+ ? '建议核对 RAW 帧、解析字段、当日里程统计,并确认平台是否存在断链或补发。'
+ : '当前页未发现明显断点、里程回退或速度异常,可作为轨迹证据使用。';
+ return [
+ '【轨迹复盘交接包】',
+ `车辆:${vehicle || '全部车辆'}`,
+ `数据来源:${protocol || '全部来源'}`,
+ `查询范围:${filters.dateFrom?.trim() || '-'} 至 ${filters.dateTo?.trim() || '-'}`,
+ `轨迹规模:位置 ${locationCount.toLocaleString()} / 有效定位 ${validPointCount.toLocaleString()} / RAW ${rawCount.toLocaleString()} / 解析字段 ${fieldCount.toLocaleString()}`,
+ `轨迹质量:定位覆盖率 ${formatPercent(coverageRate)} / 回放跨度 ${formatDurationMinutes(playbackSpanMinutes)} / 采样间隔 ${formatDurationMinutes(playbackIntervalMinutes, '/点')}`,
+ `里程速度:区间里程 ${formatNumber(mileageDelta, ' km')} / 最高速度 ${formatNumber(maxSpeed, ' km/h')}`,
+ `起点:${firstLocation?.deviceTime || firstLocation?.serverTime || '-'}`,
+ `终点:${lastLocation?.deviceTime || lastLocation?.serverTime || '-'}`,
+ `当前回放点:${currentPlayback ? `点 ${currentPlaybackIndex + 1}/${playbackCount},${currentPlayback.deviceTime || currentPlayback.serverTime || '-'},${formatNumber(currentPlayback.speedKmh, ' km/h')},${formatNumber(currentPlayback.totalMileageKm, ' km')}` : '-'}`,
+ `异常判读:断点 ${anomalySummary.gapCount.toLocaleString()} / 里程回退 ${anomalySummary.mileageRollbackCount.toLocaleString()} / 超速 ${anomalySummary.overspeedCount.toLocaleString()}`,
+ `最大断点:${formatDurationMinutes(anomalySummary.maxGapMinutes)}`,
+ `最大里程回退:${formatNumber(anomalySummary.maxMileageRollbackKm, ' km')}`,
+ `最高异常速度:${formatNumber(anomalySummary.maxSpeedKmh, ' km/h')}`,
+ `下一步:${anomalyAction}`,
+ `轨迹回放:${appURL(buildAppHash({ page: 'history', keyword: vehicle, protocol, filters: evidenceFilters }))}`,
+ `历史位置:${appURL(buildAppHash({ page: 'history-query', keyword: vehicle, protocol, filters: { ...evidenceFilters, tab: 'location' } }))}`,
+ `RAW证据:${appURL(buildAppHash({ page: 'history-query', keyword: vehicle, protocol, filters: { ...evidenceFilters, tab: 'raw', includeFields: 'true' } }))}`,
+ `解析字段:${appURL(buildAppHash({ page: 'history-query', keyword: vehicle, protocol, filters: { ...evidenceFilters, tab: 'fields', includeFields: 'true', ...(filters.fields?.trim() ? { fields: filters.fields.trim() } : {}) } }))}`,
+ `里程复核:${appURL(buildAppHash({ page: 'mileage', keyword: vehicle, protocol, filters: evidenceFilters }))}`,
+ `车辆服务:${appURL(buildAppHash({ page: 'detail', keyword: vehicle, protocol }))}`
+ ].join('\n');
+}
+
function amapLocationName(row: HistoryLocationRow, fallback: string) {
return encodeURIComponent(row.plate || row.vin || fallback);
}
@@ -628,6 +697,29 @@ export function History({
'历史证据包'
);
};
+ const copyTrajectoryReviewPackage = () => {
+ copyText(
+ trajectoryReviewPackageText({
+ filters,
+ locationCount: locations.total ?? 0,
+ rawCount: rawFrames.total ?? 0,
+ fieldCount: rawFieldRows.length,
+ validPointCount: validLocations.length,
+ coverageRate: trajectoryCoverageRate,
+ playbackSpanMinutes,
+ playbackIntervalMinutes,
+ mileageDelta,
+ maxSpeed,
+ firstLocation,
+ lastLocation,
+ currentPlayback,
+ currentPlaybackIndex,
+ playbackCount: playbackRows.length,
+ anomalySummary: trajectoryAnomalies
+ }),
+ '轨迹复盘交接包'
+ );
+ };
const openAmapTrajectory = () => {
const url = amapTrajectoryURL(validLocations);
if (!url) {
@@ -771,6 +863,7 @@ export function History({
轨迹回放作业台
} onClick={copyTrajectorySummary}>复制轨迹摘要
} onClick={copyHistoryEvidencePackage}>复制历史证据包
+ } onClick={copyTrajectoryReviewPackage}>复制轨迹复盘包
)}
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 e5b4dd5f..c50ccb6e 100644
--- a/vehicle-data-platform/apps/web/src/test/App.test.tsx
+++ b/vehicle-data-platform/apps/web/src/test/App.test.tsx
@@ -4798,6 +4798,18 @@ test('copies trajectory playback summary from history page', async () => {
expect(writeText).toHaveBeenCalledWith(expect.stringContaining('解析字段:http://localhost:3000/#/history-query?keyword=VIN-HISTORY-SUMMARY&protocol=JT808&tab=fields&dateFrom=2026-07-03&dateTo=2026-07-04&includeFields=true'));
expect(writeText).toHaveBeenCalledWith(expect.stringContaining('里程复核:http://localhost:3000/#/mileage?keyword=VIN-HISTORY-SUMMARY&protocol=JT808&dateFrom=2026-07-03&dateTo=2026-07-04'));
expect(writeText).toHaveBeenCalledWith(expect.stringContaining('车辆服务:http://localhost:3000/#/detail?keyword=VIN-HISTORY-SUMMARY&protocol=JT808'));
+
+ fireEvent.click(screen.getByRole('button', { name: /复制轨迹复盘包/ }));
+
+ expect(writeText).toHaveBeenCalledWith(expect.stringContaining('【轨迹复盘交接包】'));
+ expect(writeText).toHaveBeenCalledWith(expect.stringContaining('车辆:VIN-HISTORY-SUMMARY'));
+ expect(writeText).toHaveBeenCalledWith(expect.stringContaining('轨迹规模:位置 2 / 有效定位 2 / RAW 1 / 解析字段 2'));
+ expect(writeText).toHaveBeenCalledWith(expect.stringContaining('轨迹质量:定位覆盖率 100% / 回放跨度 60 分钟 / 采样间隔 60 分钟/点'));
+ expect(writeText).toHaveBeenCalledWith(expect.stringContaining('里程速度:区间里程 22.6 km / 最高速度 58.8 km/h'));
+ expect(writeText).toHaveBeenCalledWith(expect.stringContaining('当前回放点:点 1/2,2026-07-03 10:00:00,12.5 km/h,1,000 km'));
+ expect(writeText).toHaveBeenCalledWith(expect.stringContaining('异常判读:断点 1 / 里程回退 0 / 超速 0'));
+ expect(writeText).toHaveBeenCalledWith(expect.stringContaining('下一步:建议核对 RAW 帧、解析字段、当日里程统计,并确认平台是否存在断链或补发。'));
+ expect(writeText).toHaveBeenCalledWith(expect.stringContaining('RAW证据:http://localhost:3000/#/history-query?keyword=VIN-HISTORY-SUMMARY&protocol=JT808&tab=raw&dateFrom=2026-07-03&dateTo=2026-07-04&includeFields=true'));
});
test('opens same-day mileage statistics from quality issue row', async () => {