feat(platform): add parsed fields history view
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
Reference in New Issue
Block a user