feat: build vehicle data platform and production pipeline

This commit is contained in:
lingniu
2026-07-14 12:35:33 +08:00
parent b452be3b94
commit bb59303a4b
270 changed files with 88016 additions and 1975 deletions

View File

@@ -0,0 +1,207 @@
import { IconChevronLeft, IconChevronRight, IconClose, IconFilter, IconRefresh, IconSearch } from '@douyinfe/semi-icons';
import { memo, useCallback, useDeferredValue, useMemo, useState } from 'react';
import { Link } from 'react-router-dom';
import type { AlertEvent, MapReverseGeocode, VehicleDetail as VehicleDetailData, VehicleRealtimeRow } from '../../api/types';
import { FleetMap } from '../map/FleetMap';
import { EmptyState, InlineError } from '../shared/AsyncState';
import { formatNumber, relativeFreshness, statusLabel, vehicleStatus } from '../domain/monitor';
import { MONITOR_REFRESH, useMonitorData, useMonitorVehicleCard, type MonitorViewport } from '../hooks/useMonitorData';
const protocols = ['', 'GB32960', 'JT808', 'YUTONG_MQTT'];
const statuses = ['', 'online', 'offline', 'driving', 'idle'];
const VehicleRow = memo(function VehicleRow({ vehicle, selected, onSelect }: { vehicle: VehicleRealtimeRow; selected: boolean; onSelect: (vin: string) => void }) {
const status = vehicleStatus(vehicle);
return (
<button type="button" className={`v2-vehicle-row ${selected ? 'is-selected' : ''}`} onClick={() => onSelect(vehicle.vin)}>
<i className={`v2-status-dot is-${status}`} />
<span className="v2-vehicle-identity"><strong>{vehicle.plate || '未绑定车牌'}</strong><small>{vehicle.vin}</small></span>
<span className="v2-vehicle-motion"><strong>{formatNumber(vehicle.speedKmh, 1)} <small>km/h</small></strong><small>{statusLabel(status)}</small></span>
</button>
);
});
const MemoFleetMap = memo(FleetMap);
function VehicleDetailCard({
vehicle,
detail,
activeAlerts,
address,
onCollapse,
onClear
}: {
vehicle: VehicleRealtimeRow;
detail?: VehicleDetailData;
activeAlerts?: { items: AlertEvent[]; total: number };
address?: MapReverseGeocode;
onCollapse: () => void;
onClear: () => void;
}) {
const status = vehicleStatus(vehicle);
const dailyMileage = detail?.mileage.items[0]?.dailyMileageKm;
const latestAlert = activeAlerts?.items[0];
return (
<aside className="v2-vehicle-detail">
<div className="v2-detail-controls">
<button type="button" aria-label="收起车辆详情" title="收起到地图右侧" onClick={onCollapse}><IconChevronRight /></button>
<button type="button" aria-label="取消选择车辆" title="取消选择车辆" onClick={onClear}><IconClose /></button>
</div>
<div className="v2-detail-title">
<div><strong>{vehicle.plate || '未绑定车牌'}</strong><span className={`v2-status-text is-${status}`}>{statusLabel(status)}</span></div>
<small>{vehicle.vin}</small>
</div>
<div className="v2-detail-actions">
<Link to={`/vehicles/${encodeURIComponent(vehicle.vin)}`}></Link>
<Link to={`/tracks?vin=${encodeURIComponent(vehicle.vin)}`}></Link>
<Link to={`/history?vin=${encodeURIComponent(vehicle.vin)}`}></Link>
</div>
<section>
<h3></h3>
<dl className="v2-detail-list">
<div><dt>VIN</dt><dd>{vehicle.vin}</dd></div>
<div><dt></dt><dd>{vehicle.oem || '待补充'}</dd></div>
<div><dt></dt><dd>{vehicle.primaryProtocol || vehicle.protocols.join('、') || '未知'}</dd></div>
<div><dt></dt><dd>{detail?.sources.join('、') || vehicle.protocols.join('、') || '未知'}</dd></div>
<div><dt></dt><dd>{detail?.profile?.accessProvider || '待补充'}</dd></div>
<div><dt></dt><dd>{vehicle.onlineSourceCount}/{vehicle.sourceCount}</dd></div>
</dl>
</section>
<section>
<h3></h3>
<div className="v2-metric-grid">
<div><small></small><strong>{formatNumber(vehicle.speedKmh, 1)}<em>km/h</em></strong></div>
<div><small>SOC</small><strong>{formatNumber(vehicle.socPercent, 1)}<em>%</em></strong></div>
<div><small></small><strong>{formatNumber(vehicle.totalMileageKm, 1)}<em>km</em></strong></div>
<div><small></small><strong>{dailyMileage == null ? '—' : formatNumber(dailyMileage, 1)}<em>{dailyMileage == null ? '' : 'km'}</em></strong></div>
<div><small></small><strong>{statusLabel(status)}</strong></div>
<div><small></small><strong>{formatNumber(activeAlerts?.total ?? 0)}<em></em></strong></div>
</div>
</section>
<section>
<h3></h3>
<dl className="v2-detail-list">
<div><dt></dt><dd>{vehicle.lastSeen || '暂无'}</dd></div>
<div><dt></dt><dd>{relativeFreshness(vehicle.lastSeen)}</dd></div>
<div><dt></dt><dd>{vehicle.longitude.toFixed(6)}, {vehicle.latitude.toFixed(6)}</dd></div>
<div><dt></dt><dd>{address?.formattedAddress || '位置解析中'}</dd></div>
<div><dt></dt><dd>{latestAlert ? `${latestAlert.ruleName} · ${latestAlert.severity}` : '无当前业务告警'}</dd></div>
</dl>
</section>
</aside>
);
}
export default function MonitorPage() {
const [keyword, setKeyword] = useState('');
const deferredKeyword = useDeferredValue(keyword);
const [protocol, setProtocol] = useState('');
const [status, setStatus] = useState('');
const [selectedVin, setSelectedVin] = useState('');
const [detailOpen, setDetailOpen] = useState(false);
const [viewport, setViewport] = useState<MonitorViewport>({ zoom: 5, bounds: '' });
const updateViewport = useCallback((next: MonitorViewport) => {
setViewport((current) => current.zoom === next.zoom && current.bounds === next.bounds ? current : next);
}, []);
const { summary, vehicles, map, selectedVehicle } = useMonitorData({ keyword: deferredKeyword, protocol, status }, viewport, selectedVin);
const rows = useMemo(() => {
const data = vehicles.data?.items ?? [];
if (status === 'driving' || status === 'idle') return data.filter((vehicle) => vehicleStatus(vehicle) === status);
return data;
}, [status, vehicles.data?.items]);
const selected = selectedVin
? rows.find((vehicle) => vehicle.vin === selectedVin) ?? selectedVehicle.data?.items[0]
: undefined;
const selectVehicle = useCallback((vin: string) => {
setSelectedVin(vin);
setDetailOpen(true);
}, []);
const clearSelection = useCallback(() => {
setSelectedVin('');
setDetailOpen(false);
}, []);
const selectMapVehicle = useCallback((vehicle: VehicleRealtimeRow) => selectVehicle(vehicle.vin), [selectVehicle]);
const collapseDetail = useCallback(() => setDetailOpen(false), []);
const expandDetail = useCallback(() => setDetailOpen(true), []);
const card = useMonitorVehicleCard(selected?.vin ?? '', selected, Boolean(selectedVin));
const driving = rows.filter((vehicle) => vehicleStatus(vehicle) === 'driving').length;
const idle = rows.filter((vehicle) => vehicleStatus(vehicle) === 'idle').length;
const offline = rows.filter((vehicle) => vehicleStatus(vehicle) === 'offline').length;
return (
<div className="v2-monitor-page">
<section className="v2-filterbar" aria-label="车辆筛选">
<label className="v2-search-field"><IconSearch /><input value={keyword} onChange={(event) => setKeyword(event.target.value)} placeholder="车牌 / VIN / 厂家" /></label>
<select value={protocol} onChange={(event) => setProtocol(event.target.value)} aria-label="协议">
{protocols.map((item) => <option key={item} value={item}>{item || '全部协议'}</option>)}
</select>
<select value={status} onChange={(event) => setStatus(event.target.value)} aria-label="在线状态">
{statuses.map((item) => <option key={item} value={item}>{item ? statusLabel(item as never) : '全部状态'}</option>)}
</select>
<button type="button" className="v2-secondary-button" onClick={() => { setKeyword(''); setProtocol(''); setStatus(''); }}><IconRefresh /></button>
<button type="button" className="v2-primary-button"><IconFilter /></button>
</section>
<section className="v2-kpis" aria-label="车辆整体统计">
{[
['接入车辆', formatNumber(summary.data?.totalVehicles ?? vehicles.data?.total ?? rows.length), 'fleet'],
['当前在线', formatNumber(summary.data?.onlineVehicles ?? rows.length - offline), 'online'],
['当前离线', formatNumber(summary.data?.offlineVehicles ?? offline), 'offline'],
['行驶车辆', formatNumber(summary.data?.drivingVehicles ?? driving), 'driving'],
['静止车辆', formatNumber(summary.data?.idleVehicles ?? idle), 'idle'],
['告警车辆', summary.data?.alertDataAvailable ? formatNumber(summary.data.alertVehicles) : '—', 'alert'],
['今日上报', formatNumber(summary.data?.frameToday ?? 0), 'today']
].map(([label, value, tone]) => <div key={label} className={`v2-kpi is-${tone}`}><small>{label}</small><strong>{value}</strong></div>)}
</section>
{vehicles.isError ? <InlineError message={vehicles.error instanceof Error ? vehicles.error.message : '车辆数据加载失败'} onRetry={() => vehicles.refetch()} /> : null}
<section className={`v2-monitor-workspace${selected && detailOpen ? ' is-detail-open' : ''}${selected && !detailOpen ? ' is-detail-collapsed' : ''}`}>
<div className="v2-vehicle-rail">
<header><strong></strong><span>{formatNumber(vehicles.data?.total ?? rows.length)} </span></header>
<div className="v2-rail-search"><IconSearch /><span>{deferredKeyword ? `正在筛选“${deferredKeyword}` : '按最新上报排序'}</span></div>
<div className="v2-vehicle-scroll">
{vehicles.isLoading ? <div className="v2-list-loading"><span className="v2-spinner" /></div> : null}
{!vehicles.isLoading && rows.length === 0 ? <EmptyState /> : null}
{rows.map((vehicle) => <VehicleRow key={vehicle.vin} vehicle={vehicle} selected={vehicle.vin === selectedVin} onSelect={selectVehicle} />)}
</div>
<footer> {rows.length} / {vehicles.data?.total ?? rows.length} </footer>
</div>
<MemoFleetMap
vehicles={rows}
monitorMap={map.data}
selectedVin={selectedVin || undefined}
onSelect={selectMapVehicle}
onSelectVin={selectVehicle}
onViewportChange={updateViewport}
/>
{selected && detailOpen ? (
<VehicleDetailCard
vehicle={selected}
detail={card.detail.data}
activeAlerts={card.activeAlerts.data}
address={card.address.data}
onCollapse={collapseDetail}
onClear={clearSelection}
/>
) : null}
{selected && !detailOpen ? (
<aside className="v2-detail-peek" aria-label="已收起的车辆详情">
<button type="button" aria-label="展开车辆详情" title={`展开 ${selected.plate || selected.vin} 的车辆详情`} onClick={expandDetail}>
<IconChevronLeft />
<i className={`v2-status-dot is-${vehicleStatus(selected)}`} />
<span>{selected.plate || '未绑定车牌'}</span>
</button>
</aside>
) : null}
</section>
<section className="v2-event-strip">
<strong></strong>
<span><i className="is-online" />{vehicles.isFetching ? '正在刷新' : '实时车辆已同步'}</span>
<span> {rows.length} · {map.data?.clusters.length ? `${map.data.clusters.length} 个聚合 + ${map.data.points.length} 个车辆点` : `${map.data?.points.length ?? 0} 个点`}</span>
<span className="v2-refresh-cadence"><b></b> {MONITOR_REFRESH.selected / 1000} · {MONITOR_REFRESH.fleet / 1000} · {MONITOR_REFRESH.summary / 1000} </span>
<time>{new Date().toLocaleString('zh-CN', { hour12: false })}</time>
</section>
</div>
);
}