feat(platform): flag trajectory playback anomalies
This commit is contained in:
@@ -119,6 +119,53 @@ function formatDurationMinutes(value?: number, suffix = '') {
|
|||||||
return `${value.toLocaleString(undefined, { maximumFractionDigits: 1 })} 分钟${suffix}`;
|
return `${value.toLocaleString(undefined, { maximumFractionDigits: 1 })} 分钟${suffix}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type TrajectoryAnomalySummary = {
|
||||||
|
gapCount: number;
|
||||||
|
maxGapMinutes?: number;
|
||||||
|
mileageRollbackCount: number;
|
||||||
|
maxMileageRollbackKm?: number;
|
||||||
|
overspeedCount: number;
|
||||||
|
maxSpeedKmh?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
function analyzeTrajectoryAnomalies(rows: HistoryLocationRow[]): TrajectoryAnomalySummary {
|
||||||
|
const summary: TrajectoryAnomalySummary = {
|
||||||
|
gapCount: 0,
|
||||||
|
mileageRollbackCount: 0,
|
||||||
|
overspeedCount: 0
|
||||||
|
};
|
||||||
|
let previousTime: number | undefined;
|
||||||
|
let previousMileage: number | undefined;
|
||||||
|
for (const row of rows) {
|
||||||
|
const currentTime = timeValue(row);
|
||||||
|
if (currentTime != null && previousTime != null) {
|
||||||
|
const gapMinutes = Math.abs(currentTime - previousTime) / 60000;
|
||||||
|
if (gapMinutes > 30) {
|
||||||
|
summary.gapCount += 1;
|
||||||
|
summary.maxGapMinutes = summary.maxGapMinutes == null ? gapMinutes : Math.max(summary.maxGapMinutes, gapMinutes);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (currentTime != null) {
|
||||||
|
previousTime = currentTime;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isFiniteNumber(row.totalMileageKm) && previousMileage != null && row.totalMileageKm < previousMileage) {
|
||||||
|
const rollback = previousMileage - row.totalMileageKm;
|
||||||
|
summary.mileageRollbackCount += 1;
|
||||||
|
summary.maxMileageRollbackKm = summary.maxMileageRollbackKm == null ? rollback : Math.max(summary.maxMileageRollbackKm, rollback);
|
||||||
|
}
|
||||||
|
if (isFiniteNumber(row.totalMileageKm)) {
|
||||||
|
previousMileage = row.totalMileageKm;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isFiniteNumber(row.speedKmh) && row.speedKmh > 120) {
|
||||||
|
summary.overspeedCount += 1;
|
||||||
|
summary.maxSpeedKmh = summary.maxSpeedKmh == null ? row.speedKmh : Math.max(summary.maxSpeedKmh, row.speedKmh);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return summary;
|
||||||
|
}
|
||||||
|
|
||||||
function mergeInitialFilters(initialVin: string, initialProtocol?: string, initialFilters: Record<string, string> = {}): HistoryFilters {
|
function mergeInitialFilters(initialVin: string, initialProtocol?: string, initialFilters: Record<string, string> = {}): HistoryFilters {
|
||||||
const hasExplicitFilters = Object.keys(initialFilters).length > 0;
|
const hasExplicitFilters = Object.keys(initialFilters).length > 0;
|
||||||
return {
|
return {
|
||||||
@@ -434,6 +481,8 @@ export function History({
|
|||||||
const playbackEndMs = timeValue(lastLocation);
|
const playbackEndMs = timeValue(lastLocation);
|
||||||
const playbackSpanMinutes = playbackStartMs != null && playbackEndMs != null ? Math.abs(playbackEndMs - playbackStartMs) / 60000 : undefined;
|
const playbackSpanMinutes = playbackStartMs != null && playbackEndMs != null ? Math.abs(playbackEndMs - playbackStartMs) / 60000 : undefined;
|
||||||
const playbackIntervalMinutes = isFiniteNumber(playbackSpanMinutes) && locationItems.length > 1 ? playbackSpanMinutes / (locationItems.length - 1) : undefined;
|
const playbackIntervalMinutes = isFiniteNumber(playbackSpanMinutes) && locationItems.length > 1 ? playbackSpanMinutes / (locationItems.length - 1) : undefined;
|
||||||
|
const trajectoryAnomalies = useMemo(() => analyzeTrajectoryAnomalies(locationItems), [locationItems]);
|
||||||
|
const hasTrajectoryAnomaly = trajectoryAnomalies.gapCount > 0 || trajectoryAnomalies.mileageRollbackCount > 0 || trajectoryAnomalies.overspeedCount > 0;
|
||||||
const currentVehicleKeyword = filters.keyword?.trim() ?? '';
|
const currentVehicleKeyword = filters.keyword?.trim() ?? '';
|
||||||
const currentProtocol = filters.protocol?.trim() ?? '';
|
const currentProtocol = filters.protocol?.trim() ?? '';
|
||||||
const pageTitle = mode === 'query' ? '历史查询' : '轨迹回放';
|
const pageTitle = mode === 'query' ? '历史查询' : '轨迹回放';
|
||||||
@@ -790,6 +839,42 @@ export function History({
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
<Card bordered title="轨迹异常判读" style={{ marginTop: 12 }}>
|
||||||
|
<div className="vp-trajectory-anomaly-grid">
|
||||||
|
{[
|
||||||
|
{
|
||||||
|
label: '数据断点',
|
||||||
|
value: trajectoryAnomalies.gapCount > 0 ? `${trajectoryAnomalies.gapCount.toLocaleString()} 处` : '无',
|
||||||
|
detail: trajectoryAnomalies.gapCount > 0 ? `最大断点 ${formatDurationMinutes(trajectoryAnomalies.maxGapMinutes)}` : '相邻轨迹点未发现超过 30 分钟断点。',
|
||||||
|
color: trajectoryAnomalies.gapCount > 0 ? 'orange' as const : 'green' as const
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: '里程回退',
|
||||||
|
value: trajectoryAnomalies.mileageRollbackCount > 0 ? formatNumber(trajectoryAnomalies.maxMileageRollbackKm, ' km') : '无',
|
||||||
|
detail: trajectoryAnomalies.mileageRollbackCount > 0 ? `${trajectoryAnomalies.mileageRollbackCount.toLocaleString()} 处总里程低于上一点。` : '当前页总里程单调不回退。',
|
||||||
|
color: trajectoryAnomalies.mileageRollbackCount > 0 ? 'red' as const : 'green' as const
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: '速度异常',
|
||||||
|
value: trajectoryAnomalies.overspeedCount > 0 ? formatNumber(trajectoryAnomalies.maxSpeedKmh, ' km/h') : '无',
|
||||||
|
detail: trajectoryAnomalies.overspeedCount > 0 ? `${trajectoryAnomalies.overspeedCount.toLocaleString()} 个点超过 120 km/h。` : '当前页未发现超过 120 km/h 的速度点。',
|
||||||
|
color: trajectoryAnomalies.overspeedCount > 0 ? 'orange' as const : 'green' as const
|
||||||
|
}
|
||||||
|
].map((item) => (
|
||||||
|
<div key={item.label} className="vp-trajectory-anomaly-item">
|
||||||
|
<Tag color={item.color}>{item.label}</Tag>
|
||||||
|
<div className="vp-monitor-metric-value">{item.value}</div>
|
||||||
|
<Typography.Text type="secondary">{item.detail}</Typography.Text>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="vp-trajectory-anomaly-action">
|
||||||
|
<Tag color={hasTrajectoryAnomaly ? 'orange' : 'green'}>{hasTrajectoryAnomaly ? '建议核对 RAW 帧和当日里程统计' : '轨迹质量可用'}</Tag>
|
||||||
|
<Typography.Text type="secondary">
|
||||||
|
{hasTrajectoryAnomaly ? '异常点会影响轨迹回放、定位复盘和区间里程判断,请结合 RAW 解析字段与里程统计交叉确认。' : '当前页轨迹未发现明显断点、里程回退或速度异常。'}
|
||||||
|
</Typography.Text>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
<Space wrap style={{ marginTop: 12 }}>
|
<Space wrap style={{ marginTop: 12 }}>
|
||||||
<Tag color="grey">起点:{firstLocation?.deviceTime || firstLocation?.serverTime || '-'}</Tag>
|
<Tag color="grey">起点:{firstLocation?.deviceTime || firstLocation?.serverTime || '-'}</Tag>
|
||||||
<Tag color="grey">终点:{lastLocation?.deviceTime || lastLocation?.serverTime || '-'}</Tag>
|
<Tag color="grey">终点:{lastLocation?.deviceTime || lastLocation?.serverTime || '-'}</Tag>
|
||||||
|
|||||||
@@ -866,6 +866,35 @@ button.vp-realtime-command-item:focus-visible {
|
|||||||
line-height: 20px;
|
line-height: 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.vp-trajectory-anomaly-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vp-trajectory-anomaly-item {
|
||||||
|
min-height: 112px;
|
||||||
|
padding: 12px;
|
||||||
|
border: 1px solid var(--vp-border);
|
||||||
|
border-radius: var(--vp-radius);
|
||||||
|
background: #fbfcff;
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
align-content: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vp-trajectory-anomaly-action {
|
||||||
|
margin-top: 12px;
|
||||||
|
min-height: 44px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border: 1px solid var(--vp-border);
|
||||||
|
border-radius: var(--vp-radius);
|
||||||
|
background: #f5f9ff;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
.vp-playback-layout {
|
.vp-playback-layout {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(0, 1fr) 260px;
|
grid-template-columns: minmax(0, 1fr) 260px;
|
||||||
@@ -1233,6 +1262,7 @@ button.vp-realtime-command-item:focus-visible {
|
|||||||
.vp-stat-workspace,
|
.vp-stat-workspace,
|
||||||
.vp-stat-insight-grid,
|
.vp-stat-insight-grid,
|
||||||
.vp-stat-definition-grid,
|
.vp-stat-definition-grid,
|
||||||
|
.vp-trajectory-anomaly-grid,
|
||||||
.vp-vehicle-map-layout,
|
.vp-vehicle-map-layout,
|
||||||
.vp-playback-layout,
|
.vp-playback-layout,
|
||||||
.vp-playback-timeline {
|
.vp-playback-timeline {
|
||||||
|
|||||||
@@ -5737,6 +5737,54 @@ test('shows trajectory evidence quality on history playback workspace', async ()
|
|||||||
expect(screen.getByText('10 分钟/点')).toBeInTheDocument();
|
expect(screen.getByText('10 分钟/点')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('flags trajectory playback anomalies for gap mileage and speed review', async () => {
|
||||||
|
window.history.replaceState(null, '', '/#/history?keyword=VIN-TRACK-ANOMALY&protocol=JT808');
|
||||||
|
vi.spyOn(globalThis, 'fetch').mockImplementation(async (input, init) => {
|
||||||
|
const path = String(input);
|
||||||
|
if (path.includes('/api/history/locations')) {
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({
|
||||||
|
data: {
|
||||||
|
items: [
|
||||||
|
{ vin: 'VIN-TRACK-ANOMALY', plate: '粤A轨迹异', protocol: 'JT808', longitude: 113.2, latitude: 23.1, speedKmh: 18, socPercent: 0, totalMileageKm: 300, lastSeen: '2026-07-03 10:00:00', deviceTime: '2026-07-03 10:00:00', serverTime: '2026-07-03 10:00:01' },
|
||||||
|
{ vin: 'VIN-TRACK-ANOMALY', plate: '粤A轨迹异', protocol: 'JT808', longitude: 113.3, latitude: 23.2, speedKmh: 132, socPercent: 0, totalMileageKm: 318, lastSeen: '2026-07-03 10:40:00', deviceTime: '2026-07-03 10:40:00', serverTime: '2026-07-03 10:40:01' },
|
||||||
|
{ vin: 'VIN-TRACK-ANOMALY', plate: '粤A轨迹异', protocol: 'JT808', longitude: 113.4, latitude: 23.3, speedKmh: 22, socPercent: 0, totalMileageKm: 316, lastSeen: '2026-07-03 10:45:00', deviceTime: '2026-07-03 10:45:00', serverTime: '2026-07-03 10:45:01' }
|
||||||
|
],
|
||||||
|
total: 3,
|
||||||
|
limit: 10,
|
||||||
|
offset: 0
|
||||||
|
},
|
||||||
|
traceId: 'trace-test',
|
||||||
|
timestamp: 1783094400000
|
||||||
|
})
|
||||||
|
} as Response;
|
||||||
|
}
|
||||||
|
if (path.includes('/api/history/raw-frames/query')) {
|
||||||
|
expect(init?.method).toBe('POST');
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({
|
||||||
|
data: { items: [], total: 0, limit: 10, offset: 0 },
|
||||||
|
traceId: 'trace-test',
|
||||||
|
timestamp: 1783094400000
|
||||||
|
})
|
||||||
|
} as Response;
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<App />);
|
||||||
|
|
||||||
|
expect(await screen.findByText('轨迹异常判读')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('数据断点')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('1 处')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('里程回退')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('2 km')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('速度异常')).toBeInTheDocument();
|
||||||
|
expect(screen.getAllByText('132 km/h').length).toBeGreaterThan(0);
|
||||||
|
expect(screen.getByText('建议核对 RAW 帧和当日里程统计')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
test('opens amap route from trajectory playback workspace', async () => {
|
test('opens amap route from trajectory playback workspace', async () => {
|
||||||
window.history.replaceState(null, '', '/#/history?keyword=VIN-TRACK-AMAP&protocol=JT808');
|
window.history.replaceState(null, '', '/#/history?keyword=VIN-TRACK-AMAP&protocol=JT808');
|
||||||
const openSpy = vi.spyOn(window, 'open').mockImplementation(() => null);
|
const openSpy = vi.spyOn(window, 'open').mockImplementation(() => null);
|
||||||
|
|||||||
Reference in New Issue
Block a user