feat(platform): add vehicle data management console
This commit is contained in:
28
vehicle-data-platform/apps/web/src/App.tsx
Normal file
28
vehicle-data-platform/apps/web/src/App.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
import { useState } from 'react';
|
||||
import { AppShell, type PageKey } from './layout/AppShell';
|
||||
import { Dashboard } from './pages/Dashboard';
|
||||
import { History } from './pages/History';
|
||||
import { Mileage } from './pages/Mileage';
|
||||
import { Quality } from './pages/Quality';
|
||||
import { Realtime } from './pages/Realtime';
|
||||
import { VehicleDetail } from './pages/VehicleDetail';
|
||||
import { Vehicles } from './pages/Vehicles';
|
||||
|
||||
const pages: Record<PageKey, JSX.Element> = {
|
||||
dashboard: <Dashboard />,
|
||||
vehicles: <Vehicles />,
|
||||
realtime: <Realtime />,
|
||||
detail: <VehicleDetail />,
|
||||
history: <History />,
|
||||
mileage: <Mileage />,
|
||||
quality: <Quality />
|
||||
};
|
||||
|
||||
export default function App() {
|
||||
const [activePage, setActivePage] = useState<PageKey>('dashboard');
|
||||
return (
|
||||
<AppShell activePage={activePage} onChange={setActivePage}>
|
||||
{pages[activePage]}
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
32
vehicle-data-platform/apps/web/src/api/client.ts
Normal file
32
vehicle-data-platform/apps/web/src/api/client.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import type {
|
||||
ApiEnvelope,
|
||||
DailyMileageRow,
|
||||
DashboardSummary,
|
||||
HistoryLocationRow,
|
||||
OpsHealth,
|
||||
Page,
|
||||
QualityIssueRow,
|
||||
RawFrameRow,
|
||||
RealtimeLocationRow,
|
||||
VehicleRow
|
||||
} from './types';
|
||||
|
||||
async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
const response = await fetch(path, init);
|
||||
if (!response.ok) {
|
||||
throw new Error(`request failed ${response.status}`);
|
||||
}
|
||||
const envelope = (await response.json()) as ApiEnvelope<T>;
|
||||
return envelope.data;
|
||||
}
|
||||
|
||||
export const api = {
|
||||
dashboardSummary: () => request<DashboardSummary>('/api/dashboard/summary'),
|
||||
vehicles: (params = new URLSearchParams()) => request<Page<VehicleRow>>(`/api/vehicles?${params.toString()}`),
|
||||
realtimeLocations: (params = new URLSearchParams()) => request<Page<RealtimeLocationRow>>(`/api/realtime/locations?${params.toString()}`),
|
||||
historyLocations: (params = new URLSearchParams()) => request<Page<HistoryLocationRow>>(`/api/history/locations?${params.toString()}`),
|
||||
rawFrames: (params = new URLSearchParams()) => request<Page<RawFrameRow>>(`/api/history/raw-frames?${params.toString()}`),
|
||||
dailyMileage: (params = new URLSearchParams()) => request<Page<DailyMileageRow>>(`/api/mileage/daily?${params.toString()}`),
|
||||
qualityIssues: (params = new URLSearchParams()) => request<Page<QualityIssueRow>>(`/api/quality/issues?${params.toString()}`),
|
||||
opsHealth: () => request<OpsHealth>('/api/ops/health')
|
||||
};
|
||||
103
vehicle-data-platform/apps/web/src/api/types.ts
Normal file
103
vehicle-data-platform/apps/web/src/api/types.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
export interface ApiEnvelope<T> {
|
||||
data: T;
|
||||
traceId: string;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
export interface ProtocolStat {
|
||||
protocol: string;
|
||||
online: number;
|
||||
total: number;
|
||||
}
|
||||
|
||||
export interface LinkHealth {
|
||||
name: string;
|
||||
status: string;
|
||||
detail?: string;
|
||||
}
|
||||
|
||||
export interface DashboardSummary {
|
||||
onlineVehicles: number;
|
||||
activeToday: number;
|
||||
frameToday: number;
|
||||
issueVehicles: number;
|
||||
kafkaLag: number;
|
||||
protocols: ProtocolStat[];
|
||||
linkHealth: LinkHealth[];
|
||||
}
|
||||
|
||||
export interface VehicleRow {
|
||||
vin: string;
|
||||
plate: string;
|
||||
phone: string;
|
||||
oem: string;
|
||||
protocol: string;
|
||||
online: boolean;
|
||||
lastSeen: string;
|
||||
locationText: string;
|
||||
bindingScore: number;
|
||||
}
|
||||
|
||||
export interface RealtimeLocationRow {
|
||||
vin: string;
|
||||
plate: string;
|
||||
protocol: string;
|
||||
longitude: number;
|
||||
latitude: number;
|
||||
speedKmh: number;
|
||||
socPercent: number;
|
||||
totalMileageKm: number;
|
||||
lastSeen: string;
|
||||
}
|
||||
|
||||
export interface HistoryLocationRow extends RealtimeLocationRow {
|
||||
deviceTime: string;
|
||||
serverTime: string;
|
||||
}
|
||||
|
||||
export interface RawFrameRow {
|
||||
id: string;
|
||||
vin: string;
|
||||
protocol: string;
|
||||
frameType: string;
|
||||
deviceTime: string;
|
||||
serverTime: string;
|
||||
rawSizeBytes: number;
|
||||
parsedFields?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface DailyMileageRow {
|
||||
vin: string;
|
||||
plate: string;
|
||||
date: string;
|
||||
startMileageKm: number;
|
||||
endMileageKm: number;
|
||||
dailyMileageKm: number;
|
||||
source: string;
|
||||
anomalySeverity?: string;
|
||||
}
|
||||
|
||||
export interface QualityIssueRow {
|
||||
vin: string;
|
||||
plate: string;
|
||||
protocol: string;
|
||||
issueType: string;
|
||||
severity: string;
|
||||
lastSeen: string;
|
||||
detail: string;
|
||||
}
|
||||
|
||||
export interface OpsHealth {
|
||||
linkHealth: LinkHealth[];
|
||||
kafkaLag: number;
|
||||
redisOnlineKeys: number;
|
||||
tdengineWritable: boolean;
|
||||
mysqlWritable: boolean;
|
||||
}
|
||||
|
||||
export interface Page<T> {
|
||||
items: T[];
|
||||
total: number;
|
||||
limit: number;
|
||||
offset: number;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { Empty } from '@douyinfe/semi-ui';
|
||||
|
||||
export function DataEmpty({ text = '暂无数据' }: { text?: string }) {
|
||||
return <Empty description={text} style={{ padding: 48 }} />;
|
||||
}
|
||||
12
vehicle-data-platform/apps/web/src/components/PageHeader.tsx
Normal file
12
vehicle-data-platform/apps/web/src/components/PageHeader.tsx
Normal file
@@ -0,0 +1,12 @@
|
||||
import { Typography } from '@douyinfe/semi-ui';
|
||||
|
||||
export function PageHeader({ title, description }: { title: string; description: string }) {
|
||||
return (
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<Typography.Title heading={3} style={{ margin: 0 }}>
|
||||
{title}
|
||||
</Typography.Title>
|
||||
<Typography.Text type="tertiary">{description}</Typography.Text>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
11
vehicle-data-platform/apps/web/src/components/StatusTag.tsx
Normal file
11
vehicle-data-platform/apps/web/src/components/StatusTag.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
import { Tag } from '@douyinfe/semi-ui';
|
||||
|
||||
export function StatusTag({ status }: { status: 'ok' | 'warning' | 'error' | 'offline' }) {
|
||||
const map = {
|
||||
ok: { color: 'green' as const, text: '正常' },
|
||||
warning: { color: 'orange' as const, text: '关注' },
|
||||
error: { color: 'red' as const, text: '异常' },
|
||||
offline: { color: 'grey' as const, text: '离线' }
|
||||
};
|
||||
return <Tag color={map[status].color}>{map[status].text}</Tag>;
|
||||
}
|
||||
68
vehicle-data-platform/apps/web/src/layout/AppShell.tsx
Normal file
68
vehicle-data-platform/apps/web/src/layout/AppShell.tsx
Normal file
@@ -0,0 +1,68 @@
|
||||
import { Badge, Button, Input, Nav, Space, Tag, Typography } from '@douyinfe/semi-ui';
|
||||
import {
|
||||
IconActivity,
|
||||
IconBarChartHStroked,
|
||||
IconHistogram,
|
||||
IconHome,
|
||||
IconMapPin,
|
||||
IconSearch,
|
||||
IconServer,
|
||||
IconSetting
|
||||
} from '@douyinfe/semi-icons';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
export type PageKey = 'dashboard' | 'vehicles' | 'realtime' | 'detail' | 'history' | 'mileage' | 'quality';
|
||||
|
||||
const navItems = [
|
||||
{ itemKey: 'dashboard', text: '总览工作台', icon: <IconHome /> },
|
||||
{ itemKey: 'vehicles', text: '车辆台账', icon: <IconServer /> },
|
||||
{ itemKey: 'realtime', text: '实时状态', icon: <IconActivity /> },
|
||||
{ itemKey: 'detail', text: '车辆详情', icon: <IconSetting /> },
|
||||
{ itemKey: 'history', text: '历史查询', icon: <IconMapPin /> },
|
||||
{ itemKey: 'mileage', text: '里程分析', icon: <IconBarChartHStroked /> },
|
||||
{ itemKey: 'quality', text: '数据质量', icon: <IconHistogram /> }
|
||||
];
|
||||
|
||||
export function AppShell({
|
||||
activePage,
|
||||
onChange,
|
||||
children
|
||||
}: {
|
||||
activePage: PageKey;
|
||||
onChange: (page: PageKey) => void;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="vp-app">
|
||||
<div className="vp-shell">
|
||||
<aside className="vp-sidebar">
|
||||
<div className="vp-brand">
|
||||
<span className="vp-brand-mark" />
|
||||
<span>车辆数据中台</span>
|
||||
</div>
|
||||
<Nav
|
||||
selectedKeys={[activePage]}
|
||||
items={navItems}
|
||||
onSelect={({ itemKey }) => onChange(itemKey as PageKey)}
|
||||
style={{ maxWidth: '100%' }}
|
||||
/>
|
||||
</aside>
|
||||
<main className="vp-main">
|
||||
<header className="vp-topbar">
|
||||
<Space spacing={12}>
|
||||
<Input prefix={<IconSearch />} placeholder="搜索 VIN / 车牌 / 手机号" style={{ width: 320 }} />
|
||||
<Button theme="solid" type="primary">查询车辆</Button>
|
||||
</Space>
|
||||
<Space spacing={12}>
|
||||
<Tag color="blue">生产环境</Tag>
|
||||
<Badge count={0} type="success">
|
||||
<Typography.Text>链路正常</Typography.Text>
|
||||
</Badge>
|
||||
</Space>
|
||||
</header>
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
10
vehicle-data-platform/apps/web/src/main.tsx
Normal file
10
vehicle-data-platform/apps/web/src/main.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import App from './App';
|
||||
import './styles/global.css';
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>
|
||||
);
|
||||
124
vehicle-data-platform/apps/web/src/pages/Dashboard.tsx
Normal file
124
vehicle-data-platform/apps/web/src/pages/Dashboard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
73
vehicle-data-platform/apps/web/src/pages/History.tsx
Normal file
73
vehicle-data-platform/apps/web/src/pages/History.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
44
vehicle-data-platform/apps/web/src/pages/Mileage.tsx
Normal file
44
vehicle-data-platform/apps/web/src/pages/Mileage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
57
vehicle-data-platform/apps/web/src/pages/Quality.tsx
Normal file
57
vehicle-data-platform/apps/web/src/pages/Quality.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
61
vehicle-data-platform/apps/web/src/pages/Realtime.tsx
Normal file
61
vehicle-data-platform/apps/web/src/pages/Realtime.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
55
vehicle-data-platform/apps/web/src/pages/VehicleDetail.tsx
Normal file
55
vehicle-data-platform/apps/web/src/pages/VehicleDetail.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
72
vehicle-data-platform/apps/web/src/pages/Vehicles.tsx
Normal file
72
vehicle-data-platform/apps/web/src/pages/Vehicles.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
145
vehicle-data-platform/apps/web/src/styles/global.css
Normal file
145
vehicle-data-platform/apps/web/src/styles/global.css
Normal file
@@ -0,0 +1,145 @@
|
||||
@import './tokens.css';
|
||||
@import '@douyinfe/semi-theme-default/scss/index.scss';
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
min-width: 1280px;
|
||||
background: var(--vp-bg);
|
||||
color: var(--vp-text);
|
||||
font-family: Inter, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||
}
|
||||
|
||||
.vp-app {
|
||||
min-height: 100vh;
|
||||
background: var(--vp-bg);
|
||||
}
|
||||
|
||||
.vp-shell {
|
||||
display: grid;
|
||||
grid-template-columns: var(--vp-shell-sidebar) minmax(0, 1fr);
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.vp-sidebar {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
height: 100vh;
|
||||
background: var(--vp-surface);
|
||||
border-right: 1px solid var(--vp-border);
|
||||
padding: 16px 12px;
|
||||
}
|
||||
|
||||
.vp-brand {
|
||||
height: 44px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 0 8px 16px;
|
||||
font-weight: 700;
|
||||
color: var(--vp-text);
|
||||
}
|
||||
|
||||
.vp-brand-mark {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 7px;
|
||||
background: linear-gradient(135deg, var(--vp-primary), var(--vp-cyan));
|
||||
}
|
||||
|
||||
.vp-main {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.vp-topbar {
|
||||
height: var(--vp-shell-header);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 24px;
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
border-bottom: 1px solid var(--vp-border);
|
||||
backdrop-filter: blur(12px);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.vp-page {
|
||||
padding: var(--vp-page-gutter);
|
||||
}
|
||||
|
||||
.vp-section {
|
||||
background: var(--vp-surface);
|
||||
border: 1px solid var(--vp-border);
|
||||
border-radius: var(--vp-radius);
|
||||
box-shadow: var(--vp-shadow-sm);
|
||||
}
|
||||
|
||||
.vp-grid {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.vp-grid.two {
|
||||
grid-template-columns: minmax(0, 1.2fr) minmax(360px, 0.8fr);
|
||||
}
|
||||
|
||||
.vp-kpi-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 16px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.vp-kpi-value {
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
line-height: 34px;
|
||||
color: var(--vp-text);
|
||||
}
|
||||
|
||||
.vp-kpi-label {
|
||||
margin-top: 4px;
|
||||
color: var(--vp-text-muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.vp-map {
|
||||
position: relative;
|
||||
height: 360px;
|
||||
overflow: hidden;
|
||||
background:
|
||||
linear-gradient(90deg, rgba(22, 100, 255, 0.08) 1px, transparent 1px),
|
||||
linear-gradient(0deg, rgba(22, 100, 255, 0.08) 1px, transparent 1px),
|
||||
#f8fbff;
|
||||
background-size: 36px 36px;
|
||||
}
|
||||
|
||||
.vp-map-dot {
|
||||
position: absolute;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
background: var(--vp-primary);
|
||||
box-shadow: 0 0 0 5px rgba(22, 100, 255, 0.16);
|
||||
}
|
||||
|
||||
.vp-json {
|
||||
margin: 0;
|
||||
padding: 16px;
|
||||
border-radius: 8px;
|
||||
background: #101828;
|
||||
color: #e4e7ec;
|
||||
overflow: auto;
|
||||
max-height: 360px;
|
||||
font-size: 12px;
|
||||
line-height: 1.65;
|
||||
}
|
||||
|
||||
.semi-navigation-item-text {
|
||||
font-size: 14px;
|
||||
}
|
||||
20
vehicle-data-platform/apps/web/src/styles/tokens.css
Normal file
20
vehicle-data-platform/apps/web/src/styles/tokens.css
Normal file
@@ -0,0 +1,20 @@
|
||||
:root {
|
||||
--vp-bg: #f5f7fb;
|
||||
--vp-surface: #ffffff;
|
||||
--vp-surface-muted: #f9fafc;
|
||||
--vp-border: #e6e9f0;
|
||||
--vp-text: #172033;
|
||||
--vp-text-muted: #667085;
|
||||
--vp-text-subtle: #98a2b3;
|
||||
--vp-primary: #1664ff;
|
||||
--vp-primary-hover: #0f4fd6;
|
||||
--vp-cyan: #00a4b8;
|
||||
--vp-success: #12b76a;
|
||||
--vp-warning: #f79009;
|
||||
--vp-danger: #f04438;
|
||||
--vp-radius: 8px;
|
||||
--vp-shadow-sm: 0 1px 2px rgba(16, 24, 40, 0.06);
|
||||
--vp-shell-sidebar: 232px;
|
||||
--vp-shell-header: 56px;
|
||||
--vp-page-gutter: 24px;
|
||||
}
|
||||
9
vehicle-data-platform/apps/web/src/test/App.test.tsx
Normal file
9
vehicle-data-platform/apps/web/src/test/App.test.tsx
Normal file
@@ -0,0 +1,9 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { expect, test } from 'vitest';
|
||||
import App from '../App';
|
||||
|
||||
test('renders vehicle platform shell', () => {
|
||||
render(<App />);
|
||||
expect(screen.getByText('车辆数据中台')).toBeInTheDocument();
|
||||
expect(screen.getAllByText('总览工作台').length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
85
vehicle-data-platform/apps/web/src/test/setup.ts
Normal file
85
vehicle-data-platform/apps/web/src/test/setup.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import '@testing-library/jest-dom/vitest';
|
||||
|
||||
Object.defineProperty(HTMLCanvasElement.prototype, 'getContext', {
|
||||
value: () => ({
|
||||
fillStyle: '',
|
||||
fillRect: () => undefined,
|
||||
clearRect: () => undefined,
|
||||
getImageData: () => ({ data: new Uint8ClampedArray(4) }),
|
||||
putImageData: () => undefined,
|
||||
createImageData: () => ({ data: new Uint8ClampedArray(4) }),
|
||||
setTransform: () => undefined,
|
||||
drawImage: () => undefined,
|
||||
save: () => undefined,
|
||||
fillText: () => undefined,
|
||||
restore: () => undefined,
|
||||
beginPath: () => undefined,
|
||||
moveTo: () => undefined,
|
||||
lineTo: () => undefined,
|
||||
closePath: () => undefined,
|
||||
stroke: () => undefined,
|
||||
translate: () => undefined,
|
||||
scale: () => undefined,
|
||||
rotate: () => undefined,
|
||||
arc: () => undefined,
|
||||
fill: () => undefined,
|
||||
measureText: () => ({ width: 0 }),
|
||||
transform: () => undefined,
|
||||
rect: () => undefined,
|
||||
clip: () => undefined
|
||||
})
|
||||
});
|
||||
|
||||
class TestResizeObserver {
|
||||
observe() {
|
||||
return undefined;
|
||||
}
|
||||
unobserve() {
|
||||
return undefined;
|
||||
}
|
||||
disconnect() {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
Object.defineProperty(window, 'ResizeObserver', { value: TestResizeObserver });
|
||||
Object.defineProperty(globalThis, 'ResizeObserver', { value: TestResizeObserver });
|
||||
|
||||
Object.defineProperty(window, 'matchMedia', {
|
||||
value: (query: string) => ({
|
||||
matches: false,
|
||||
media: query,
|
||||
onchange: null,
|
||||
addListener: () => undefined,
|
||||
removeListener: () => undefined,
|
||||
addEventListener: () => undefined,
|
||||
removeEventListener: () => undefined,
|
||||
dispatchEvent: () => false
|
||||
})
|
||||
});
|
||||
|
||||
document.createRange = () => ({
|
||||
setStart: () => undefined,
|
||||
setEnd: () => undefined,
|
||||
selectNodeContents: () => undefined,
|
||||
detach: () => undefined,
|
||||
commonAncestorContainer: document.body,
|
||||
getBoundingClientRect: () => ({
|
||||
bottom: 0,
|
||||
height: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
top: 0,
|
||||
width: 0,
|
||||
x: 0,
|
||||
y: 0,
|
||||
toJSON: () => undefined
|
||||
}),
|
||||
getClientRects: () => ({
|
||||
length: 0,
|
||||
item: () => null,
|
||||
[Symbol.iterator]: function* iterator() {
|
||||
return undefined;
|
||||
}
|
||||
})
|
||||
} as unknown as Range);
|
||||
Reference in New Issue
Block a user