feat(platform): add trajectory playback workspace
This commit is contained in:
@@ -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">
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
Reference in New Issue
Block a user