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