feat: build vehicle data platform and production pipeline
This commit is contained in:
191
vehicle-data-platform/apps/web/src/v2/pages/TrackPage.tsx
Normal file
191
vehicle-data-platform/apps/web/src/v2/pages/TrackPage.tsx
Normal file
@@ -0,0 +1,191 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import {
|
||||
IconBox, IconChevronLeft, IconChevronRight, IconDownload, IconPause, IconPlay, IconSearch
|
||||
} from '@douyinfe/semi-icons';
|
||||
import { FormEvent, useEffect, useMemo, useState } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { api } from '../../api/client';
|
||||
import type { TrackPlaybackEvent, TrackPlaybackResponse } from '../../api/types';
|
||||
import { downloadTrackCsv, formatDuration, sampledEventIndex } from '../domain/track';
|
||||
import { TrackMap } from '../map/TrackMap';
|
||||
import { InlineError } from '../shared/AsyncState';
|
||||
|
||||
const speedOptions = [1, 2, 4] as const;
|
||||
const visibleSegmentLimit = 120;
|
||||
|
||||
function number(value: number, digits = 1) {
|
||||
return new Intl.NumberFormat('zh-CN', { maximumFractionDigits: digits }).format(Number.isFinite(value) ? value : 0);
|
||||
}
|
||||
|
||||
function direction(value?: number) {
|
||||
if (value === undefined || !Number.isFinite(value)) return '方向 —';
|
||||
const normalized = ((value % 360) + 360) % 360;
|
||||
const names = ['北', '东北', '东', '东南', '南', '西南', '西', '西北'];
|
||||
return `${number(normalized, 0)}° ${names[Math.round(normalized / 45) % names.length]}`;
|
||||
}
|
||||
|
||||
function alarm(value?: number) {
|
||||
if (value === undefined) return '报警 —';
|
||||
return value === 0 ? '无报警' : `报警 0x${Math.trunc(value).toString(16).toUpperCase().padStart(8, '0')}`;
|
||||
}
|
||||
|
||||
function time(value?: string) {
|
||||
if (!value) return '—';
|
||||
const parsed = new Date(value);
|
||||
if (!Number.isNaN(parsed.getTime())) return new Intl.DateTimeFormat('zh-CN', { timeZone: 'Asia/Shanghai', hour12: false, hour: '2-digit', minute: '2-digit', second: '2-digit' }).format(parsed);
|
||||
const clock = value.split(' ').pop();
|
||||
return clock?.slice(0, 8) || '—';
|
||||
}
|
||||
|
||||
function localDateTime(value: Date) {
|
||||
const local = new Date(value.getTime() - value.getTimezoneOffset() * 60_000);
|
||||
return local.toISOString().slice(0, 16);
|
||||
}
|
||||
|
||||
function defaultTrackWindow() {
|
||||
const now = new Date();
|
||||
const start = new Date(now);
|
||||
start.setHours(0, 0, 0, 0);
|
||||
return { dateFrom: localDateTime(start), dateTo: localDateTime(now) };
|
||||
}
|
||||
|
||||
function eventTone(type: string) {
|
||||
if (type === 'start') return 'start';
|
||||
if (type === 'end' || type === 'braking' || type === 'gap') return 'end';
|
||||
if (type === 'acceleration' || type === 'stop') return 'warning';
|
||||
return 'info';
|
||||
}
|
||||
|
||||
function EmptyTrack({ queried }: { queried: boolean }) {
|
||||
return <div className="v2-track-empty"><IconSearch size="extra-large" /><strong>{queried ? '当前条件没有轨迹点' : '选择车辆开始查询轨迹'}</strong><p>{queried ? '请扩大时间范围或切换数据来源。' : '支持车牌、VIN 或终端标识,结果不会回退到本地样例。'}</p></div>;
|
||||
}
|
||||
|
||||
function CoverageStrip({ track }: { track: TrackPlaybackResponse }) {
|
||||
return <div className={`v2-track-coverage ${track.coverage.complete ? 'is-complete' : 'is-limited'}`}>
|
||||
<strong>{track.coverage.complete ? '时间窗完整' : '仅展示最新切片'}</strong>
|
||||
<span>{track.coverage.evidence}</span>
|
||||
<em>{track.coverage.processedPoints} 个有效点 → {track.coverage.returnedPoints} 个地图点</em>
|
||||
</div>;
|
||||
}
|
||||
|
||||
function SegmentTimeline({ track, onSelectIndex }: { track: TrackPlaybackResponse; onSelectIndex: (index: number) => void }) {
|
||||
const segments = track.segments.slice(0, visibleSegmentLimit);
|
||||
return <div className="v2-track-timeline">
|
||||
<header><strong>活动时间轴</strong><span>{track.segments.length} 段 · {track.stops.length} 次有效停车</span></header>
|
||||
<div>{segments.map((segment) => <button
|
||||
aria-label={`${segment.title} ${time(segment.startTime)} 至 ${time(segment.endTime)}`}
|
||||
className={`is-${segment.type}`}
|
||||
key={`${segment.index}-${segment.startTime}`}
|
||||
onClick={() => onSelectIndex(segment.sampledStartIndex)}
|
||||
title={`${segment.title} · ${formatDuration(segment.durationSeconds)} · ${number(segment.distanceKm)} km`}
|
||||
type="button"
|
||||
><i /><span>{segment.title}</span></button>)}</div>
|
||||
{track.segments.length > visibleSegmentLimit ? <em>时间轴仅渲染前 {visibleSegmentLimit} 段,完整统计仍基于全部已加工点。</em> : null}
|
||||
</div>;
|
||||
}
|
||||
|
||||
function TripInspector({ track, onEvent }: { track: TrackPlaybackResponse; onEvent: (event: TrackPlaybackEvent) => void }) {
|
||||
return <aside className="v2-track-inspector">
|
||||
<section><header><strong>车辆</strong><span>{track.sampled ? '地图已抽稀' : '完整点集'}</span></header><div className="v2-track-vehicle"><span><IconBox /></span><div><strong>{track.plate || track.vin}</strong><small>VIN {track.vin}</small></div></div></section>
|
||||
<section><header><strong>行程概览</strong></header><dl className="v2-track-summary">
|
||||
<div><dt>开始时间</dt><dd>{track.summary.startTime || '—'}</dd></div><div><dt>结束时间</dt><dd>{track.summary.endTime || '—'}</dd></div><div><dt>行驶里程</dt><dd>{number(track.summary.distanceKm)} km</dd></div><div><dt>总跨度</dt><dd>{formatDuration(track.summary.durationSeconds)}</dd></div><div><dt>移动 / 停车</dt><dd>{formatDuration(track.summary.movingSeconds)} / {formatDuration(track.summary.stoppedSeconds)}</dd></div><div><dt>停车 / 分段</dt><dd>{track.summary.stopCount} / {track.summary.segmentCount}</dd></div><div><dt>平均速度</dt><dd>{number(track.summary.averageSpeedKmh)} km/h</dd></div><div><dt>最高速度</dt><dd>{number(track.summary.maximumSpeedKmh)} km/h</dd></div>
|
||||
</dl></section>
|
||||
<section className="v2-track-sources"><header><strong>数据来源</strong><b>{track.coverage.totalPoints} 个源点</b></header><div>{track.sources.map((source) => <span key={source.protocol}><strong>{source.protocol}</strong><small>{source.pointCount} 点 · {time(source.startTime)}–{time(source.endTime)}</small></span>)}</div>{!track.coverage.complete ? <p>{track.coverage.evidence}。请缩小到 7 天内更精确的时间窗。</p> : null}</section>
|
||||
<section className={`v2-track-quality is-${track.quality.status}`}><header><strong>轨迹质量</strong><span>{track.quality.status === 'good' ? '通过' : '需关注'}</span></header><dl className="v2-track-summary"><div><dt>主来源 / 其他点</dt><dd>{track.quality.selectedProtocol || 'UNKNOWN'} / {track.quality.alternateSourcePoints}</dd></div><div><dt>有效 / 读取</dt><dd>{track.quality.validPoints} / {track.quality.rawPoints}</dd></div><div><dt>无效 / 重复 / 漂移</dt><dd>{track.quality.invalidCoordinatePoints} / {track.quality.duplicatePoints} / {track.quality.driftPoints}</dd></div><div><dt>大间隔 / 最大间隔</dt><dd>{track.quality.largeGapCount} / {formatDuration(track.quality.maximumGapSeconds)}</dd></div></dl><p>{track.quality.evidence}</p></section>
|
||||
<section className="v2-track-events"><header><strong>轨迹事件</strong><span>{track.events.length} 项</span></header><div>{track.events.map((event, index) => <button key={`${event.type}-${event.index}-${event.time}`} onClick={() => onEvent(event)} type="button"><i className={`is-${eventTone(event.type)}`}>{index + 1}</i><span><strong>{event.title}</strong><small>{time(event.time)}</small></span><em>{number(event.speedKmh, 0)} km/h</em></button>)}</div></section>
|
||||
</aside>;
|
||||
}
|
||||
|
||||
export default function TrackPage() {
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const initialKeyword = searchParams.get('vin') || searchParams.get('keyword') || '';
|
||||
const [draft, setDraft] = useState(() => {
|
||||
const fallback = defaultTrackWindow();
|
||||
return { keyword: initialKeyword, dateFrom: searchParams.get('dateFrom') || fallback.dateFrom, dateTo: searchParams.get('dateTo') || fallback.dateTo, protocol: searchParams.get('protocol') || '' };
|
||||
});
|
||||
const [criteria, setCriteria] = useState(draft);
|
||||
const [activeIndex, setActiveIndex] = useState(0);
|
||||
const [playing, setPlaying] = useState(false);
|
||||
const [playbackSpeed, setPlaybackSpeed] = useState<(typeof speedOptions)[number]>(1);
|
||||
|
||||
const params = useMemo(() => {
|
||||
const next = new URLSearchParams({ keyword: criteria.keyword, maxPoints: '1200' });
|
||||
if (criteria.dateFrom) next.set('dateFrom', criteria.dateFrom);
|
||||
if (criteria.dateTo) next.set('dateTo', criteria.dateTo);
|
||||
if (criteria.protocol) next.set('protocol', criteria.protocol);
|
||||
return next;
|
||||
}, [criteria]);
|
||||
const query = useQuery({ queryKey: ['track-playback', params.toString()], enabled: Boolean(criteria.keyword), queryFn: () => api.trackPlayback(params) });
|
||||
const track = query.data;
|
||||
const points = track?.points ?? [];
|
||||
const current = points[Math.min(activeIndex, Math.max(points.length - 1, 0))];
|
||||
const [addressPoint, setAddressPoint] = useState<{ longitude: number; latitude: number }>();
|
||||
|
||||
useEffect(() => {
|
||||
if (playing || !current) return;
|
||||
const timer = window.setTimeout(() => setAddressPoint({ longitude: current.longitude, latitude: current.latitude }), 350);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [current?.latitude, current?.longitude, playing]);
|
||||
const addressQuery = useQuery({
|
||||
queryKey: ['track-address', addressPoint?.longitude.toFixed(6), addressPoint?.latitude.toFixed(6)],
|
||||
enabled: Boolean(addressPoint), staleTime: 60 * 60 * 1000,
|
||||
queryFn: () => api.reverseGeocode(new URLSearchParams({ longitude: addressPoint!.longitude.toFixed(6), latitude: addressPoint!.latitude.toFixed(6) }))
|
||||
});
|
||||
|
||||
useEffect(() => { setActiveIndex(0); setPlaying(false); }, [track?.asOf]);
|
||||
useEffect(() => {
|
||||
if (!playing || points.length < 2) return;
|
||||
const timer = window.setInterval(() => setActiveIndex((index) => {
|
||||
if (index >= points.length - 1) { setPlaying(false); return points.length - 1; }
|
||||
return index + 1;
|
||||
}), Math.max(120, 800 / playbackSpeed));
|
||||
return () => window.clearInterval(timer);
|
||||
}, [playing, playbackSpeed, points.length]);
|
||||
|
||||
const submit = (event: FormEvent) => {
|
||||
event.preventDefault();
|
||||
const keyword = draft.keyword.trim();
|
||||
if (!keyword) return;
|
||||
const next = { ...draft, keyword };
|
||||
setCriteria(next);
|
||||
const url = new URLSearchParams({ vin: keyword });
|
||||
if (next.dateFrom) url.set('dateFrom', next.dateFrom);
|
||||
if (next.dateTo) url.set('dateTo', next.dateTo);
|
||||
if (next.protocol) url.set('protocol', next.protocol);
|
||||
setSearchParams(url, { replace: true });
|
||||
};
|
||||
const selectEvent = (event: TrackPlaybackEvent) => {
|
||||
if (!track) return;
|
||||
setActiveIndex(sampledEventIndex(event, points.length, track.summary.pointCount));
|
||||
setPlaying(false);
|
||||
};
|
||||
|
||||
return <div className="v2-track-page">
|
||||
<form className="v2-track-toolbar" onSubmit={submit}>
|
||||
<label className="v2-track-vehicle-input"><span>车辆</span><div><IconSearch /><input value={draft.keyword} onChange={(event) => setDraft((value) => ({ ...value, keyword: event.target.value }))} placeholder="车牌 / VIN / 终端标识" /></div></label>
|
||||
<label><span>开始时间</span><input type="datetime-local" value={draft.dateFrom} onChange={(event) => setDraft((value) => ({ ...value, dateFrom: event.target.value }))} /></label>
|
||||
<label><span>结束时间</span><input type="datetime-local" value={draft.dateTo} onChange={(event) => setDraft((value) => ({ ...value, dateTo: event.target.value }))} /></label>
|
||||
<label><span>数据来源</span><select value={draft.protocol} onChange={(event) => setDraft((value) => ({ ...value, protocol: event.target.value }))}><option value="">全部来源</option><option value="GB32960">GB32960</option><option value="JT808">JT808</option><option value="YUTONG_MQTT">YUTONG_MQTT</option></select></label>
|
||||
<button className="v2-primary-button" type="submit" disabled={!draft.keyword.trim()}>查询</button>
|
||||
<button className="v2-secondary-button" type="button" disabled={!track?.points.length} onClick={() => track && downloadTrackCsv(track)}><IconDownload />导出地图点</button>
|
||||
</form>
|
||||
|
||||
{query.isError ? <InlineError message={query.error instanceof Error ? query.error.message : '轨迹查询失败'} onRetry={() => query.refetch()} /> : null}
|
||||
<div className="v2-track-workspace">
|
||||
<div className="v2-track-main">
|
||||
{track && points.length ? <CoverageStrip track={track} /> : <div className="v2-track-coverage is-empty"><span>默认查询今天;单次时间窗最长 7 天</span></div>}
|
||||
<div className="v2-track-canvas-wrap">
|
||||
{query.isFetching ? <div className="v2-track-loading"><span className="v2-spinner" />正在读取历史轨迹</div> : null}
|
||||
{points.length ? <TrackMap points={points} events={track?.events ?? []} activeIndex={activeIndex} onSelectIndex={setActiveIndex} /> : <EmptyTrack queried={Boolean(track)} />}
|
||||
</div>
|
||||
{track && points.length ? <SegmentTimeline track={track} onSelectIndex={(index) => { setPlaying(false); setActiveIndex(index); }} /> : <div className="v2-track-timeline is-empty"><span>查询后显示行驶、停车和数据间隔分段</span></div>}
|
||||
<div className="v2-track-playback">
|
||||
<div className="v2-play-controls"><small>播放</small><p><button type="button" onClick={() => setPlaying((value) => !value)} disabled={points.length < 2}>{playing ? <IconPause /> : <IconPlay />}</button><button type="button" onClick={() => { setPlaying(false); setActiveIndex((value) => Math.max(0, value - 1)); }} disabled={!activeIndex}><IconChevronLeft /></button><button type="button" onClick={() => { setPlaying(false); setActiveIndex((value) => Math.min(points.length - 1, value + 1)); }} disabled={!points.length || activeIndex >= points.length - 1}><IconChevronRight /></button><select value={playbackSpeed} onChange={(event) => setPlaybackSpeed(Number(event.target.value) as 1 | 2 | 4)}>{speedOptions.map((speed) => <option value={speed} key={speed}>{speed}×</option>)}</select></p></div>
|
||||
<div className="v2-play-progress"><header><strong>{time(current?.deviceTime)}</strong><span>{track?.summary.endTime ? time(track.summary.endTime) : '—'}</span></header><input aria-label="轨迹播放进度" type="range" min="0" max={Math.max(0, points.length - 1)} value={Math.min(activeIndex, Math.max(0, points.length - 1))} onChange={(event) => { setPlaying(false); setActiveIndex(Number(event.target.value)); }} disabled={!points.length} /><footer>数据点 {points.length ? activeIndex + 1 : 0} / {points.length}</footer></div>
|
||||
<dl className="v2-current-metrics"><div><dt>速度 / 方向</dt><dd>{number(current?.speedKmh ?? 0)}<em>km/h · {direction(current?.directionDeg)}</em></dd></div><div><dt>SOC / 报警</dt><dd>{current?.socAvailable ? `${number(current.socPercent)}%` : '—'}<em> · {alarm(current?.alarmFlag)}</em></dd></div><div><dt>总里程 / 来源</dt><dd>{number(current?.totalMileageKm ?? 0)}<em>km · {current?.protocol || '—'}</em></dd></div><div><dt>当前地址</dt><dd title={addressQuery.data?.formattedAddress}>{playing ? '播放中暂停解析' : addressQuery.isFetching ? '地址解析中…' : addressQuery.data?.formattedAddress || (current ? `${current.longitude.toFixed(6)}, ${current.latitude.toFixed(6)}` : '—')}</dd></div></dl>
|
||||
</div>
|
||||
</div>
|
||||
{track && points.length ? <TripInspector track={track} onEvent={selectEvent} /> : <aside className="v2-track-inspector is-empty"><strong>行程检查器</strong><p>查询后显示车辆、行程摘要、来源证据和轨迹事件。</p></aside>}
|
||||
</div>
|
||||
</div>;
|
||||
}
|
||||
Reference in New Issue
Block a user