feat(platform): make vehicle detail source agnostic

This commit is contained in:
lingniu
2026-07-03 21:31:11 +08:00
parent f9b8182949
commit 0438d054f6
4 changed files with 358 additions and 61 deletions

View File

@@ -1,33 +1,115 @@
import { Button, Card, Form, SideSheet, Space, Table, Tabs, Toast } from '@douyinfe/semi-ui';
import { useEffect, useState } from 'react';
import { Button, Card, Form, Select, SideSheet, Space, Table, Tabs, Toast, Typography } from '@douyinfe/semi-ui';
import { IconChevronLeft, IconChevronRight, IconRefresh, IconSearch } from '@douyinfe/semi-icons';
import { useEffect, useMemo, useState } from 'react';
import { api } from '../api/client';
import type { HistoryLocationRow, RawFrameRow } from '../api/types';
import type { HistoryLocationRow, Page, RawFrameRow } from '../api/types';
import { PageHeader } from '../components/PageHeader';
export function History() {
const [locations, setLocations] = useState<HistoryLocationRow[]>([]);
const [rawFrames, setRawFrames] = useState<RawFrameRow[]>([]);
const [selectedRaw, setSelectedRaw] = useState<RawFrameRow | null>(null);
type HistoryFilters = {
vin?: string;
protocol?: string;
dateFrom?: string;
dateTo?: string;
fields?: string;
includeFields?: boolean;
};
const load = (values?: Record<string, string>) => {
const params = new URLSearchParams({ limit: '20', includeFields: 'true' });
if (values?.vin) params.set('vin', values.vin);
if (values?.protocol) params.set('protocol', values.protocol);
api.historyLocations(params).then((page) => setLocations(page.items)).catch((error: Error) => Toast.error(error.message));
api.rawFrames(params).then((page) => setRawFrames(page.items)).catch((error: Error) => Toast.error(error.message));
const defaultFilters: HistoryFilters = {
protocol: 'GB32960',
vin: 'LB9A32A24R0LS1426',
includeFields: true
};
const defaultPage = { items: [], total: 0, limit: 10, offset: 0 };
export function History() {
const [filters, setFilters] = useState<HistoryFilters>(defaultFilters);
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 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;
};
useEffect(() => load(), []);
const loadLocations = (nextFilters = filters, offset = locations.offset) => {
setLoadingLocations(true);
api.historyLocations(buildParams(nextFilters, locations.limit || 10, offset, false))
.then(setLocations)
.catch((error: Error) => Toast.error(error.message))
.finally(() => setLoadingLocations(false));
};
const loadRawFrames = (nextFilters = filters, offset = rawFrames.offset) => {
setLoadingRaw(true);
api.rawFrames(buildParams(nextFilters, rawFrames.limit || 10, offset, true))
.then(setRawFrames)
.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, 0);
loadRawFrames(nextFilters, 0);
};
const reset = () => {
setFilters(defaultFilters);
loadLocations(defaultFilters, 0);
loadRawFrames(defaultFilters, 0);
};
useEffect(() => {
loadLocations(defaultFilters, 0);
loadRawFrames(defaultFilters, 0);
}, []);
const rawFieldCount = useMemo(() => Object.keys(selectedRaw?.parsedFields ?? {}).length, [selectedRaw]);
return (
<div className="vp-page">
<PageHeader title="历史查询" description="位置历史和 RAW 帧历史的分页查询工作台" />
<Card bordered>
<Form layout="horizontal" onSubmit={(values) => load(values as Record<string, string>)}>
<Form initValues={filters} layout="horizontal" onSubmit={(values) => submit(values)}>
<Form.Input field="vin" label="VIN" placeholder="输入 VIN" style={{ width: 260 }} />
<Form.Input field="protocol" label="协议" placeholder="GB32960 / JT808 / YUTONG_MQTT" style={{ width: 240 }} />
<Space>
<Button htmlType="submit" theme="solid" type="primary"></Button>
<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>
@@ -36,38 +118,92 @@ export function History() {
<Tabs.TabPane tab="位置历史" itemKey="location">
<Table
rowKey="deviceTime"
dataSource={locations}
dataSource={locations.items}
loading={loadingLocations}
pagination={false}
columns={[
{ title: 'VIN', dataIndex: 'vin' },
{ title: '协议', dataIndex: 'protocol' },
{ title: '经度', dataIndex: 'longitude' },
{ title: '纬度', dataIndex: 'latitude' },
{ title: '速度', dataIndex: 'speedKmh' },
{ title: '设备时间', dataIndex: 'deviceTime' }
{ 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 }
]}
/>
<HistoryPager
page={locations}
loading={loadingLocations}
onPrev={() => loadLocations(filters, Math.max(0, locations.offset - locations.limit))}
onNext={() => loadLocations(filters, locations.offset + locations.limit)}
/>
</Tabs.TabPane>
<Tabs.TabPane tab="RAW 帧" itemKey="raw">
<Table
rowKey="id"
dataSource={rawFrames}
dataSource={rawFrames.items}
loading={loadingRaw}
pagination={false}
columns={[
{ title: 'ID', dataIndex: 'id' },
{ title: 'VIN', dataIndex: 'vin' },
{ title: '协议', dataIndex: 'protocol' },
{ title: '帧类型', dataIndex: 'frameType' },
{ title: '大小', dataIndex: 'rawSizeBytes' },
{ title: '操作', render: (_: unknown, row: RawFrameRow) => <Button onClick={() => setSelectedRaw(row)}></Button> }
{ title: 'ID', dataIndex: 'id', width: 260 },
{ title: 'VIN', dataIndex: 'vin', width: 190 },
{ 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: 100, render: (_: unknown, row: RawFrameRow) => <Button onClick={() => setSelectedRaw(row)}></Button> }
]}
/>
<HistoryPager
page={rawFrames}
loading={loadingRaw}
onPrev={() => loadRawFrames(filters, Math.max(0, rawFrames.offset - rawFrames.limit))}
onNext={() => loadRawFrames(filters, rawFrames.offset + rawFrames.limit)}
/>
</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?.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>
);
}
function HistoryPager<T>({
page,
loading,
onPrev,
onNext
}: {
page: Page<T>;
loading: boolean;
onPrev: () => void;
onNext: () => void;
}) {
const start = page.offset + 1;
const end = page.offset + page.items.length;
const hasPrevious = page.offset > 0;
const hasNext = page.items.length >= page.limit;
return (
<div className="vp-table-footer">
<Typography.Text type="tertiary">
{page.items.length === 0 ? 0 : start}-{end} {page.limit}
</Typography.Text>
<Space>
<Button icon={<IconChevronLeft />} disabled={!hasPrevious || loading} onClick={onPrev}></Button>
<Button icon={<IconChevronRight />} disabled={!hasNext || loading} onClick={onNext}></Button>
</Space>
</div>
);
}