feat(platform): add vehicle data management console

This commit is contained in:
lingniu
2026-07-03 20:55:54 +08:00
parent 0d8916df47
commit 859bc3e9ee
45 changed files with 6837 additions and 0 deletions

View File

@@ -0,0 +1,124 @@
import { Card, Col, Row, Spin, Table, Tag, Toast, Typography } from '@douyinfe/semi-ui';
import { useEffect, useState } from 'react';
import { api } from '../api/client';
import type { DashboardSummary, LinkHealth, ProtocolStat, RealtimeLocationRow, VehicleRow } from '../api/types';
import { PageHeader } from '../components/PageHeader';
const statusColor: Record<string, 'green' | 'orange' | 'red' | 'grey'> = {
ok: 'green',
warning: 'orange',
error: 'red'
};
export function Dashboard() {
const [summary, setSummary] = useState<DashboardSummary | null>(null);
const [vehicles, setVehicles] = useState<VehicleRow[]>([]);
const [locations, setLocations] = useState<RealtimeLocationRow[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
Promise.all([
api.dashboardSummary(),
api.vehicles(new URLSearchParams({ limit: '5' })),
api.realtimeLocations(new URLSearchParams({ limit: '8' }))
])
.then(([nextSummary, vehiclePage, locationPage]) => {
setSummary(nextSummary);
setVehicles(vehiclePage.items);
setLocations(locationPage.items);
})
.catch((error: Error) => Toast.error(error.message))
.finally(() => setLoading(false));
}, []);
const kpis = [
{ label: '在线车辆', value: summary?.onlineVehicles ?? 0 },
{ label: '今日活跃', value: summary?.activeToday ?? 0 },
{ label: '今日帧数', value: summary?.frameToday.toLocaleString() ?? '0' },
{ label: '问题车辆', value: summary?.issueVehicles ?? 0 }
];
return (
<div className="vp-page">
<PageHeader title="总览工作台" description="车辆在线、协议分布、数据质量和链路健康的统一入口" />
<Spin spinning={loading}>
<div className="vp-kpi-grid">
{kpis.map((item) => (
<Card key={item.label} bordered>
<div className="vp-kpi-value">{item.value}</div>
<div className="vp-kpi-label">{item.label}</div>
</Card>
))}
</div>
<Row gutter={16}>
<Col span={14}>
<Card title="协议在线分布" bordered>
<Table
pagination={false}
dataSource={summary?.protocols ?? []}
columns={[
{ title: '协议', dataIndex: 'protocol' },
{ title: '在线', dataIndex: 'online' },
{ title: '总数', dataIndex: 'total' },
{
title: '在线率',
render: (_: unknown, row: ProtocolStat) => `${Math.round((row.online / row.total) * 100)}%`
}
]}
/>
</Card>
</Col>
<Col span={10}>
<Card title="链路健康" bordered>
<Table
pagination={false}
dataSource={summary?.linkHealth ?? []}
columns={[
{ title: '链路', dataIndex: 'name' },
{
title: '状态',
render: (_: unknown, row: LinkHealth) => <Tag color={statusColor[row.status] ?? 'grey'}>{row.status}</Tag>
},
{ title: '说明', dataIndex: 'detail' }
]}
/>
</Card>
</Col>
</Row>
<Card title="实时积压" bordered style={{ marginTop: 16 }}>
<Typography.Text>Kafka {summary?.kafkaLag ?? 0}</Typography.Text>
</Card>
<Row gutter={16} style={{ marginTop: 16 }}>
<Col span={12}>
<Card title="实时位置预览" bordered>
<div className="vp-map" style={{ height: 260 }}>
{locations.map((row, index) => (
<span
key={row.vin}
className="vp-map-dot"
title={`${row.plate} ${row.protocol}`}
style={{ left: `${18 + index * 13}%`, top: `${24 + (index % 4) * 15}%` }}
/>
))}
</div>
</Card>
</Col>
<Col span={12}>
<Card title="最新车辆" bordered>
<Table
pagination={false}
dataSource={vehicles}
columns={[
{ title: '车牌', dataIndex: 'plate' },
{ title: 'VIN', dataIndex: 'vin' },
{ title: '协议', dataIndex: 'protocol' },
{ title: '最后时间', dataIndex: 'lastSeen' }
]}
/>
</Card>
</Col>
</Row>
</Spin>
</div>
);
}

View File

@@ -0,0 +1,73 @@
import { Button, Card, Form, SideSheet, Space, Table, Tabs, Toast } from '@douyinfe/semi-ui';
import { useEffect, useState } from 'react';
import { api } from '../api/client';
import type { HistoryLocationRow, 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);
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));
};
useEffect(() => load(), []);
return (
<div className="vp-page">
<PageHeader title="历史查询" description="位置历史和 RAW 帧历史的分页查询工作台" />
<Card bordered>
<Form layout="horizontal" onSubmit={(values) => load(values as Record<string, string>)}>
<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>
</Space>
</Form>
</Card>
<Card bordered style={{ marginTop: 16 }}>
<Tabs>
<Tabs.TabPane tab="位置历史" itemKey="location">
<Table
rowKey="deviceTime"
dataSource={locations}
pagination={false}
columns={[
{ title: 'VIN', dataIndex: 'vin' },
{ title: '协议', dataIndex: 'protocol' },
{ title: '经度', dataIndex: 'longitude' },
{ title: '纬度', dataIndex: 'latitude' },
{ title: '速度', dataIndex: 'speedKmh' },
{ title: '设备时间', dataIndex: 'deviceTime' }
]}
/>
</Tabs.TabPane>
<Tabs.TabPane tab="RAW 帧" itemKey="raw">
<Table
rowKey="id"
dataSource={rawFrames}
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> }
]}
/>
</Tabs.TabPane>
</Tabs>
</Card>
<SideSheet title="RAW 解析字段" visible={Boolean(selectedRaw)} onCancel={() => setSelectedRaw(null)} width={720}>
<pre className="vp-json">{JSON.stringify(selectedRaw?.parsedFields ?? {}, null, 2)}</pre>
</SideSheet>
</div>
);
}

View File

@@ -0,0 +1,44 @@
import { Card, DatePicker, Form, Table, Tag, Toast } from '@douyinfe/semi-ui';
import { useEffect, useState } from 'react';
import { api } from '../api/client';
import type { DailyMileageRow } from '../api/types';
import { PageHeader } from '../components/PageHeader';
export function Mileage() {
const [rows, setRows] = useState<DailyMileageRow[]>([]);
useEffect(() => {
api.dailyMileage(new URLSearchParams({ limit: '20' }))
.then((page) => setRows(page.items))
.catch((error: Error) => Toast.error(error.message));
}, []);
return (
<div className="vp-page">
<PageHeader title="里程分析" description="每日里程、区间里程和异常差值分析" />
<Card bordered>
<Form layout="horizontal">
<Form.Input field="vin" label="VIN" placeholder="输入 VIN" style={{ width: 260 }} />
<DatePicker type="dateRange" density="compact" />
</Form>
</Card>
<Card bordered style={{ marginTop: 16 }}>
<Table
rowKey="vin"
dataSource={rows}
pagination={false}
columns={[
{ title: '日期', dataIndex: 'date' },
{ title: 'VIN', dataIndex: 'vin' },
{ title: '车牌', dataIndex: 'plate' },
{ title: '起始里程', dataIndex: 'startMileageKm' },
{ title: '结束里程', dataIndex: 'endMileageKm' },
{ title: '日里程', dataIndex: 'dailyMileageKm' },
{ title: '来源', dataIndex: 'source' },
{ title: '异常', render: (_: unknown, row: DailyMileageRow) => row.anomalySeverity ? <Tag color="orange">{row.anomalySeverity}</Tag> : <Tag color="green"></Tag> }
]}
/>
</Card>
</div>
);
}

View File

@@ -0,0 +1,57 @@
import { Card, Col, Row, Table, Tag, Toast } from '@douyinfe/semi-ui';
import { useEffect, useState } from 'react';
import { api } from '../api/client';
import type { OpsHealth, QualityIssueRow } from '../api/types';
import { PageHeader } from '../components/PageHeader';
export function Quality() {
const [issues, setIssues] = useState<QualityIssueRow[]>([]);
const [health, setHealth] = useState<OpsHealth | null>(null);
useEffect(() => {
api.qualityIssues(new URLSearchParams({ limit: '20' }))
.then((page) => setIssues(page.items))
.catch((error: Error) => Toast.error(error.message));
api.opsHealth()
.then(setHealth)
.catch((error: Error) => Toast.error(error.message));
}, []);
return (
<div className="vp-page">
<PageHeader title="数据质量" description="断链、VIN 缺失、字段缺失和链路健康的排查入口" />
<Row gutter={16}>
<Col span={8}><Card bordered title="Kafka Lag">{health?.kafkaLag ?? 0}</Card></Col>
<Col span={8}><Card bordered title="Redis 在线 Key">{health?.redisOnlineKeys ?? 0}</Card></Col>
<Col span={8}><Card bordered title="存储写入">{health?.tdengineWritable && health.mysqlWritable ? '正常' : '异常'}</Card></Col>
</Row>
<Card bordered title="质量问题" style={{ marginTop: 16 }}>
<Table
rowKey="vin"
dataSource={issues}
pagination={false}
columns={[
{ title: 'VIN', dataIndex: 'vin' },
{ title: '车牌', dataIndex: 'plate' },
{ title: '协议', dataIndex: 'protocol' },
{ title: '问题', dataIndex: 'issueType' },
{ title: '级别', render: (_: unknown, row: QualityIssueRow) => <Tag color={row.severity === 'error' ? 'red' : 'orange'}>{row.severity}</Tag> },
{ title: '最后时间', dataIndex: 'lastSeen' },
{ title: '说明', dataIndex: 'detail' }
]}
/>
</Card>
<Card bordered title="链路健康" style={{ marginTop: 16 }}>
<Table
dataSource={health?.linkHealth ?? []}
pagination={false}
columns={[
{ title: '链路', dataIndex: 'name' },
{ title: '状态', dataIndex: 'status' },
{ title: '说明', dataIndex: 'detail' }
]}
/>
</Card>
</div>
);
}

View File

@@ -0,0 +1,61 @@
import { Card, Table, Tabs, Toast } from '@douyinfe/semi-ui';
import { useEffect, useState } from 'react';
import { api } from '../api/client';
import type { RealtimeLocationRow } from '../api/types';
import { DataEmpty } from '../components/DataEmpty';
import { PageHeader } from '../components/PageHeader';
export function Realtime() {
const [rows, setRows] = useState<RealtimeLocationRow[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
api.realtimeLocations(new URLSearchParams({ limit: '50' }))
.then((page) => setRows(page.items))
.catch((error: Error) => Toast.error(error.message))
.finally(() => setLoading(false));
}, []);
return (
<div className="vp-page">
<PageHeader title="实时状态" description="按协议和车辆查看最新实时位置、在线状态和核心数据" />
<Card bordered>
<Tabs type="line">
<Tabs.TabPane tab="表格视图" itemKey="table">
{rows.length === 0 && !loading ? (
<DataEmpty />
) : (
<Table
loading={loading}
rowKey="vin"
dataSource={rows}
pagination={false}
columns={[
{ title: 'VIN', dataIndex: 'vin' },
{ title: '车牌', dataIndex: 'plate' },
{ title: '协议', dataIndex: 'protocol' },
{ title: '速度 km/h', dataIndex: 'speedKmh' },
{ title: 'SOC %', dataIndex: 'socPercent' },
{ title: '总里程 km', dataIndex: 'totalMileageKm' },
{ title: '最后时间', dataIndex: 'lastSeen' }
]}
/>
)}
</Tabs.TabPane>
<Tabs.TabPane tab="地图视图" itemKey="map">
<div className="vp-map">
{rows.map((row, index) => (
<span
key={row.vin}
className="vp-map-dot"
title={`${row.plate} ${row.protocol}`}
style={{ left: `${18 + index * 24}%`, top: `${26 + (index % 3) * 18}%` }}
/>
))}
</div>
</Tabs.TabPane>
</Tabs>
</Card>
</div>
);
}

View File

@@ -0,0 +1,55 @@
import { Card, Descriptions, Table, Tabs, Toast } from '@douyinfe/semi-ui';
import { useEffect, useState } from 'react';
import { api } from '../api/client';
import type { RawFrameRow, RealtimeLocationRow } from '../api/types';
import { PageHeader } from '../components/PageHeader';
export function VehicleDetail() {
const [latest, setLatest] = useState<RealtimeLocationRow | null>(null);
const [raw, setRaw] = useState<RawFrameRow | null>(null);
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));
}, []);
return (
<div className="vp-page">
<PageHeader title="车辆详情" description="单车身份、实时、历史、RAW、里程和质量的综合视图" />
<Card bordered>
<Descriptions row data={[
{ key: 'VIN', value: latest?.vin ?? 'LB9A32A24R0LS1426' },
{ key: '车牌', value: latest?.plate ?? '-' },
{ key: '协议', value: latest?.protocol ?? '-' },
{ key: '最后时间', value: latest?.lastSeen ?? '-' }
]} />
</Card>
<Card bordered style={{ marginTop: 16 }}>
<Tabs>
<Tabs.TabPane tab="最新状态" itemKey="latest">
<Table
pagination={false}
dataSource={latest ? [latest] : []}
columns={[
{ title: '经度', dataIndex: 'longitude' },
{ title: '纬度', dataIndex: 'latitude' },
{ title: '速度', dataIndex: 'speedKmh' },
{ title: 'SOC', dataIndex: 'socPercent' },
{ title: '总里程', dataIndex: 'totalMileageKm' }
]}
/>
</Tabs.TabPane>
<Tabs.TabPane tab="RAW 字段" itemKey="raw">
<pre className="vp-json">{JSON.stringify(raw?.parsedFields ?? {}, null, 2)}</pre>
</Tabs.TabPane>
</Tabs>
</Card>
</div>
);
}

View File

@@ -0,0 +1,72 @@
import { Button, Card, Form, Select, SideSheet, Space, Table, TextArea, Toast } from '@douyinfe/semi-ui';
import { useEffect, useMemo, useState } from 'react';
import { api } from '../api/client';
import type { VehicleRow } from '../api/types';
import { DataEmpty } from '../components/DataEmpty';
import { PageHeader } from '../components/PageHeader';
import { StatusTag } from '../components/StatusTag';
export function Vehicles() {
const [rows, setRows] = useState<VehicleRow[]>([]);
const [loading, setLoading] = useState(true);
const [selected, setSelected] = useState<VehicleRow | null>(null);
const load = (values?: Record<string, string>) => {
setLoading(true);
const params = new URLSearchParams({ limit: '20' });
if (values?.keyword) params.set('keyword', values.keyword);
if (values?.protocol) params.set('protocol', values.protocol);
api.vehicles(params)
.then((page) => setRows(page.items))
.catch((error: Error) => Toast.error(error.message))
.finally(() => setLoading(false));
};
useEffect(() => load(), []);
const columns = useMemo(
() => [
{ title: 'VIN', dataIndex: 'vin', width: 190 },
{ title: '车牌', dataIndex: 'plate', width: 120 },
{ title: '手机号', dataIndex: 'phone', width: 130 },
{ title: 'OEM', dataIndex: 'oem', width: 120 },
{ title: '协议', dataIndex: 'protocol', width: 120 },
{ title: '在线', render: (_: unknown, row: VehicleRow) => <StatusTag status={row.online ? 'ok' : 'offline'} /> },
{ title: '最后在线', dataIndex: 'lastSeen', width: 170 },
{ title: '位置', dataIndex: 'locationText' },
{ title: '绑定分', dataIndex: 'bindingScore', width: 90 },
{ title: '操作', render: (_: unknown, row: VehicleRow) => <Button onClick={() => setSelected(row)}></Button> }
],
[]
);
return (
<div className="vp-page">
<PageHeader title="车辆台账" description="车辆身份、协议绑定、车牌、手机号和 OEM 的运营台账" />
<Card bordered>
<Form layout="horizontal" onSubmit={(values) => load(values as Record<string, string>)}>
<Form.Input field="keyword" label="关键词" placeholder="VIN / 车牌 / 手机号" style={{ width: 260 }} />
<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 htmlType="submit" theme="solid" type="primary"></Button>
<Button onClick={() => load()}></Button>
</Space>
</Form>
</Card>
<Card bordered style={{ marginTop: 16 }}>
{rows.length === 0 && !loading ? (
<DataEmpty />
) : (
<Table loading={loading} rowKey="vin" dataSource={rows} columns={columns} pagination={false} />
)}
</Card>
<SideSheet title="车辆详情" visible={Boolean(selected)} onCancel={() => setSelected(null)} width={520}>
<TextArea value={JSON.stringify(selected, null, 2)} autosize readOnly />
</SideSheet>
</div>
);
}