feat(platform): export history query results
This commit is contained in:
14
vehicle-data-platform/apps/web/src/domain/csvExport.test.ts
Normal file
14
vehicle-data-platform/apps/web/src/domain/csvExport.test.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { expect, test } from 'vitest';
|
||||
import { buildCsv } from './csvExport';
|
||||
|
||||
test('builds csv with escaping and json object values', () => {
|
||||
const csv = buildCsv(
|
||||
[
|
||||
{ title: 'VIN', value: (row: { vin: string; fields?: unknown }) => row.vin },
|
||||
{ title: '字段', value: (row) => row.fields }
|
||||
],
|
||||
[{ vin: 'VIN"001', fields: { speed: 42 } }]
|
||||
);
|
||||
|
||||
expect(csv).toBe('"VIN","字段"\n"VIN""001","{""speed"":42}"');
|
||||
});
|
||||
29
vehicle-data-platform/apps/web/src/domain/csvExport.ts
Normal file
29
vehicle-data-platform/apps/web/src/domain/csvExport.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
export type CsvColumn<T> = {
|
||||
title: string;
|
||||
value: (row: T) => unknown;
|
||||
};
|
||||
|
||||
function csvCell(value: unknown) {
|
||||
if (value == null) return '';
|
||||
const text = typeof value === 'object' ? JSON.stringify(value) : String(value);
|
||||
return `"${text.replace(/"/g, '""')}"`;
|
||||
}
|
||||
|
||||
export function buildCsv<T>(columns: CsvColumn<T>[], rows: T[]) {
|
||||
return [
|
||||
columns.map((item) => csvCell(item.title)).join(','),
|
||||
...rows.map((row) => columns.map((column) => csvCell(column.value(row))).join(','))
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
export function downloadCsv(filename: string, csv: string) {
|
||||
const blob = new Blob(['\uFEFF', csv], { type: 'text/csv;charset=utf-8' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = filename;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
link.remove();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import type { HistoryLocationRow, Page, RawFrameRow } from '../api/types';
|
||||
import { VehicleMap, type VehicleMapPoint } from '../components/VehicleMap';
|
||||
import { PageHeader } from '../components/PageHeader';
|
||||
import { isAMapConfigured } from '../config/appConfig';
|
||||
import { buildCsv, downloadCsv, type CsvColumn } from '../domain/csvExport';
|
||||
|
||||
type HistoryFilters = {
|
||||
keyword?: string;
|
||||
@@ -68,6 +69,36 @@ function mergeInitialFilters(initialVin: string, initialProtocol?: string, initi
|
||||
};
|
||||
}
|
||||
|
||||
const locationExportColumns: CsvColumn<HistoryLocationRow>[] = [
|
||||
{ title: 'VIN', value: (row) => row.vin },
|
||||
{ title: '车牌', value: (row) => row.plate },
|
||||
{ title: '数据来源', value: (row) => row.protocol },
|
||||
{ title: '经度', value: (row) => row.longitude },
|
||||
{ title: '纬度', value: (row) => row.latitude },
|
||||
{ title: '速度km/h', value: (row) => row.speedKmh },
|
||||
{ title: '总里程km', value: (row) => row.totalMileageKm },
|
||||
{ title: '设备时间', value: (row) => row.deviceTime },
|
||||
{ title: '入库时间', value: (row) => row.serverTime }
|
||||
];
|
||||
|
||||
const rawExportColumns: CsvColumn<RawFrameRow>[] = [
|
||||
{ title: 'ID', value: (row) => row.id },
|
||||
{ title: 'VIN', value: (row) => row.vin },
|
||||
{ title: '车牌', value: (row) => row.plate },
|
||||
{ title: '数据来源', value: (row) => row.protocol },
|
||||
{ title: '帧类型', value: (row) => row.frameType },
|
||||
{ title: '大小B', value: (row) => row.rawSizeBytes },
|
||||
{ title: '设备时间', value: (row) => row.deviceTime },
|
||||
{ title: '入库时间', value: (row) => row.serverTime },
|
||||
{ title: '解析字段', value: (row) => row.parsedFields ?? {} }
|
||||
];
|
||||
|
||||
function exportFileName(prefix: string, filters: HistoryFilters) {
|
||||
const keyword = filters.keyword?.trim() || 'all';
|
||||
const protocol = filters.protocol?.trim() || 'all-source';
|
||||
return `${prefix}-${keyword}-${protocol}.csv`;
|
||||
}
|
||||
|
||||
export function History({
|
||||
initialVin,
|
||||
initialProtocol,
|
||||
@@ -211,6 +242,22 @@ export function History({
|
||||
const clearRangeFilters = () => {
|
||||
applyFilters(currentVehicleKeyword ? { keyword: currentVehicleKeyword } : {});
|
||||
};
|
||||
const exportLocations = () => {
|
||||
if (locations.items.length === 0) {
|
||||
Toast.warning('当前没有可导出的位置历史');
|
||||
return;
|
||||
}
|
||||
downloadCsv(exportFileName('history-locations', filters), buildCsv(locationExportColumns, locations.items));
|
||||
Toast.success(`已导出 ${locations.items.length} 条位置历史`);
|
||||
};
|
||||
const exportRawFrames = () => {
|
||||
if (rawFrames.items.length === 0) {
|
||||
Toast.warning('当前没有可导出的 RAW 帧');
|
||||
return;
|
||||
}
|
||||
downloadCsv(exportFileName('raw-frames', filters), buildCsv(rawExportColumns, rawFrames.items));
|
||||
Toast.success(`已导出 ${rawFrames.items.length} 条 RAW 帧`);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="vp-page">
|
||||
@@ -320,6 +367,12 @@ export function History({
|
||||
<Card bordered style={{ marginTop: 16 }}>
|
||||
<Tabs activeKey={activeTab} onChange={(key) => changeTab(String(key))}>
|
||||
<Tabs.TabPane tab="位置历史" itemKey="location">
|
||||
<div className="vp-table-toolbar">
|
||||
<Space wrap>
|
||||
<Tag color="blue">当前页 {locations.items.length.toLocaleString()} 条</Tag>
|
||||
<Button size="small" onClick={exportLocations}>导出位置当前页 CSV</Button>
|
||||
</Space>
|
||||
</div>
|
||||
<Table
|
||||
rowKey="deviceTime"
|
||||
dataSource={locations.items}
|
||||
@@ -352,6 +405,15 @@ export function History({
|
||||
/>
|
||||
</Tabs.TabPane>
|
||||
<Tabs.TabPane tab="RAW 帧" itemKey="raw">
|
||||
<div className="vp-table-toolbar">
|
||||
<Space wrap>
|
||||
<Tag color="blue">当前页 {rawFrames.items.length.toLocaleString()} 条</Tag>
|
||||
<Tag color={isIncludeFieldsEnabled(filters.includeFields) || splitFields(filters.fields).length > 0 ? 'green' : 'orange'}>
|
||||
{isIncludeFieldsEnabled(filters.includeFields) || splitFields(filters.fields).length > 0 ? '包含解析字段' : '未请求解析字段'}
|
||||
</Tag>
|
||||
<Button size="small" onClick={exportRawFrames}>导出 RAW 当前页 CSV</Button>
|
||||
</Space>
|
||||
</div>
|
||||
<Table
|
||||
rowKey="id"
|
||||
dataSource={rawFrames.items}
|
||||
|
||||
@@ -565,6 +565,15 @@ body {
|
||||
padding-top: 12px;
|
||||
}
|
||||
|
||||
.vp-table-toolbar {
|
||||
min-height: 42px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 12px;
|
||||
padding-bottom: 12px;
|
||||
}
|
||||
|
||||
.vp-vehicle-summary {
|
||||
min-height: 72px;
|
||||
display: flex;
|
||||
|
||||
@@ -3037,6 +3037,143 @@ test('shows trajectory playback workspace from history locations', async () => {
|
||||
expect(screen.getByText('终点')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('exports current history location page as csv', async () => {
|
||||
window.history.replaceState(null, '', '/#/history?keyword=VIN-EXPORT-LOC&protocol=JT808');
|
||||
const createObjectURL = vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:history-location');
|
||||
const revokeObjectURL = vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => undefined);
|
||||
vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => undefined);
|
||||
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-EXPORT-LOC',
|
||||
plate: '粤A导出1',
|
||||
protocol: 'JT808',
|
||||
longitude: 113.2,
|
||||
latitude: 23.1,
|
||||
speedKmh: 30,
|
||||
socPercent: 0,
|
||||
totalMileageKm: 100,
|
||||
lastSeen: '2026-07-03 20:12:10',
|
||||
deviceTime: '2026-07-03 20:12:10',
|
||||
serverTime: '2026-07-03 20:12:11'
|
||||
}],
|
||||
total: 1,
|
||||
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('VIN-EXPORT-LOC')).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole('button', { name: '导出位置当前页 CSV' }));
|
||||
|
||||
expect(createObjectURL).toHaveBeenCalled();
|
||||
expect(revokeObjectURL).toHaveBeenCalledWith('blob:history-location');
|
||||
});
|
||||
|
||||
test('exports current raw frame page as csv with parsed fields', async () => {
|
||||
window.history.replaceState(null, '', '/#/history?keyword=VIN-EXPORT-RAW&protocol=GB32960&includeFields=true&tab=raw');
|
||||
const createObjectURL = vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:history-raw');
|
||||
const revokeObjectURL = vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => undefined);
|
||||
vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => undefined);
|
||||
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, 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: [], total: 0, 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: [{
|
||||
id: 'raw-export-001',
|
||||
vin: 'VIN-EXPORT-RAW',
|
||||
plate: '粤A导出2',
|
||||
protocol: 'GB32960',
|
||||
frameType: 'REALTIME',
|
||||
deviceTime: '2026-07-03 20:12:10',
|
||||
serverTime: '2026-07-03 20:12:11',
|
||||
rawSizeBytes: 256,
|
||||
parsedFields: { 'gb32960.vehicle.speed_kmh': 42 }
|
||||
}],
|
||||
total: 1,
|
||||
limit: 10,
|
||||
offset: 0
|
||||
},
|
||||
traceId: 'trace-test',
|
||||
timestamp: 1783094400000
|
||||
})
|
||||
} as Response;
|
||||
}
|
||||
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('raw-export-001')).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole('button', { name: '导出 RAW 当前页 CSV' }));
|
||||
|
||||
expect(createObjectURL).toHaveBeenCalled();
|
||||
expect(revokeObjectURL).toHaveBeenCalledWith('blob:history-raw');
|
||||
});
|
||||
|
||||
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) => {
|
||||
|
||||
@@ -65,6 +65,16 @@ Object.defineProperty(window, 'matchMedia', {
|
||||
})
|
||||
});
|
||||
|
||||
Object.defineProperty(URL, 'createObjectURL', {
|
||||
configurable: true,
|
||||
value: () => 'blob:test'
|
||||
});
|
||||
|
||||
Object.defineProperty(URL, 'revokeObjectURL', {
|
||||
configurable: true,
|
||||
value: () => undefined
|
||||
});
|
||||
|
||||
document.createRange = () => ({
|
||||
setStart: () => undefined,
|
||||
setEnd: () => undefined,
|
||||
|
||||
Reference in New Issue
Block a user