diff --git a/vehicle-data-platform/apps/web/src/domain/csvExport.test.ts b/vehicle-data-platform/apps/web/src/domain/csvExport.test.ts new file mode 100644 index 00000000..918b2d1e --- /dev/null +++ b/vehicle-data-platform/apps/web/src/domain/csvExport.test.ts @@ -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}"'); +}); diff --git a/vehicle-data-platform/apps/web/src/domain/csvExport.ts b/vehicle-data-platform/apps/web/src/domain/csvExport.ts new file mode 100644 index 00000000..be580d91 --- /dev/null +++ b/vehicle-data-platform/apps/web/src/domain/csvExport.ts @@ -0,0 +1,29 @@ +export type CsvColumn = { + 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(columns: CsvColumn[], 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); +} diff --git a/vehicle-data-platform/apps/web/src/pages/History.tsx b/vehicle-data-platform/apps/web/src/pages/History.tsx index cdfe1c18..39fea037 100644 --- a/vehicle-data-platform/apps/web/src/pages/History.tsx +++ b/vehicle-data-platform/apps/web/src/pages/History.tsx @@ -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[] = [ + { 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[] = [ + { 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 (
@@ -320,6 +367,12 @@ export function History({ changeTab(String(key))}> +
+ + 当前页 {locations.items.length.toLocaleString()} 条 + + +
+
+ + 当前页 {rawFrames.items.length.toLocaleString()} 条 + 0 ? 'green' : 'orange'}> + {isIncludeFieldsEnabled(filters.includeFields) || splitFields(filters.fields).length > 0 ? '包含解析字段' : '未请求解析字段'} + + + +
{ 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(); + + 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(); + + 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) => { diff --git a/vehicle-data-platform/apps/web/src/test/setup.ts b/vehicle-data-platform/apps/web/src/test/setup.ts index 9fd89c5e..adb39a59 100644 --- a/vehicle-data-platform/apps/web/src/test/setup.ts +++ b/vehicle-data-platform/apps/web/src/test/setup.ts @@ -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,