feat(platform): export history query results

This commit is contained in:
lingniu
2026-07-04 10:59:47 +08:00
parent 312382c95d
commit cd9d160f3e
6 changed files with 261 additions and 0 deletions

View 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}"');
});

View 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);
}

View File

@@ -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}

View File

@@ -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;

View File

@@ -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) => {

View File

@@ -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,