diff --git a/vehicle-data-platform/apps/web/src/pages/History.tsx b/vehicle-data-platform/apps/web/src/pages/History.tsx index 0601fb77..a3612a02 100644 --- a/vehicle-data-platform/apps/web/src/pages/History.tsx +++ b/vehicle-data-platform/apps/web/src/pages/History.tsx @@ -407,6 +407,60 @@ function trajectoryReviewPackageText({ ].join('\n'); } +function trajectoryImpactPackageText({ + filters, + locationCount, + rawCount, + fieldCount, + validPointCount, + coverageRate, + playbackSpanMinutes, + playbackIntervalMinutes, + mileageDelta, + maxSpeed, + anomalySummary, + amapConfigured +}: { + filters: HistoryFilters; + locationCount: number; + rawCount: number; + fieldCount: number; + validPointCount: number; + coverageRate?: number; + playbackSpanMinutes?: number; + playbackIntervalMinutes?: number; + mileageDelta?: number; + maxSpeed?: number; + anomalySummary: TrajectoryAnomalySummary; + amapConfigured: boolean; +}) { + 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 anomalyTotal = anomalySummary.gapCount + anomalySummary.mileageRollbackCount + anomalySummary.overspeedCount; + const operationState = anomalyTotal > 0 || (coverageRate ?? 100) < 80 ? '需要复核' : '可用于业务回放'; + return [ + '【轨迹运营影响】', + `车辆:${vehicle || '全部车辆'}`, + `数据来源:${protocol || '全部来源'}`, + `查询范围:${filters.dateFrom?.trim() || '-'} 至 ${filters.dateTo?.trim() || '-'}`, + `业务状态:${operationState}`, + `轨迹范围:位置 ${locationCount.toLocaleString()} / 有效定位 ${validPointCount.toLocaleString()} / RAW ${rawCount.toLocaleString()} / 字段 ${fieldCount.toLocaleString()}`, + `定位覆盖率:${formatPercent(coverageRate)}`, + `回放跨度:${formatDurationMinutes(playbackSpanMinutes)},采样间隔:${formatDurationMinutes(playbackIntervalMinutes, '/点')}`, + `里程速度:${formatNumber(mileageDelta, ' km')} / ${formatNumber(maxSpeed, ' km/h')}`, + `异常影响:断点 ${anomalySummary.gapCount.toLocaleString()} / 里程回退 ${anomalySummary.mileageRollbackCount.toLocaleString()} / 超速 ${anomalySummary.overspeedCount.toLocaleString()}`, + `地图能力:${amapConfigured ? '高德 JS API 已配置' : '高德 JS API 待配置'}`, + `轨迹回放:${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: 'detail', keyword: vehicle, protocol }))}` + ].join('\n'); +} + function amapLocationName(row: HistoryLocationRow, fallback: string) { return encodeURIComponent(row.plate || row.vin || fallback); } @@ -728,6 +782,25 @@ export function History({ '轨迹复盘交接包' ); }; + const copyTrajectoryImpactPackage = () => { + copyText( + trajectoryImpactPackageText({ + filters, + locationCount: locations.total ?? 0, + rawCount: rawFrames.total ?? 0, + fieldCount: rawFieldRows.length, + validPointCount: validLocations.length, + coverageRate: trajectoryCoverageRate, + playbackSpanMinutes, + playbackIntervalMinutes, + mileageDelta, + maxSpeed, + anomalySummary: trajectoryAnomalies, + amapConfigured + }), + '轨迹运营影响' + ); + }; const openAmapTrajectory = () => { const url = amapTrajectoryURL(validLocations); if (!url) { @@ -886,6 +959,67 @@ export function History({ ) : null} + +
+
+ + + {hasTrajectoryAnomaly ? '需要复核' : '可用于业务回放'} + + {amapConfigured ? '地图已接入' : '地图待接入'} + + {currentVehicleKeyword || '全部车辆'} + + 轨迹、历史查询、RAW 证据和里程复核按同一车辆范围联动,用于调度复盘、客户问询和断链定位。 + + + + + + +
+
+ {[ + { + label: '轨迹范围', + value: `${locations.total.toLocaleString()} 点`, + detail: `${validLocations.length.toLocaleString()} 个有效坐标,当前页覆盖可回放轨迹。`, + color: 'blue' as const + }, + { + label: '定位覆盖', + value: formatPercent(trajectoryCoverageRate), + detail: `回放跨度 ${formatDurationMinutes(playbackSpanMinutes)},采样 ${formatDurationMinutes(playbackIntervalMinutes, '/点')}。`, + color: (trajectoryCoverageRate ?? 0) >= 80 ? 'green' as const : 'orange' as const + }, + { + label: '异常影响', + value: `${(trajectoryAnomalies.gapCount + trajectoryAnomalies.mileageRollbackCount + trajectoryAnomalies.overspeedCount).toLocaleString()} 项`, + detail: `断点 ${trajectoryAnomalies.gapCount.toLocaleString()},里程回退 ${trajectoryAnomalies.mileageRollbackCount.toLocaleString()},超速 ${trajectoryAnomalies.overspeedCount.toLocaleString()}。`, + color: hasTrajectoryAnomaly ? 'orange' as const : 'green' as const + }, + { + label: 'RAW证据', + value: `${(rawFrames.total ?? 0).toLocaleString()} 帧`, + detail: rawFieldRows.length > 0 ? `${rawFieldRows.length.toLocaleString()} 个解析字段可直接导出。` : '可切换 RAW 帧并请求解析字段。', + color: (rawFrames.total ?? 0) > 0 ? 'blue' as const : 'grey' as const + }, + { + label: '地图能力', + value: amapConfigured ? '高德可用' : '待配置', + detail: amapConfigured ? '可打开高德线路并解析当前回放点地址。' : '配置高德 Web 服务和 JS API 后启用地图能力。', + color: amapConfigured ? 'green' as const : 'orange' as const + } + ].map((item) => ( +
+ {item.label} + {item.value} + {item.detail} +
+ ))} +
+
+
{ 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('数据来源:JT808')); + expect(writeText).toHaveBeenCalledWith(expect.stringContaining('业务状态:需要复核')); + expect(writeText).toHaveBeenCalledWith(expect.stringContaining('轨迹范围:位置 2 / 有效定位 2 / RAW 1 / 字段 2')); + expect(writeText).toHaveBeenCalledWith(expect.stringContaining('定位覆盖率:100%')); + expect(writeText).toHaveBeenCalledWith(expect.stringContaining('回放跨度:60 分钟,采样间隔:60 分钟/点')); + expect(writeText).toHaveBeenCalledWith(expect.stringContaining('异常影响:断点 1 / 里程回退 0 / 超速 0')); + expect(writeText).toHaveBeenCalledWith(expect.stringContaining('地图能力:高德 JS API 待配置')); + expect(writeText).toHaveBeenCalledWith(expect.stringContaining('历史查询:http://localhost:3000/#/history-query?keyword=VIN-HISTORY-SUMMARY&protocol=JT808&tab=location&dateFrom=2026-07-03&dateTo=2026-07-04')); + 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')); + fireEvent.click(screen.getByRole('button', { name: /复制轨迹复盘包/ })); expect(writeText).toHaveBeenCalledWith(expect.stringContaining('【轨迹复盘交接包】')); @@ -6045,6 +6059,12 @@ test('shows trajectory playback workspace from history locations', async () => { render(); expect(await screen.findByRole('heading', { name: '轨迹回放' })).toBeInTheDocument(); + expect(screen.getByText('轨迹运营影响')).toBeInTheDocument(); + expect(screen.getByText('轨迹范围')).toBeInTheDocument(); + expect(screen.getByText('定位覆盖')).toBeInTheDocument(); + expect(screen.getByText('异常影响')).toBeInTheDocument(); + expect(screen.getByText('RAW证据')).toBeInTheDocument(); + expect(screen.getByText('地图能力')).toBeInTheDocument(); expect(screen.getByText('轨迹回放作业台')).toBeInTheDocument(); expect(screen.getByText('2 个有效轨迹点')).toBeInTheDocument(); expect(screen.getByText('区间里程')).toBeInTheDocument(); @@ -6094,7 +6114,7 @@ test('shows trajectory evidence quality on history playback workspace', async () expect(await screen.findByText('轨迹证据质量')).toBeInTheDocument(); expect(screen.getByText('定位覆盖率')).toBeInTheDocument(); - expect(screen.getByText('100%')).toBeInTheDocument(); + expect(screen.getAllByText('100%').length).toBeGreaterThan(0); expect(screen.getByText('回放跨度')).toBeInTheDocument(); expect(screen.getByText('10 分钟')).toBeInTheDocument(); expect(screen.getByText('采样间隔')).toBeInTheDocument();