feat(platform): make vehicle detail source agnostic
This commit is contained in:
@@ -1,33 +1,115 @@
|
|||||||
import { Button, Card, Form, SideSheet, Space, Table, Tabs, Toast } from '@douyinfe/semi-ui';
|
import { Button, Card, Form, Select, SideSheet, Space, Table, Tabs, Toast, Typography } from '@douyinfe/semi-ui';
|
||||||
import { useEffect, useState } from 'react';
|
import { IconChevronLeft, IconChevronRight, IconRefresh, IconSearch } from '@douyinfe/semi-icons';
|
||||||
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import { api } from '../api/client';
|
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';
|
import { PageHeader } from '../components/PageHeader';
|
||||||
|
|
||||||
export function History() {
|
type HistoryFilters = {
|
||||||
const [locations, setLocations] = useState<HistoryLocationRow[]>([]);
|
vin?: string;
|
||||||
const [rawFrames, setRawFrames] = useState<RawFrameRow[]>([]);
|
protocol?: string;
|
||||||
const [selectedRaw, setSelectedRaw] = useState<RawFrameRow | null>(null);
|
dateFrom?: string;
|
||||||
|
dateTo?: string;
|
||||||
const load = (values?: Record<string, string>) => {
|
fields?: string;
|
||||||
const params = new URLSearchParams({ limit: '20', includeFields: 'true' });
|
includeFields?: boolean;
|
||||||
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));
|
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => load(), []);
|
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;
|
||||||
|
};
|
||||||
|
|
||||||
|
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 (
|
return (
|
||||||
<div className="vp-page">
|
<div className="vp-page">
|
||||||
<PageHeader title="历史查询" description="位置历史和 RAW 帧历史的分页查询工作台" />
|
<PageHeader title="历史查询" description="位置历史和 RAW 帧历史的分页查询工作台" />
|
||||||
<Card bordered>
|
<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="vin" label="VIN" placeholder="输入 VIN" style={{ width: 260 }} />
|
||||||
<Form.Input field="protocol" label="协议" placeholder="GB32960 / JT808 / YUTONG_MQTT" style={{ width: 240 }} />
|
<Form.Select field="protocol" label="协议" placeholder="全部协议" style={{ width: 190 }}>
|
||||||
<Space>
|
<Select.Option value="GB32960">GB32960</Select.Option>
|
||||||
<Button htmlType="submit" theme="solid" type="primary">查询</Button>
|
<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>
|
</Space>
|
||||||
</Form>
|
</Form>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -36,38 +118,92 @@ export function History() {
|
|||||||
<Tabs.TabPane tab="位置历史" itemKey="location">
|
<Tabs.TabPane tab="位置历史" itemKey="location">
|
||||||
<Table
|
<Table
|
||||||
rowKey="deviceTime"
|
rowKey="deviceTime"
|
||||||
dataSource={locations}
|
dataSource={locations.items}
|
||||||
|
loading={loadingLocations}
|
||||||
pagination={false}
|
pagination={false}
|
||||||
columns={[
|
columns={[
|
||||||
{ title: 'VIN', dataIndex: 'vin' },
|
{ title: 'VIN', dataIndex: 'vin', width: 190 },
|
||||||
{ title: '协议', dataIndex: 'protocol' },
|
{ title: '协议', dataIndex: 'protocol', width: 120 },
|
||||||
{ title: '经度', dataIndex: 'longitude' },
|
{ title: '经度', dataIndex: 'longitude', width: 120 },
|
||||||
{ title: '纬度', dataIndex: 'latitude' },
|
{ title: '纬度', dataIndex: 'latitude', width: 120 },
|
||||||
{ title: '速度', dataIndex: 'speedKmh' },
|
{ title: '速度 km/h', dataIndex: 'speedKmh', width: 120 },
|
||||||
{ title: '设备时间', dataIndex: 'deviceTime' }
|
{ 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>
|
||||||
<Tabs.TabPane tab="RAW 帧" itemKey="raw">
|
<Tabs.TabPane tab="RAW 帧" itemKey="raw">
|
||||||
<Table
|
<Table
|
||||||
rowKey="id"
|
rowKey="id"
|
||||||
dataSource={rawFrames}
|
dataSource={rawFrames.items}
|
||||||
|
loading={loadingRaw}
|
||||||
pagination={false}
|
pagination={false}
|
||||||
columns={[
|
columns={[
|
||||||
{ title: 'ID', dataIndex: 'id' },
|
{ title: 'ID', dataIndex: 'id', width: 260 },
|
||||||
{ title: 'VIN', dataIndex: 'vin' },
|
{ title: 'VIN', dataIndex: 'vin', width: 190 },
|
||||||
{ title: '协议', dataIndex: 'protocol' },
|
{ title: '协议', dataIndex: 'protocol', width: 120 },
|
||||||
{ title: '帧类型', dataIndex: 'frameType' },
|
{ title: '帧类型', dataIndex: 'frameType', width: 190 },
|
||||||
{ title: '大小', dataIndex: 'rawSizeBytes' },
|
{ title: '大小 B', dataIndex: 'rawSizeBytes', width: 100 },
|
||||||
{ title: '操作', render: (_: unknown, row: RawFrameRow) => <Button onClick={() => setSelectedRaw(row)}>字段</Button> }
|
{ 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.TabPane>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
</Card>
|
</Card>
|
||||||
<SideSheet title="RAW 解析字段" visible={Boolean(selectedRaw)} onCancel={() => setSelectedRaw(null)} width={720}>
|
<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>
|
<pre className="vp-json">{JSON.stringify(selectedRaw?.parsedFields ?? {}, null, 2)}</pre>
|
||||||
</SideSheet>
|
</SideSheet>
|
||||||
</div>
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,52 +1,195 @@
|
|||||||
import { Card, Descriptions, Table, Tabs, Toast } from '@douyinfe/semi-ui';
|
import { Button, Card, Descriptions, Form, Select, Space, Table, Tabs, Tag, Toast, Typography } from '@douyinfe/semi-ui';
|
||||||
import { useEffect, useState } from 'react';
|
import { IconRefresh, IconSearch } from '@douyinfe/semi-icons';
|
||||||
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import { api } from '../api/client';
|
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 { 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() {
|
export function VehicleDetail() {
|
||||||
const [latest, setLatest] = useState<RealtimeLocationRow | null>(null);
|
const [query, setQuery] = useState<VehicleQuery>(defaultQuery);
|
||||||
const [raw, setRaw] = useState<RawFrameRow | null>(null);
|
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(() => {
|
useEffect(() => {
|
||||||
const params = new URLSearchParams({ vin: 'LB9A32A24R0LS1426', limit: '1' });
|
load(defaultQuery);
|
||||||
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));
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
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 (
|
return (
|
||||||
<div className="vp-page">
|
<div className="vp-page">
|
||||||
<PageHeader title="车辆详情" description="单车身份、实时、历史、RAW、里程和质量的综合视图" />
|
<PageHeader title="车辆服务" description="以 VIN 为主对象聚合身份、实时、历史、RAW 和里程,协议仅作为数据来源" />
|
||||||
<Card bordered>
|
<Card bordered>
|
||||||
<Descriptions row data={[
|
<Form initValues={query} layout="horizontal" onSubmit={(values) => {
|
||||||
{ key: 'VIN', value: latest?.vin ?? 'LB9A32A24R0LS1426' },
|
const nextQuery = { vin: String(values.vin ?? ''), protocol: String(values.protocol ?? '') };
|
||||||
{ key: '车牌', value: latest?.plate ?? '-' },
|
setQuery(nextQuery);
|
||||||
{ key: '协议', value: latest?.protocol ?? '-' },
|
load(nextQuery);
|
||||||
{ key: '最后时间', value: latest?.lastSeen ?? '-' }
|
}}>
|
||||||
]} />
|
<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>
|
||||||
|
|
||||||
|
<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 }}>
|
<Card bordered style={{ marginTop: 16 }}>
|
||||||
<Tabs>
|
<Tabs>
|
||||||
<Tabs.TabPane tab="最新状态" itemKey="latest">
|
<Tabs.TabPane tab="实时状态" itemKey="realtime">
|
||||||
<Table
|
<Table
|
||||||
|
loading={loading}
|
||||||
pagination={false}
|
pagination={false}
|
||||||
dataSource={latest ? [latest] : []}
|
rowKey="protocol"
|
||||||
|
dataSource={state.realtime}
|
||||||
columns={[
|
columns={[
|
||||||
{ title: '经度', dataIndex: 'longitude' },
|
{ title: '来源', dataIndex: 'protocol', width: 130 },
|
||||||
{ title: '纬度', dataIndex: 'latitude' },
|
{ title: '经度', dataIndex: 'longitude', width: 120 },
|
||||||
{ title: '速度', dataIndex: 'speedKmh' },
|
{ title: '纬度', dataIndex: 'latitude', width: 120 },
|
||||||
{ title: 'SOC', dataIndex: 'socPercent' },
|
{ title: '速度 km/h', dataIndex: 'speedKmh', width: 120 },
|
||||||
{ title: '总里程', dataIndex: 'totalMileageKm' }
|
{ 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>
|
||||||
<Tabs.TabPane tab="RAW 字段" itemKey="raw">
|
<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.TabPane>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -140,6 +140,21 @@ body {
|
|||||||
line-height: 1.65;
|
line-height: 1.65;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.vp-table-footer {
|
||||||
|
min-height: 52px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
|
padding-top: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vp-vehicle-summary {
|
||||||
|
min-height: 72px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
.semi-navigation-item-text {
|
.semi-navigation-item-text {
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,10 @@ The UI is a professional vehicle data operations console. It uses Semi UI as the
|
|||||||
6. Mileage: daily and range mileage analysis.
|
6. Mileage: daily and range mileage analysis.
|
||||||
7. Quality: data quality and link health workspace.
|
7. Quality: data quality and link health workspace.
|
||||||
|
|
||||||
|
## Domain Principle
|
||||||
|
|
||||||
|
The product model is vehicle-first. GB32960, JT808, and Yutong MQTT are ingestion sources for one vehicle service, not separate business products. The UI should lead with VIN, plate, online state, location, mileage, and data quality; protocol appears only as source attribution, diagnosis, and raw-frame traceability.
|
||||||
|
|
||||||
## Interaction Rules
|
## Interaction Rules
|
||||||
|
|
||||||
- Tables are the default data surface.
|
- Tables are the default data surface.
|
||||||
@@ -21,4 +25,3 @@ The UI is a professional vehicle data operations console. It uses Semi UI as the
|
|||||||
- Details open in a right drawer unless a full route is needed.
|
- Details open in a right drawer unless a full route is needed.
|
||||||
- Error and empty states must be explicit.
|
- Error and empty states must be explicit.
|
||||||
- No decorative hero, no oversized marketing card layout, no dark large-screen style.
|
- No decorative hero, no oversized marketing card layout, no dark large-screen style.
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user