feat(platform): add trajectory playback workspace

This commit is contained in:
lingniu
2026-07-04 10:38:10 +08:00
parent 89c4fffd5d
commit be5a0cf92c
3 changed files with 200 additions and 5 deletions

View File

@@ -43,6 +43,28 @@ function isIncludeFieldsEnabled(value?: boolean | string) {
return value === true || value === 'true';
}
function isFiniteNumber(value: unknown): value is number {
return typeof value === 'number' && Number.isFinite(value);
}
function hasValidCoordinate(row: HistoryLocationRow) {
return isFiniteNumber(row.longitude) && isFiniteNumber(row.latitude) && row.longitude !== 0 && row.latitude !== 0;
}
function mapPointStyle(row: HistoryLocationRow, index: number) {
if (!hasValidCoordinate(row)) {
return { left: `${18 + (index % 4) * 18}%`, top: `${28 + (index % 3) * 17}%` };
}
const left = Math.min(88, Math.max(10, ((row.longitude + 180) / 360) * 100));
const top = Math.min(82, Math.max(12, ((90 - row.latitude) / 180) * 100));
return { left: `${left}%`, top: `${top}%` };
}
function formatNumber(value?: number, suffix = '') {
if (!isFiniteNumber(value)) return '-';
return `${value.toLocaleString(undefined, { maximumFractionDigits: 1 })}${suffix}`;
}
function mergeInitialFilters(initialVin: string, initialProtocol?: string, initialFilters: Record<string, string> = {}): HistoryFilters {
return {
...defaultFilters,
@@ -166,6 +188,13 @@ export function History({
};
const rawFieldCount = useMemo(() => Object.keys(selectedRaw?.parsedFields ?? {}).length, [selectedRaw]);
const locationItems = locations.items;
const validLocations = locationItems.filter(hasValidCoordinate);
const mileageValues = locationItems.map((item) => item.totalMileageKm).filter(isFiniteNumber);
const mileageDelta = mileageValues.length > 1 ? Math.max(...mileageValues) - Math.min(...mileageValues) : undefined;
const maxSpeed = locationItems.map((item) => item.speedKmh).filter(isFiniteNumber).reduce<number | undefined>((max, item) => max == null ? item : Math.max(max, item), undefined);
const firstLocation = locationItems[0];
const lastLocation = locationItems[locationItems.length - 1];
const currentVehicleKeyword = filters.keyword?.trim() ?? '';
const currentProtocol = filters.protocol?.trim() ?? '';
const selectedFieldCount = splitFields(filters.fields).length;
@@ -184,8 +213,8 @@ export function History({
return (
<div className="vp-page">
<PageHeader
title="历史数据"
description="按车辆查询位置历史和 RAW 帧历史,数据来源只作为过滤和诊断维度"
title="轨迹回放"
description="按车辆查询历史位置、轨迹回放和 RAW 帧证据,数据来源只作为过滤和诊断维度"
actions={(
<Button disabled={!currentVehicleKeyword} onClick={() => onOpenVehicle(currentVehicleKeyword, currentProtocol)}>
@@ -233,6 +262,63 @@ export function History({
</Space>
</Card>
) : null}
<Card bordered title="轨迹回放作业台" style={{ marginTop: 16 }}>
<div className="vp-playback-layout">
<div className="vp-playback-map">
<div className="vp-monitor-map-header">
<Space wrap>
<Tag color="blue">{validLocations.length.toLocaleString()} </Tag>
<Tag color={currentProtocol ? 'blue' : 'green'}>{currentProtocol || '全部来源'}</Tag>
<Tag color="green">{formatNumber(mileageDelta, ' km')}</Tag>
</Space>
</div>
<div className="vp-map vp-map-monitor">
{validLocations.slice(0, 120).map((row, index) => (
<span
key={`${row.vin}-${row.deviceTime}-${index}`}
className={`vp-map-dot ${index === 0 ? 'vp-map-dot-start' : index === validLocations.length - 1 ? 'vp-map-dot-end' : 'vp-map-dot-online'}`}
title={`${row.deviceTime} ${row.speedKmh ?? '-'} km/h`}
style={mapPointStyle(row, index)}
/>
))}
</div>
</div>
<div className="vp-playback-side">
{[
{ label: '轨迹点', value: locations.total.toLocaleString(), color: 'blue' as const },
{ label: '有效定位', value: validLocations.length.toLocaleString(), color: 'green' as const },
{ label: '区间里程', value: formatNumber(mileageDelta, ' km'), color: 'green' as const },
{ label: '最高速度', value: formatNumber(maxSpeed, ' km/h'), color: 'orange' as const }
].map((item) => (
<div key={item.label} className="vp-monitor-metric">
<Tag color={item.color}>{item.label}</Tag>
<div className="vp-monitor-metric-value">{item.value}</div>
</div>
))}
</div>
</div>
<div className="vp-playback-timeline">
{(validLocations.length > 0 ? validLocations : locationItems).slice(0, 8).map((row, index) => (
<div key={`${row.deviceTime}-${index}`} className="vp-playback-step">
<Tag color={index === 0 ? 'green' : index === Math.min((validLocations.length || locationItems.length), 8) - 1 ? 'orange' : 'blue'}>
{index === 0 ? '起点' : index === Math.min((validLocations.length || locationItems.length), 8) - 1 ? '终点' : `${index + 1}`}
</Tag>
<div className="vp-evidence-value">{row.deviceTime || row.serverTime || '-'}</div>
<Typography.Text type="secondary">
{formatNumber(row.speedKmh, ' km/h')} / {formatNumber(row.totalMileageKm, ' km')}
</Typography.Text>
</div>
))}
{locationItems.length === 0 ? (
<Typography.Text type="secondary"></Typography.Text>
) : null}
</div>
<Space wrap style={{ marginTop: 12 }}>
<Tag color="grey">{firstLocation?.deviceTime || firstLocation?.serverTime || '-'}</Tag>
<Tag color="grey">{lastLocation?.deviceTime || lastLocation?.serverTime || '-'}</Tag>
<Tag color="grey"> JS API </Tag>
</Space>
</Card>
<Card bordered style={{ marginTop: 16 }}>
<Tabs activeKey={activeTab} onChange={(key) => changeTab(String(key))}>
<Tabs.TabPane tab="位置历史" itemKey="location">

View File

@@ -297,6 +297,20 @@ body {
box-shadow: 0 0 0 5px rgba(247, 144, 9, 0.16);
}
.vp-map-dot-start {
width: 12px;
height: 12px;
background: var(--vp-success);
box-shadow: 0 0 0 6px rgba(18, 183, 106, 0.18);
}
.vp-map-dot-end {
width: 12px;
height: 12px;
background: var(--vp-warning);
box-shadow: 0 0 0 6px rgba(247, 144, 9, 0.18);
}
.vp-monitor-side {
display: grid;
gap: 12px;
@@ -336,6 +350,41 @@ body {
align-content: start;
}
.vp-playback-layout {
display: grid;
grid-template-columns: minmax(0, 1fr) 260px;
gap: 16px;
}
.vp-playback-map {
min-height: 430px;
border: 1px solid var(--vp-border);
border-radius: var(--vp-radius);
overflow: hidden;
background: var(--vp-surface);
}
.vp-playback-side {
display: grid;
gap: 12px;
align-content: start;
}
.vp-playback-timeline {
margin-top: 16px;
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 12px;
}
.vp-playback-step {
min-height: 112px;
padding: 12px;
border: 1px solid var(--vp-border);
border-radius: var(--vp-radius);
background: #fbfcff;
}
.vp-json {
margin: 0;
padding: 16px;
@@ -472,7 +521,9 @@ body {
.vp-action-grid,
.vp-conclusion-grid,
.vp-monitor-layout,
.vp-alert-flow {
.vp-alert-flow,
.vp-playback-layout,
.vp-playback-timeline {
grid-template-columns: 1fr;
}
}

View File

@@ -2878,9 +2878,9 @@ test('updates history hash when vehicle history filters are submitted', async ()
render(<App />);
await screen.findByRole('heading', { name: '历史数据' });
await screen.findByRole('heading', { name: '轨迹回放' });
fireEvent.change(screen.getByPlaceholderText('VIN / 车牌 / 手机号'), { target: { value: '粤AG18312' } });
fireEvent.click(screen.getByText('全部来源'));
fireEvent.click(screen.getAllByText('全部来源')[0]);
fireEvent.click(await screen.findByText('JT808'));
fireEvent.change(screen.getByPlaceholderText('2026-07-03 00:00:00'), { target: { value: '2026-07-01 00:00:00' } });
fireEvent.change(screen.getByPlaceholderText('2026-07-03 23:59:59'), { target: { value: '2026-07-01 23:59:59' } });
@@ -2897,6 +2897,64 @@ test('updates history hash when vehicle history filters are submitted', async ()
}));
});
test('shows trajectory playback workspace from history locations', async () => {
window.history.replaceState(null, '', '/#/history?keyword=VIN-TRACK-001&protocol=JT808');
vi.spyOn(globalThis, 'fetch').mockImplementation(async (input, init) => {
const path = String(input);
if (path.includes('/api/ops/health')) {
return {
ok: true,
json: async () => ({
data: { linkHealth: [], kafkaLag: 0, redisOnlineKeys: 0, tdengineWritable: true, mysqlWritable: true, runtime: { requestTimeoutMs: 5000 } },
traceId: 'trace-test',
timestamp: 1783094400000
})
} as Response;
}
if (path.includes('/api/history/locations')) {
return {
ok: true,
json: async () => ({
data: {
items: [
{ vin: 'VIN-TRACK-001', plate: '粤A轨迹1', protocol: 'JT808', longitude: 113.2, latitude: 23.1, speedKmh: 10, socPercent: 0, totalMileageKm: 100, lastSeen: '2026-07-03 10:00:00', deviceTime: '2026-07-03 10:00:00', serverTime: '2026-07-03 10:00:01' },
{ vin: 'VIN-TRACK-001', plate: '粤A轨迹1', protocol: 'JT808', longitude: 113.3, latitude: 23.2, speedKmh: 42, socPercent: 0, totalMileageKm: 112.4, lastSeen: '2026-07-03 10:10:00', deviceTime: '2026-07-03 10:10:00', serverTime: '2026-07-03 10:10:01' }
],
total: 2,
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.findByRole('heading', { name: '轨迹回放' })).toBeInTheDocument();
expect(screen.getByText('轨迹回放作业台')).toBeInTheDocument();
expect(screen.getByText('2 个有效轨迹点')).toBeInTheDocument();
expect(screen.getByText('区间里程')).toBeInTheDocument();
expect(screen.getAllByText('12.4 km').length).toBeGreaterThan(0);
expect(screen.getByText('最高速度')).toBeInTheDocument();
expect(screen.getByText('42 km/h')).toBeInTheDocument();
expect(screen.getByText('起点')).toBeInTheDocument();
expect(screen.getByText('终点')).toBeInTheDocument();
});
test('shows vehicle and source scope on mileage hash', async () => {
window.history.replaceState(null, '', '/#/mileage?keyword=VIN-MILEAGE-001&protocol=GB32960');
vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => {