feat(platform): add parsed fields history view

This commit is contained in:
lingniu
2026-07-04 17:31:44 +08:00
parent 747894ce1d
commit 2d4b397acd
4 changed files with 221 additions and 16 deletions

View File

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

View File

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

View File

@@ -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<RawFrameRow>[] = [
{ title: '解析字段', value: (row) => row.parsedFields ?? {} }
];
const rawFieldExportColumns: CsvColumn<RawFieldRow>[] = [
{ 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<string, unknown>).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<RawFieldRow[]>(() => 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({
]}
/>
</Tabs.TabPane>
<Tabs.TabPane tab="解析字段" itemKey="fields">
<div className="vp-table-toolbar">
<Space wrap>
<Tag color="green"> {rawFieldRows.length.toLocaleString()} </Tag>
<Tag color="blue">{rawFrames.items.length.toLocaleString()} RAW </Tag>
{selectedFieldCount > 0 ? <Tag color="blue"> {selectedFieldCount.toLocaleString()} </Tag> : <Tag color="grey"></Tag>}
<Button size="small" onClick={exportRawFields}> CSV</Button>
</Space>
</div>
<Table
rowKey="id"
dataSource={rawFieldRows}
loading={loadingRaw}
pagination={false}
columns={[
{ title: '字段', dataIndex: 'fieldPath', width: 320 },
{
title: '值',
width: 260,
render: (_: unknown, row: RawFieldRow) => (
<Typography.Text ellipsis={{ showTooltip: true }}>{formatFieldValue(row.fieldValue) || '-'}</Typography.Text>
)
},
{ 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) => (
<Space wrap>
<Button disabled={!canOpenVehicle(row.vin)} onClick={() => onOpenVehicle(row.vin, row.protocol)}></Button>
</Space>
)
}
]}
/>
<Space wrap style={{ marginTop: 12 }}>
<Tag color="grey">沿 RAW </Tag>
<Tag color="grey"> parsedFields</Tag>
</Space>
</Tabs.TabPane>
</Tabs>
</Card>
<SideSheet title="RAW 解析字段" visible={Boolean(selectedRaw)} onCancel={() => setSelectedRaw(null)} width={720}>

View File

@@ -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(<App />);
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(<App />);
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(<App />);
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(() => {