diff --git a/vehicle-data-platform/apps/web/src/App.tsx b/vehicle-data-platform/apps/web/src/App.tsx index c31eb38e..fd76653a 100644 --- a/vehicle-data-platform/apps/web/src/App.tsx +++ b/vehicle-data-platform/apps/web/src/App.tsx @@ -333,8 +333,8 @@ export default function App() { const nextFilters = normalizeHistoryFilterValues(filters); const nextVin = nextFilters.keyword?.trim() || analysisVin; const nextProtocol = nextFilters.protocol?.trim() ?? activeProtocol; - const nextTab = nextFilters.tab === 'raw' ? 'raw' : 'location'; - const nextPage = nextTab === 'raw' ? 'history-query' : 'history'; + const nextTab = nextFilters.tab === 'raw' || nextFilters.tab === 'fields' ? nextFilters.tab : 'location'; + const nextPage = nextTab === 'raw' || nextTab === 'fields' ? 'history-query' : 'history'; setAnalysisVin(nextVin); setActiveProtocol(nextProtocol); setHistoryTab(nextTab); diff --git a/vehicle-data-platform/apps/web/src/domain/appRoute.test.ts b/vehicle-data-platform/apps/web/src/domain/appRoute.test.ts index 90235680..743e1d4b 100644 --- a/vehicle-data-platform/apps/web/src/domain/appRoute.test.ts +++ b/vehicle-data-platform/apps/web/src/domain/appRoute.test.ts @@ -102,6 +102,10 @@ describe('buildAppHash', () => { expect(buildAppHash({ page: 'history-query', keyword: 'VIN001', protocol: 'GB32960', filters: { tab: 'raw' } })).toBe('#/history-query?keyword=VIN001&protocol=GB32960&tab=raw'); }); + test('builds shareable parsed fields history query hash', () => { + expect(buildAppHash({ page: 'history-query', keyword: 'VIN001', protocol: 'GB32960', filters: { tab: 'fields', fields: 'gb32960.vehicle.speed_kmh' } })).toBe('#/history-query?keyword=VIN001&protocol=GB32960&tab=fields&fields=gb32960.vehicle.speed_kmh'); + }); + test('builds shareable vehicle list hash with filters', () => { expect(buildAppHash({ page: 'vehicles', filters: { coverage: 'multi', serviceStatus: 'degraded', archiveStatus: 'incomplete', archiveMissing: 'phone', missingProtocol: 'YUTONG_MQTT' } })).toBe('#/vehicles?coverage=multi&serviceStatus=degraded&archiveStatus=incomplete&archiveMissing=phone&missingProtocol=YUTONG_MQTT'); }); diff --git a/vehicle-data-platform/apps/web/src/pages/History.tsx b/vehicle-data-platform/apps/web/src/pages/History.tsx index 49a2d50b..759e42da 100644 --- a/vehicle-data-platform/apps/web/src/pages/History.tsx +++ b/vehicle-data-platform/apps/web/src/pages/History.tsx @@ -17,6 +17,19 @@ type HistoryFilters = { includeFields?: boolean | string; }; +type RawFieldRow = { + id: string; + rawId: string; + vin: string; + plate: string; + protocol: string; + frameType: string; + deviceTime: string; + serverTime: string; + fieldPath: string; + fieldValue: unknown; +}; + const defaultFilters: HistoryFilters = { keyword: 'LB9A32A24R0LS1426', includeFields: false @@ -48,9 +61,10 @@ function splitFields(value?: string) { .filter(Boolean); } -type HistoryTabKey = 'location' | 'raw'; +type HistoryTabKey = 'location' | 'raw' | 'fields'; function normalizeHistoryTab(value?: string): HistoryTabKey { + if (value === 'fields') return 'fields'; return value === 'raw' ? 'raw' : 'location'; } @@ -136,6 +150,42 @@ const rawExportColumns: CsvColumn[] = [ { title: '解析字段', value: (row) => row.parsedFields ?? {} } ]; +const rawFieldExportColumns: CsvColumn[] = [ + { title: 'RAW ID', value: (row) => row.rawId }, + { title: 'VIN', value: (row) => row.vin }, + { title: '车牌', value: (row) => row.plate }, + { title: '数据来源', value: (row) => row.protocol }, + { title: '帧类型', value: (row) => row.frameType }, + { title: '字段', value: (row) => row.fieldPath }, + { title: '值', value: (row) => formatFieldValue(row.fieldValue) }, + { title: '设备时间', value: (row) => row.deviceTime }, + { title: '入库时间', value: (row) => row.serverTime } +]; + +function formatFieldValue(value: unknown) { + if (value == null) return ''; + if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') { + return String(value); + } + return JSON.stringify(value); +} + +function flattenParsedFields(value: unknown, prefix = ''): Array<{ path: string; value: unknown }> { + if (value == null || typeof value !== 'object') { + return prefix ? [{ path: prefix, value }] : []; + } + if (Array.isArray(value)) { + return value.flatMap((item, index) => flattenParsedFields(item, prefix ? `${prefix}.${index}` : String(index))); + } + return Object.entries(value as Record).flatMap(([key, item]) => { + const nextPath = prefix ? `${prefix}.${key}` : key; + if (item != null && typeof item === 'object') { + return flattenParsedFields(item, nextPath); + } + return [{ path: nextPath, value: item }]; + }); +} + function exportFileName(prefix: string, filters: HistoryFilters) { const keyword = filters.keyword?.trim() || 'all'; const protocol = filters.protocol?.trim() || 'all-source'; @@ -263,13 +313,13 @@ export function History({ return params; }; - const buildRawQuery = (nextFilters: HistoryFilters, limit: number, offset: number): RawFrameQuery => { + const buildRawQuery = (nextFilters: HistoryFilters, limit: number, offset: number, forceIncludeFields = false): RawFrameQuery => { const query: RawFrameQuery = { limit, offset }; if (nextFilters.keyword?.trim()) query.keyword = nextFilters.keyword.trim(); if (nextFilters.protocol?.trim()) query.protocol = nextFilters.protocol.trim(); if (nextFilters.dateFrom?.trim()) query.dateFrom = nextFilters.dateFrom.trim(); if (nextFilters.dateTo?.trim()) query.dateTo = nextFilters.dateTo.trim(); - if (isIncludeFieldsEnabled(nextFilters.includeFields)) query.includeFields = true; + if (forceIncludeFields || isIncludeFieldsEnabled(nextFilters.includeFields)) query.includeFields = true; const fields = splitFields(nextFilters.fields); if (fields.length > 0) query.fields = fields; return query; @@ -288,15 +338,15 @@ export function History({ .finally(() => setLoadingLocations(false)); }; - const loadRawFrames = (nextFilters = filters, page = rawPagination.currentPage, pageSize = rawPagination.pageSize) => { - if (shouldBlockRawFieldQuery(nextFilters)) { + const loadRawFrames = (nextFilters = filters, page = rawPagination.currentPage, pageSize = rawPagination.pageSize, forceIncludeFields = activeTab === 'fields') => { + if ((forceIncludeFields || isIncludeFieldsEnabled(nextFilters.includeFields)) && shouldBlockRawFieldQuery(nextFilters)) { setRawFrames({ items: [], total: 0, limit: pageSize, offset: (page - 1) * pageSize }); setRawPagination({ currentPage: page, pageSize }); Toast.warning('RAW 解析字段查询需要车辆、时间范围或字段裁剪'); return; } setLoadingRaw(true); - api.rawFramesQuery(buildRawQuery(nextFilters, pageSize, (page - 1) * pageSize)) + api.rawFramesQuery(buildRawQuery(nextFilters, pageSize, (page - 1) * pageSize, forceIncludeFields)) .then((nextPage) => { setRawFrames(nextPage); setRawPagination({ currentPage: page, pageSize }); @@ -317,14 +367,14 @@ export function History({ setFilters(nextFilters); onFiltersChange?.(nextFilters, activeTab); loadLocations(nextFilters, 1, locationPagination.pageSize); - loadRawFrames(nextFilters, 1, rawPagination.pageSize); + loadRawFrames(nextFilters, 1, rawPagination.pageSize, activeTab === 'fields'); }; const applyFilters = (nextFilters: HistoryFilters) => { setFilters(nextFilters); onFiltersChange?.(nextFilters, activeTab); loadLocations(nextFilters, 1, locationPagination.pageSize); - loadRawFrames(nextFilters, 1, rawPagination.pageSize); + loadRawFrames(nextFilters, 1, rawPagination.pageSize, activeTab === 'fields'); }; const reset = () => { @@ -334,19 +384,40 @@ export function History({ useEffect(() => { const nextFilters = mergeInitialFilters(initialVin, initialProtocol, initialFilters); + const nextTab = normalizeHistoryTab(initialTab); setFilters(nextFilters); - setActiveTab(normalizeHistoryTab(initialTab)); + setActiveTab(nextTab); loadLocations(nextFilters, 1, locationPagination.pageSize); - loadRawFrames(nextFilters, 1, rawPagination.pageSize); + loadRawFrames(nextFilters, 1, rawPagination.pageSize, nextTab === 'fields'); }, [initialVin, initialProtocol, initialTab, JSON.stringify(initialFilters)]); const changeTab = (key: string) => { const nextTab = normalizeHistoryTab(String(key)); + const nextFilters = nextTab === 'fields' ? { ...filters, includeFields: true } : filters; setActiveTab(nextTab); - onFiltersChange?.(filters, nextTab); + setFilters(nextFilters); + onFiltersChange?.(nextFilters, nextTab); + if (nextTab === 'fields') { + loadRawFrames(nextFilters, 1, rawPagination.pageSize, true); + } }; const rawFieldCount = useMemo(() => Object.keys(selectedRaw?.parsedFields ?? {}).length, [selectedRaw]); + const rawFieldRows = useMemo(() => rawFrames.items.flatMap((row) => { + const fields = flattenParsedFields(row.parsedFields ?? {}); + return fields.map((field, index) => ({ + id: `${row.id}-${field.path}-${index}`, + rawId: row.id, + vin: row.vin, + plate: row.plate, + protocol: row.protocol, + frameType: row.frameType, + deviceTime: row.deviceTime, + serverTime: row.serverTime, + fieldPath: field.path, + fieldValue: field.value + })); + }), [rawFrames.items]); const locationItems = locations.items; const validLocations = locationItems.filter(hasValidCoordinate); const mileageValues = locationItems.map((item) => item.totalMileageKm).filter(isFiniteNumber); @@ -409,6 +480,14 @@ export function History({ downloadCsv(exportFileName('raw-frames', filters), buildCsv(rawExportColumns, rawFrames.items)); Toast.success(`已导出 ${rawFrames.items.length} 条 RAW 帧`); }; + const exportRawFields = () => { + if (rawFieldRows.length === 0) { + Toast.warning('当前没有可导出的解析字段'); + return; + } + downloadCsv(exportFileName('raw-fields', filters), buildCsv(rawFieldExportColumns, rawFieldRows)); + Toast.success(`已导出 ${rawFieldRows.length} 条解析字段`); + }; const copyTrajectorySummary = () => { copyText( trajectorySummaryText({ @@ -772,6 +851,51 @@ export function History({ ]} /> + +
+ + 当前页 {rawFieldRows.length.toLocaleString()} 个字段 + {rawFrames.items.length.toLocaleString()} 条 RAW 来源 + {selectedFieldCount > 0 ? 字段裁剪 {selectedFieldCount.toLocaleString()} 个 : 全量解析字段} + + +
+ ( + {formatFieldValue(row.fieldValue) || '-'} + ) + }, + { title: 'VIN', dataIndex: 'vin', width: 190 }, + { title: '车牌', dataIndex: 'plate', width: 120 }, + { title: '数据来源', dataIndex: 'protocol', width: 120 }, + { title: '帧类型', dataIndex: 'frameType', width: 190 }, + { title: '设备时间', dataIndex: 'deviceTime', width: 190 }, + { title: 'RAW ID', dataIndex: 'rawId', width: 260 }, + { + title: '操作', + width: 170, + render: (_: unknown, row: RawFieldRow) => ( + + + + ) + } + ]} + /> + + 分页沿用 RAW 帧分页 + 字段来自 parsedFields,保留协议字段映射后的路径 + + setSelectedRaw(null)} width={720}> diff --git a/vehicle-data-platform/apps/web/src/test/App.test.tsx b/vehicle-data-platform/apps/web/src/test/App.test.tsx index 638a84e3..f7bb32f5 100644 --- a/vehicle-data-platform/apps/web/src/test/App.test.tsx +++ b/vehicle-data-platform/apps/web/src/test/App.test.tsx @@ -5335,6 +5335,83 @@ test('shows and clears current history filters while keeping vehicle scope', asy expect(window.location.hash).toBe('#/history-query?keyword=VIN-HISTORY-001&tab=raw'); }); +test('shows parsed field history as a flattened evidence table', async () => { + window.history.replaceState(null, '', '/#/history-query?keyword=VIN-FIELDS-001&protocol=GB32960&tab=fields&fields=gb32960.vehicle.speed_kmh'); + const fetchMock = 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: [], 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-field-001', + vin: 'VIN-FIELDS-001', + plate: '粤A字段1', + protocol: 'GB32960', + frameType: 'realtime', + rawSizeBytes: 256, + deviceTime: '2026-07-03 11:56:06', + serverTime: '2026-07-03 11:56:07', + parsedFields: { + 'gb32960.vehicle.speed_kmh': 42, + 'gb32960.vehicle.soc_percent': 78.4 + } + }], + 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.findByRole('heading', { name: '历史查询' })).toBeInTheDocument(); + expect(await screen.findByRole('tab', { name: '解析字段' })).toHaveAttribute('aria-selected', 'true'); + expect(fetchMock).toHaveBeenCalledWith('/api/history/raw-frames/query', expect.objectContaining({ + method: 'POST', + body: expect.stringContaining('"includeFields":true') + })); + expect(screen.getAllByText('gb32960.vehicle.speed_kmh').length).toBeGreaterThanOrEqual(1); + expect(screen.getByText('gb32960.vehicle.soc_percent')).toBeInTheDocument(); + expect(screen.getByText('42')).toBeInTheDocument(); + expect(screen.getByText('78.4')).toBeInTheDocument(); + expect(screen.getByText('当前页 2 个字段')).toBeInTheDocument(); +}); + test('updates history hash when vehicle history filters are submitted', async () => { window.history.replaceState(null, '', '/#/history'); const fetchMock = vi.spyOn(globalThis, 'fetch').mockImplementation(async (input, init) => { @@ -5898,7 +5975,7 @@ test('exports current raw frame page as csv with parsed fields', async () => { render(); - expect(await screen.findByText('raw-export-001')).toBeInTheDocument(); + expect((await screen.findAllByText('raw-export-001')).length).toBeGreaterThanOrEqual(1); fireEvent.click(screen.getByRole('button', { name: '导出 RAW 当前页 CSV' })); expect(createObjectURL).toHaveBeenCalled(); @@ -7027,7 +7104,7 @@ test('opens same-day raw evidence from history location row', async () => { expect(params.get('includeFields')).toBe('true'); expect(params.get('tab')).toBe('raw'); expect(await screen.findByRole('tab', { name: 'RAW 帧' })).toHaveAttribute('aria-selected', 'true'); - expect(await screen.findByText('raw-from-location-001')).toBeInTheDocument(); + expect((await screen.findAllByText('raw-from-location-001')).length).toBeGreaterThanOrEqual(1); }); test('opens vehicle service from raw history rows with row source evidence', async () => { @@ -7215,7 +7292,7 @@ test('opens same-day mileage statistics from raw history row', async () => { render(); - expect(await screen.findByText('raw-jt808-mileage-001')).toBeInTheDocument(); + expect((await screen.findAllByText('raw-jt808-mileage-001')).length).toBeGreaterThanOrEqual(1); fireEvent.click(screen.getByRole('button', { name: '核对里程' })); await waitFor(() => {