Files
lingniu-vehicle-ingest/vehicle-data-platform/apps/web/src/pages/History.tsx

216 lines
10 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { Button, Card, Form, Select, SideSheet, Space, Table, Tabs, Toast, Typography } from '@douyinfe/semi-ui';
import { IconRefresh, IconSearch } from '@douyinfe/semi-icons';
import { useEffect, useMemo, useState } from 'react';
import { api } from '../api/client';
import type { HistoryLocationRow, Page, RawFrameRow } from '../api/types';
import { PageHeader } from '../components/PageHeader';
type HistoryFilters = {
vin?: string;
protocol?: string;
dateFrom?: string;
dateTo?: string;
fields?: string;
includeFields?: boolean;
};
const defaultFilters: HistoryFilters = {
vin: 'LB9A32A24R0LS1426',
includeFields: false
};
const defaultPage = { items: [], total: 0, limit: 10, offset: 0 };
function canOpenVehicle(vin?: string) {
const value = vin?.trim();
return Boolean(value && value !== 'unknown');
}
export function History({ initialVin, onOpenVehicle }: { initialVin: string; onOpenVehicle: (vin: string) => void }) {
const [filters, setFilters] = useState<HistoryFilters>({ ...defaultFilters, vin: initialVin || defaultFilters.vin });
const [locations, setLocations] = useState<Page<HistoryLocationRow>>(defaultPage);
const [rawFrames, setRawFrames] = useState<Page<RawFrameRow>>(defaultPage);
const [selectedRaw, setSelectedRaw] = useState<RawFrameRow | null>(null);
const [loadingLocations, setLoadingLocations] = useState(false);
const [loadingRaw, setLoadingRaw] = useState(false);
const [locationPagination, setLocationPagination] = useState({ currentPage: 1, pageSize: 10 });
const [rawPagination, setRawPagination] = useState({ currentPage: 1, pageSize: 10 });
const buildParams = (nextFilters: HistoryFilters, limit: number, offset: number, raw: boolean) => {
const params = new URLSearchParams({ limit: String(limit), offset: String(offset) });
if (nextFilters.vin?.trim()) params.set('vin', nextFilters.vin.trim());
if (nextFilters.protocol?.trim()) params.set('protocol', nextFilters.protocol.trim());
if (nextFilters.dateFrom?.trim()) params.set('dateFrom', nextFilters.dateFrom.trim());
if (nextFilters.dateTo?.trim()) params.set('dateTo', nextFilters.dateTo.trim());
if (raw && nextFilters.includeFields) params.set('includeFields', 'true');
if (raw && nextFilters.fields?.trim()) params.set('fields', nextFilters.fields.trim());
return params;
};
const loadLocations = (nextFilters = filters, page = locationPagination.currentPage, pageSize = locationPagination.pageSize) => {
setLoadingLocations(true);
api.historyLocations(buildParams(nextFilters, pageSize, (page - 1) * pageSize, false))
.then((nextPage) => {
setLocations(nextPage);
setLocationPagination({ currentPage: page, pageSize });
})
.catch((error: Error) => Toast.error(error.message))
.finally(() => setLoadingLocations(false));
};
const loadRawFrames = (nextFilters = filters, page = rawPagination.currentPage, pageSize = rawPagination.pageSize) => {
setLoadingRaw(true);
api.rawFrames(buildParams(nextFilters, pageSize, (page - 1) * pageSize, true))
.then((nextPage) => {
setRawFrames(nextPage);
setRawPagination({ currentPage: page, pageSize });
})
.catch((error: Error) => Toast.error(error.message))
.finally(() => setLoadingRaw(false));
};
const submit = (values: Record<string, unknown>) => {
const nextFilters: HistoryFilters = {
vin: String(values.vin ?? ''),
protocol: String(values.protocol ?? ''),
dateFrom: String(values.dateFrom ?? ''),
dateTo: String(values.dateTo ?? ''),
fields: String(values.fields ?? ''),
includeFields: Boolean(values.includeFields)
};
setFilters(nextFilters);
loadLocations(nextFilters, 1, locationPagination.pageSize);
loadRawFrames(nextFilters, 1, rawPagination.pageSize);
};
const reset = () => {
const nextFilters = { ...defaultFilters, vin: initialVin || defaultFilters.vin };
setFilters(nextFilters);
loadLocations(nextFilters, 1, locationPagination.pageSize);
loadRawFrames(nextFilters, 1, rawPagination.pageSize);
};
useEffect(() => {
const nextFilters = { ...defaultFilters, vin: initialVin || defaultFilters.vin };
setFilters(nextFilters);
loadLocations(nextFilters, 1, locationPagination.pageSize);
loadRawFrames(nextFilters, 1, rawPagination.pageSize);
}, [initialVin]);
const rawFieldCount = useMemo(() => Object.keys(selectedRaw?.parsedFields ?? {}).length, [selectedRaw]);
return (
<div className="vp-page">
<PageHeader title="历史数据" description="按车辆查询位置历史和 RAW 帧历史,数据来源只作为过滤和诊断维度" />
<Card bordered>
<Form key={`${filters.vin ?? ''}-${filters.protocol ?? ''}-${filters.includeFields ? 'fields' : 'light'}`} initValues={filters} layout="horizontal" onSubmit={(values) => submit(values)}>
<Form.Input field="vin" label="VIN" placeholder="输入 VIN" style={{ width: 260 }} />
<Form.Select field="protocol" label="数据来源" placeholder="全部来源" style={{ width: 190 }}>
<Select.Option value="GB32960">GB32960</Select.Option>
<Select.Option value="JT808">JT808</Select.Option>
<Select.Option value="YUTONG_MQTT">YUTONG_MQTT</Select.Option>
</Form.Select>
<Form.Input field="dateFrom" label="开始时间" placeholder="2026-07-03 00:00:00" style={{ width: 210 }} />
<Form.Input field="dateTo" label="结束时间" placeholder="2026-07-03 23:59:59" style={{ width: 210 }} />
<Form.Checkbox field="includeFields" noLabel>
</Form.Checkbox>
<Form.TextArea
field="fields"
label="字段裁剪"
placeholder="可选,逗号分隔,如 jt808.header.phone,jt808.location.longitude"
autosize={{ minRows: 1, maxRows: 3 }}
style={{ width: 420 }}
/>
<Space align="start">
<Button icon={<IconSearch />} htmlType="submit" theme="solid" type="primary"></Button>
<Button icon={<IconRefresh />} onClick={reset}></Button>
</Space>
</Form>
</Card>
<Card bordered style={{ marginTop: 16 }}>
<Tabs>
<Tabs.TabPane tab="位置历史" itemKey="location">
<Table
rowKey="deviceTime"
dataSource={locations.items}
loading={loadingLocations}
pagination={{
currentPage: locationPagination.currentPage,
pageSize: locationPagination.pageSize,
total: locations.total,
showSizeChanger: true,
onPageChange: (page) => loadLocations(filters, page, locationPagination.pageSize),
onPageSizeChange: (pageSize) => loadLocations(filters, 1, pageSize)
}}
columns={[
{ title: 'VIN', dataIndex: 'vin', width: 190 },
{ title: '数据来源', dataIndex: 'protocol', width: 120 },
{ title: '经度', dataIndex: 'longitude', width: 120 },
{ title: '纬度', dataIndex: 'latitude', width: 120 },
{ title: '速度 km/h', dataIndex: 'speedKmh', width: 120 },
{ title: '总里程 km', dataIndex: 'totalMileageKm', width: 130 },
{ title: '设备时间', dataIndex: 'deviceTime', width: 190 },
{ title: '入库时间', dataIndex: 'serverTime', width: 190 },
{
title: '操作',
width: 110,
render: (_: unknown, row: HistoryLocationRow) => (
<Button disabled={!canOpenVehicle(row.vin)} onClick={() => onOpenVehicle(row.vin)}></Button>
)
}
]}
/>
</Tabs.TabPane>
<Tabs.TabPane tab="RAW 帧" itemKey="raw">
<Table
rowKey="id"
dataSource={rawFrames.items}
loading={loadingRaw}
pagination={{
currentPage: rawPagination.currentPage,
pageSize: rawPagination.pageSize,
total: rawFrames.total,
showSizeChanger: true,
onPageChange: (page) => loadRawFrames(filters, page, rawPagination.pageSize),
onPageSizeChange: (pageSize) => loadRawFrames(filters, 1, pageSize)
}}
columns={[
{ title: 'ID', dataIndex: 'id', width: 260 },
{ title: 'VIN', dataIndex: 'vin', width: 190 },
{ title: '车牌', dataIndex: 'plate', width: 120 },
{ title: '数据来源', dataIndex: 'protocol', width: 120 },
{ title: '帧类型', dataIndex: 'frameType', width: 190 },
{ title: '大小 B', dataIndex: 'rawSizeBytes', width: 100 },
{ title: '设备时间', dataIndex: 'deviceTime', width: 190 },
{ title: '入库时间', dataIndex: 'serverTime', width: 190 },
{
title: '操作',
width: 200,
render: (_: unknown, row: RawFrameRow) => (
<Space>
<Button onClick={() => setSelectedRaw(row)}></Button>
<Button disabled={!canOpenVehicle(row.vin)} onClick={() => onOpenVehicle(row.vin)}></Button>
</Space>
)
}
]}
/>
</Tabs.TabPane>
</Tabs>
</Card>
<SideSheet title="RAW 解析字段" visible={Boolean(selectedRaw)} onCancel={() => setSelectedRaw(null)} width={720}>
<Space vertical align="start" spacing={12} style={{ width: '100%' }}>
<Typography.Text type="tertiary">
{selectedRaw?.protocol ?? '-'} / {selectedRaw?.plate || selectedRaw?.vin || '-'} / {rawFieldCount}
</Typography.Text>
{rawFieldCount === 0 ? (
<Typography.Text type="secondary"></Typography.Text>
) : null}
</Space>
<pre className="vp-json">{JSON.stringify(selectedRaw?.parsedFields ?? {}, null, 2)}</pre>
</SideSheet>
</div>
);
}