feat(platform): make vehicle detail source agnostic
This commit is contained in:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user