feat(platform): copy trajectory playback summary
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import { Button, Card, Form, Select, SideSheet, Space, Table, Tabs, Tag, Toast, Typography } from '@douyinfe/semi-ui';
|
||||
import { IconRefresh, IconSearch } from '@douyinfe/semi-icons';
|
||||
import { IconCopy, IconRefresh, IconSearch } from '@douyinfe/semi-icons';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { api, type RawFrameQuery } from '../api/client';
|
||||
import type { HistoryLocationRow, Page, RawFrameRow } from '../api/types';
|
||||
@@ -125,6 +125,58 @@ function exportFileName(prefix: string, filters: HistoryFilters) {
|
||||
return `${prefix}-${keyword}-${protocol}.csv`;
|
||||
}
|
||||
|
||||
function trajectorySummaryText({
|
||||
filters,
|
||||
totalPoints,
|
||||
validPointCount,
|
||||
mileageDelta,
|
||||
maxSpeed,
|
||||
firstLocation,
|
||||
lastLocation
|
||||
}: {
|
||||
filters: HistoryFilters;
|
||||
totalPoints: number;
|
||||
validPointCount: number;
|
||||
mileageDelta?: number;
|
||||
maxSpeed?: number;
|
||||
firstLocation?: HistoryLocationRow;
|
||||
lastLocation?: HistoryLocationRow;
|
||||
}) {
|
||||
const vehicle = filters.keyword?.trim() || '全部车辆';
|
||||
const protocol = filters.protocol?.trim() || '全部来源';
|
||||
const dateFrom = filters.dateFrom?.trim();
|
||||
const dateTo = filters.dateTo?.trim();
|
||||
const range = dateFrom || dateTo ? `${dateFrom || '-'} 至 ${dateTo || '-'}` : '全部时间';
|
||||
const startTime = firstLocation?.deviceTime || firstLocation?.serverTime || '-';
|
||||
const endTime = lastLocation?.deviceTime || lastLocation?.serverTime || '-';
|
||||
return [
|
||||
'【轨迹回放摘要】',
|
||||
`车辆:${vehicle}`,
|
||||
`数据来源:${protocol}`,
|
||||
`查询范围:${range}`,
|
||||
`轨迹点:${totalPoints.toLocaleString()},有效定位:${validPointCount.toLocaleString()}`,
|
||||
`区间里程:${formatNumber(mileageDelta, ' km')}`,
|
||||
`最高速度:${formatNumber(maxSpeed, ' km/h')}`,
|
||||
`起点:${startTime}`,
|
||||
`终点:${endTime}`,
|
||||
`当前位置服务:${window.location.origin}${window.location.pathname}${window.location.hash}`
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
async function copyText(value: string, label: string) {
|
||||
const text = value.trim();
|
||||
if (!text) {
|
||||
Toast.warning(`${label}为空`);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
Toast.success(`已复制${label}`);
|
||||
} catch {
|
||||
Toast.error(`复制${label}失败`);
|
||||
}
|
||||
}
|
||||
|
||||
export function History({
|
||||
initialVin,
|
||||
initialProtocol,
|
||||
@@ -299,6 +351,20 @@ export function History({
|
||||
downloadCsv(exportFileName('raw-frames', filters), buildCsv(rawExportColumns, rawFrames.items));
|
||||
Toast.success(`已导出 ${rawFrames.items.length} 条 RAW 帧`);
|
||||
};
|
||||
const copyTrajectorySummary = () => {
|
||||
copyText(
|
||||
trajectorySummaryText({
|
||||
filters,
|
||||
totalPoints: locations.total,
|
||||
validPointCount: validLocations.length,
|
||||
mileageDelta,
|
||||
maxSpeed,
|
||||
firstLocation,
|
||||
lastLocation
|
||||
}),
|
||||
'轨迹摘要'
|
||||
);
|
||||
};
|
||||
const movePlayback = (delta: number) => {
|
||||
if (playbackRows.length === 0) return;
|
||||
setPlaybackIndex((current) => Math.min(Math.max(current + delta, 0), playbackRows.length - 1));
|
||||
@@ -388,7 +454,16 @@ export function History({
|
||||
</Space>
|
||||
</Card>
|
||||
) : null}
|
||||
<Card bordered title="轨迹回放作业台" style={{ marginTop: 16 }}>
|
||||
<Card
|
||||
bordered
|
||||
title={(
|
||||
<Space>
|
||||
<span>轨迹回放作业台</span>
|
||||
<Button size="small" theme="light" icon={<IconCopy />} onClick={copyTrajectorySummary}>复制轨迹摘要</Button>
|
||||
</Space>
|
||||
)}
|
||||
style={{ marginTop: 16 }}
|
||||
>
|
||||
<div className="vp-playback-layout">
|
||||
<div className="vp-playback-map">
|
||||
<div className="vp-monitor-map-header">
|
||||
|
||||
@@ -3380,6 +3380,99 @@ test('opens same-day raw evidence from quality issue row', async () => {
|
||||
expect(await screen.findByRole('heading', { name: '轨迹回放' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('copies trajectory playback summary from history page', async () => {
|
||||
window.history.replaceState(null, '', '/#/history?keyword=VIN-HISTORY-SUMMARY&protocol=JT808&dateFrom=2026-07-03&dateTo=2026-07-04');
|
||||
const writeText = vi.fn(() => Promise.resolve());
|
||||
Object.defineProperty(navigator, 'clipboard', {
|
||||
configurable: true,
|
||||
value: { writeText }
|
||||
});
|
||||
vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => {
|
||||
const path = String(input);
|
||||
if (path.includes('/api/ops/health')) {
|
||||
return {
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
data: { linkHealth: [], kafkaLag: 0, activeConnections: 0, capacityFindings: [], 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-HISTORY-SUMMARY',
|
||||
plate: '粤A轨迹1',
|
||||
protocol: 'JT808',
|
||||
longitude: 113.201,
|
||||
latitude: 23.122,
|
||||
speedKmh: 12.5,
|
||||
totalMileageKm: 1000,
|
||||
deviceTime: '2026-07-03 10:00:00',
|
||||
serverTime: '2026-07-03 10:00:02'
|
||||
},
|
||||
{
|
||||
vin: 'VIN-HISTORY-SUMMARY',
|
||||
plate: '粤A轨迹1',
|
||||
protocol: 'JT808',
|
||||
longitude: 113.301,
|
||||
latitude: 23.222,
|
||||
speedKmh: 58.8,
|
||||
totalMileageKm: 1022.6,
|
||||
deviceTime: '2026-07-03 11:00:00',
|
||||
serverTime: '2026-07-03 11:00:02'
|
||||
}
|
||||
],
|
||||
total: 2,
|
||||
limit: 10,
|
||||
offset: 0
|
||||
},
|
||||
traceId: 'trace-test',
|
||||
timestamp: 1783094400000
|
||||
})
|
||||
} as Response;
|
||||
}
|
||||
if (path.includes('/api/history/raw-frames/query')) {
|
||||
return {
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
data: { items: [], total: 0, limit: 10, offset: 0 },
|
||||
traceId: 'trace-test',
|
||||
timestamp: 1783094400000
|
||||
})
|
||||
} as Response;
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
data: { items: [], total: 0, limit: 20, offset: 0 },
|
||||
traceId: 'trace-test',
|
||||
timestamp: 1783094400000
|
||||
})
|
||||
} as Response;
|
||||
});
|
||||
|
||||
render(<App />);
|
||||
|
||||
expect((await screen.findAllByText('VIN-HISTORY-SUMMARY')).length).toBeGreaterThan(0);
|
||||
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('查询范围:2026-07-03 至 2026-07-04'));
|
||||
expect(writeText).toHaveBeenCalledWith(expect.stringContaining('轨迹点:2,有效定位:2'));
|
||||
expect(writeText).toHaveBeenCalledWith(expect.stringContaining('区间里程:22.6 km'));
|
||||
expect(writeText).toHaveBeenCalledWith(expect.stringContaining('最高速度:58.8 km/h'));
|
||||
expect(writeText).toHaveBeenCalledWith(expect.stringContaining('起点:2026-07-03 10:00:00'));
|
||||
expect(writeText).toHaveBeenCalledWith(expect.stringContaining('终点:2026-07-03 11:00:00'));
|
||||
});
|
||||
|
||||
test('opens same-day mileage statistics from quality issue row', async () => {
|
||||
window.history.replaceState(null, '', '/#/quality');
|
||||
vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => {
|
||||
|
||||
Reference in New Issue
Block a user