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,52 +1,195 @@
import { Card, Descriptions, Table, Tabs, Toast } from '@douyinfe/semi-ui';
import { useEffect, useState } from 'react';
import { Button, Card, Descriptions, Form, Select, Space, Table, Tabs, Tag, 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 { RawFrameRow, RealtimeLocationRow } from '../api/types';
import type { DailyMileageRow, HistoryLocationRow, RawFrameRow, RealtimeLocationRow, VehicleRow } from '../api/types';
import { PageHeader } from '../components/PageHeader';
import { StatusTag } from '../components/StatusTag';
type VehicleServiceState = {
vehicles: VehicleRow[];
realtime: RealtimeLocationRow[];
history: HistoryLocationRow[];
raw: RawFrameRow[];
mileage: DailyMileageRow[];
};
type VehicleQuery = {
vin: string;
protocol?: string;
};
const defaultQuery: VehicleQuery = {
vin: 'LB9A32A24R0LS1426'
};
const emptyState: VehicleServiceState = {
vehicles: [],
realtime: [],
history: [],
raw: [],
mileage: []
};
export function VehicleDetail() {
const [latest, setLatest] = useState<RealtimeLocationRow | null>(null);
const [raw, setRaw] = useState<RawFrameRow | null>(null);
const [query, setQuery] = useState<VehicleQuery>(defaultQuery);
const [state, setState] = useState<VehicleServiceState>(emptyState);
const [loading, setLoading] = useState(false);
const load = (nextQuery = query) => {
const vin = nextQuery.vin.trim();
if (!vin) {
Toast.warning('请输入 VIN');
return;
}
setLoading(true);
const scoped = new URLSearchParams({ vin, limit: '20' });
const vehicleParams = new URLSearchParams({ keyword: vin, limit: '5' });
const rawParams = new URLSearchParams({ vin, limit: '10', includeFields: 'true' });
const mileageParams = new URLSearchParams({ vin, limit: '20' });
if (nextQuery.protocol?.trim()) {
scoped.set('protocol', nextQuery.protocol.trim());
rawParams.set('protocol', nextQuery.protocol.trim());
}
Promise.all([
api.vehicles(vehicleParams),
api.realtimeLocations(scoped),
api.historyLocations(scoped),
api.rawFrames(rawParams),
api.dailyMileage(mileageParams)
])
.then(([vehicles, realtime, history, raw, mileage]) => {
setState({
vehicles: vehicles.items,
realtime: realtime.items,
history: history.items,
raw: raw.items,
mileage: mileage.items
});
})
.catch((error: Error) => Toast.error(error.message))
.finally(() => setLoading(false));
};
useEffect(() => {
const params = new URLSearchParams({ vin: 'LB9A32A24R0LS1426', limit: '1' });
api.realtimeLocations(params)
.then((page) => setLatest(page.items[0] ?? null))
.catch((error: Error) => Toast.error(error.message));
params.set('includeFields', 'true');
api.rawFrames(params)
.then((page) => setRaw(page.items[0] ?? null))
.catch((error: Error) => Toast.error(error.message));
load(defaultQuery);
}, []);
const identity = state.vehicles[0];
const latest = state.realtime[0];
const protocols = useMemo(
() => Array.from(new Set([...state.vehicles.map((row) => row.protocol), ...state.realtime.map((row) => row.protocol)].filter(Boolean))),
[state.realtime, state.vehicles]
);
const latestRaw = state.raw[0];
return (
<div className="vp-page">
<PageHeader title="车辆详情" description="单车身份、实时、历史、RAW、里程和质量的综合视图" />
<PageHeader title="车辆服务" description="以 VIN 为主对象聚合身份、实时、历史、RAW 和里程,协议仅作为数据来源" />
<Card bordered>
<Descriptions row data={[
{ key: 'VIN', value: latest?.vin ?? 'LB9A32A24R0LS1426' },
{ key: '车牌', value: latest?.plate ?? '-' },
{ key: '协议', value: latest?.protocol ?? '-' },
{ key: '最后时间', value: latest?.lastSeen ?? '-' }
]} />
<Form initValues={query} layout="horizontal" onSubmit={(values) => {
const nextQuery = { vin: String(values.vin ?? ''), protocol: String(values.protocol ?? '') };
setQuery(nextQuery);
load(nextQuery);
}}>
<Form.Input field="vin" label="VIN" placeholder="输入 VIN" style={{ width: 280 }} />
<Form.Select field="protocol" label="来源协议" placeholder="全部来源" style={{ width: 180 }}>
<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>
<Space>
<Button icon={<IconSearch />} htmlType="submit" theme="solid" type="primary"></Button>
<Button icon={<IconRefresh />} onClick={() => load(query)} loading={loading}></Button>
</Space>
</Form>
</Card>
<Card bordered style={{ marginTop: 16 }}>
<div className="vp-vehicle-summary">
<Descriptions
row
data={[
{ key: 'VIN', value: identity?.vin ?? latest?.vin ?? query.vin },
{ key: '车牌', value: identity?.plate || latest?.plate || '-' },
{ key: '手机号', value: identity?.phone || '-' },
{ key: 'OEM', value: identity?.oem || '-' },
{ key: '在线', value: <StatusTag status={identity?.online || latest ? 'ok' : 'offline'} /> },
{ key: '来源协议', value: protocols.length > 0 ? <Space>{protocols.map((item) => <Tag key={item} color="blue">{item}</Tag>)}</Space> : '-' },
{ key: '最后位置时间', value: latest?.lastSeen ?? '-' },
{ key: '最新 RAW 时间', value: latestRaw?.serverTime ?? '-' }
]}
/>
</div>
</Card>
<Card bordered style={{ marginTop: 16 }}>
<Tabs>
<Tabs.TabPane tab="最新状态" itemKey="latest">
<Tabs.TabPane tab="实时状态" itemKey="realtime">
<Table
loading={loading}
pagination={false}
dataSource={latest ? [latest] : []}
rowKey="protocol"
dataSource={state.realtime}
columns={[
{ title: '经度', dataIndex: 'longitude' },
{ title: '度', dataIndex: 'latitude' },
{ title: '度', dataIndex: 'speedKmh' },
{ title: 'SOC', dataIndex: 'socPercent' },
{ title: '总里程', dataIndex: 'totalMileageKm' }
{ title: '来源', dataIndex: 'protocol', width: 130 },
{ title: '度', dataIndex: 'longitude', width: 120 },
{ title: '度', dataIndex: 'latitude', width: 120 },
{ title: '速度 km/h', dataIndex: 'speedKmh', width: 120 },
{ title: 'SOC %', dataIndex: 'socPercent', width: 120 },
{ title: '总里程 km', dataIndex: 'totalMileageKm', width: 130 },
{ title: '最后时间', dataIndex: 'lastSeen', width: 190 }
]}
/>
</Tabs.TabPane>
<Tabs.TabPane tab="历史位置" itemKey="history">
<Table
loading={loading}
pagination={false}
rowKey="deviceTime"
dataSource={state.history}
columns={[
{ title: '来源', dataIndex: 'protocol', width: 130 },
{ 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 }
]}
/>
</Tabs.TabPane>
<Tabs.TabPane tab="RAW 字段" itemKey="raw">
<pre className="vp-json">{JSON.stringify(raw?.parsedFields ?? {}, null, 2)}</pre>
<Table
loading={loading}
pagination={false}
rowKey="id"
dataSource={state.raw}
columns={[
{ title: '来源', dataIndex: 'protocol', width: 130 },
{ title: '帧类型', dataIndex: 'frameType', width: 190 },
{ title: '大小 B', dataIndex: 'rawSizeBytes', width: 100 },
{ title: '设备时间', dataIndex: 'deviceTime', width: 190 },
{ title: '入库时间', dataIndex: 'serverTime', width: 190 }
]}
/>
<Typography.Text type="tertiary"> RAW </Typography.Text>
<pre className="vp-json">{JSON.stringify(latestRaw?.parsedFields ?? {}, null, 2)}</pre>
</Tabs.TabPane>
<Tabs.TabPane tab="里程" itemKey="mileage">
<Table
loading={loading}
pagination={false}
rowKey="date"
dataSource={state.mileage}
columns={[
{ title: '日期', dataIndex: 'date', width: 130 },
{ title: '来源', dataIndex: 'source', width: 130 },
{ title: '起始里程', dataIndex: 'startMileageKm', width: 130 },
{ title: '结束里程', dataIndex: 'endMileageKm', width: 130 },
{ title: '日里程', dataIndex: 'dailyMileageKm', width: 130 }
]}
/>
</Tabs.TabPane>
</Tabs>
</Card>