feat(platform): consolidate production vehicle data workflows

This commit is contained in:
lingniu
2026-07-15 23:26:29 +08:00
parent 6cddc0a43d
commit 3fabcf181a
59 changed files with 6849 additions and 3532 deletions

View File

@@ -1,17 +1,20 @@
import { useQuery } from '@tanstack/react-query';
import {
IconBox, IconChevronLeft, IconChevronRight, IconDownload, IconPause, IconPlay, IconSearch
IconBox, IconChevronLeft, IconChevronRight, IconClose, IconDownload, IconEyeClosed,
IconEyeOpened, IconList, IconMapPin, IconPause, IconPlay, IconRefresh, IconSearch
} from '@douyinfe/semi-icons';
import { FormEvent, useEffect, useMemo, useState } from 'react';
import { FormEvent, useEffect, useMemo, useRef, useState } from 'react';
import { useSearchParams } from 'react-router-dom';
import { api } from '../../api/client';
import type { TrackPlaybackEvent, TrackPlaybackResponse } from '../../api/types';
import type { TrackPlaybackEvent, TrackPlaybackResponse, VehicleRow } 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;
const speedOptions = [0.5, 1, 2, 4] as const;
type PlaybackSpeed = (typeof speedOptions)[number];
type PanelTab = 'stops' | 'events' | 'overview';
type Draft = { keyword: string; dateFrom: string; dateTo: string; protocol: string };
function number(value: number, digits = 1) {
return new Intl.NumberFormat('zh-CN', { maximumFractionDigits: digits }).format(Number.isFinite(value) ? value : 0);
@@ -33,8 +36,14 @@ 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) || '—';
return value.split(' ').pop()?.slice(0, 8) || '—';
}
function dateTime(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, month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit' }).format(parsed);
return value.replace('T', ' ').slice(5, 19);
}
function localDateTime(value: Date) {
@@ -42,11 +51,17 @@ function localDateTime(value: Date) {
return local.toISOString().slice(0, 16);
}
function defaultTrackWindow() {
const now = new Date();
const start = new Date(now);
function trackWindow(daysBack = 0, fullDay = false) {
const end = new Date();
end.setDate(end.getDate() - daysBack);
if (fullDay) end.setHours(23, 59, 59, 999);
const start = new Date(end);
start.setHours(0, 0, 0, 0);
return { dateFrom: localDateTime(start), dateTo: localDateTime(now) };
return { dateFrom: localDateTime(start), dateTo: localDateTime(end) };
}
function defaultTrackWindow() {
return trackWindow();
}
function eventTone(type: string) {
@@ -56,60 +71,122 @@ function eventTone(type: string) {
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 VehiclePicker({ value, onChange, onSelect }: { value: string; onChange: (value: string) => void; onSelect: (vehicle: VehicleRow) => void }) {
const [open, setOpen] = useState(false);
const [debounced, setDebounced] = useState(value.trim());
useEffect(() => { const timer = window.setTimeout(() => setDebounced(value.trim()), 220); return () => window.clearTimeout(timer); }, [value]);
const params = useMemo(() => {
const next = new URLSearchParams({ limit: '10', offset: '0' });
if (debounced) next.set('keyword', debounced);
return next;
}, [debounced]);
const candidates = useQuery({ queryKey: ['track-vehicle-options', params.toString()], queryFn: () => api.vehicles(params), enabled: open, staleTime: 60_000 });
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>
return <div className={`v2-track-vehicle-picker${open ? ' is-open' : ''}`}>
<IconSearch />
<input
aria-label="搜索轨迹车辆" autoComplete="off" placeholder="输入车牌 / VIN / 终端标识"
value={value} onFocus={() => setOpen(true)} onBlur={() => window.setTimeout(() => setOpen(false), 140)}
onChange={(event) => { onChange(event.target.value); setOpen(true); }}
/>
{value ? <button type="button" aria-label="清空车辆" onMouseDown={(event) => event.preventDefault()} onClick={() => onChange('')}><IconClose /></button> : null}
{open ? <div className="v2-track-vehicle-options" role="listbox">
<header><span></span><em> VIN</em></header>
{candidates.isFetching ? <p><span className="v2-spinner" /></p> : null}
{!candidates.isFetching && (candidates.data?.items ?? []).map((vehicle) => <button
type="button" role="option" aria-selected={false} key={vehicle.vin}
onMouseDown={(event) => event.preventDefault()} onClick={() => { onSelect(vehicle); setOpen(false); }}
><strong>{vehicle.plate || '未绑定车牌'}</strong><span>{vehicle.vin}</span><em></em></button>)}
{!candidates.isFetching && !(candidates.data?.items.length) ? <p></p> : null}
</div> : null}
</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>
function OverviewPanel({ track }: { track: TrackPlaybackResponse }) {
return <div className="v2-track-overview-panel">
<section><header><strong></strong><span>{track.sampled ? '地图已抽稀' : '完整点集'}</span></header><dl>
<div><dt></dt><dd>{dateTime(track.summary.startTime)}</dd></div>
<div><dt></dt><dd>{dateTime(track.summary.endTime)}</dd></div>
<div><dt></dt><dd>{number(track.summary.distanceKm)} km</dd></div>
<div><dt> / </dt><dd>{formatDuration(track.summary.movingSeconds)} / {formatDuration(track.summary.stoppedSeconds)}</dd></div>
<div><dt> / </dt><dd>{number(track.summary.averageSpeedKmh)} / {number(track.summary.maximumSpeedKmh)} km/h</dd></div>
<div><dt> / </dt><dd>{track.summary.stopCount} / {track.summary.segmentCount}</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>
<section><header><strong></strong><span>{track.coverage.totalPoints.toLocaleString('zh-CN')} </span></header><div className="v2-track-source-list">{track.sources.map((source) => <article key={source.protocol}><strong>{source.protocol}</strong><span>{source.pointCount.toLocaleString('zh-CN')} </span><small>{time(source.startTime)}{time(source.endTime)}</small></article>)}</div></section>
<section className={`v2-track-quality-card is-${track.quality.status}`}><header><strong></strong><span>{track.quality.status === 'good' ? '通过' : '需关注'}</span></header><p>{track.quality.evidence}</p><small> {track.quality.invalidCoordinatePoints} · {track.quality.duplicatePoints} · {track.quality.driftPoints} · {track.quality.largeGapCount}</small></section>
</div>;
}
function TrackRail({ draft, track, activeIndex, tab, onDraft, onSubmit, onTab, onSelectIndex, onCollapse }: {
draft: Draft;
track?: TrackPlaybackResponse;
activeIndex: number;
tab: PanelTab;
onDraft: (draft: Draft) => void;
onSubmit: (event: FormEvent) => void;
onTab: (tab: PanelTab) => void;
onSelectIndex: (index: number) => void;
onCollapse: () => void;
}) {
const choosePreset = (daysBack: number) => onDraft({ ...draft, ...trackWindow(daysBack, daysBack > 0) });
return <aside className="v2-track-rail">
<form className="v2-track-query" onSubmit={onSubmit}>
<header><div><strong></strong><span> 7 </span></div><button type="button" aria-label="收起查询面板" onClick={onCollapse}><IconChevronLeft /></button></header>
<label><span></span><VehiclePicker value={draft.keyword} onChange={(keyword) => onDraft({ ...draft, keyword })} onSelect={(vehicle) => onDraft({ ...draft, keyword: vehicle.plate || vehicle.vin })} /></label>
<div className="v2-track-presets"><button type="button" onClick={() => choosePreset(0)}></button><button type="button" onClick={() => choosePreset(1)}></button><button type="button" onClick={() => { const end = new Date(); const start = new Date(end); start.setDate(start.getDate() - 2); start.setHours(0, 0, 0, 0); onDraft({ ...draft, dateFrom: localDateTime(start), dateTo: localDateTime(end) }); }}> 3 </button></div>
<div className="v2-track-date-grid"><label><span></span><input type="datetime-local" value={draft.dateFrom} onChange={(event) => onDraft({ ...draft, dateFrom: event.target.value })} /></label><label><span></span><input type="datetime-local" value={draft.dateTo} onChange={(event) => onDraft({ ...draft, dateTo: event.target.value })} /></label></div>
<label><span></span><select value={draft.protocol} onChange={(event) => onDraft({ ...draft, protocol: event.target.value })}><option value=""></option><option value="GB32960">GB32960 · </option><option value="JT808">JT808 · GPS </option><option value="YUTONG_MQTT">YUTONG · </option></select></label>
<button className="v2-track-query-button" type="submit" disabled={!draft.keyword.trim()}><IconSearch /></button>
</form>
<div className="v2-track-rail-result">
{track ? <div className="v2-track-rail-vehicle"><span><IconBox /></span><div><strong>{track.plate || track.vin}</strong><small>{track.vin}</small></div><em>{number(track.summary.distanceKm)} km</em></div> : null}
<nav aria-label="轨迹明细分类"><button type="button" className={tab === 'stops' ? 'is-active' : ''} onClick={() => onTab('stops')}> <b>{track?.stops.length ?? 0}</b></button><button type="button" className={tab === 'events' ? 'is-active' : ''} onClick={() => onTab('events')}> <b>{track?.events.length ?? 0}</b></button><button type="button" className={tab === 'overview' ? 'is-active' : ''} onClick={() => onTab('overview')}></button></nav>
<div className="v2-track-rail-scroll">
{!track ? <div className="v2-track-rail-empty"><IconMapPin /><strong></strong><p></p></div> : null}
{track && tab === 'stops' ? <div className="v2-track-stop-list">{track.stops.map((stop, index) => <button type="button" className={Math.abs(stop.sampledIndex - activeIndex) < 2 ? 'is-active' : ''} key={`${stop.startTime}-${index}`} onClick={() => onSelectIndex(stop.sampledIndex)}><i>{index + 1}</i><span><strong>{dateTime(stop.startTime)}</strong><small> {formatDuration(stop.durationSeconds)} · {stop.pointCount} </small></span><em>{time(stop.endTime)}</em></button>)}{!track.stops.length ? <p className="v2-track-list-empty"> 3 </p> : null}</div> : null}
{track && tab === 'events' ? <div className="v2-track-event-list">{track.events.map((event, index) => { const sampled = sampledEventIndex(event, track.points.length, track.summary.pointCount); return <button type="button" className={sampled === activeIndex ? 'is-active' : ''} key={`${event.type}-${event.time}-${index}`} onClick={() => onSelectIndex(sampled)}><i className={`is-${eventTone(event.type)}`}>{index + 1}</i><span><strong>{event.title}</strong><small>{dateTime(event.time)}</small></span><em>{number(event.speedKmh, 0)} km/h</em></button>; })}</div> : null}
{track && tab === 'overview' ? <OverviewPanel track={track} /> : null}
</div>
</div>
</aside>;
}
function SegmentRail({ track, onSelectIndex }: { track: TrackPlaybackResponse; onSelectIndex: (index: number) => void }) {
const segments = track.segments.slice(0, 160);
const total = Math.max(1, segments.reduce((sum, segment) => sum + Math.max(1, segment.durationSeconds), 0));
return <div className="v2-track-segment-rail" aria-label="轨迹活动分段">{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)} style={{ flexGrow: Math.max(1, segment.durationSeconds) / total }}
title={`${segment.title} · ${formatDuration(segment.durationSeconds)} · ${number(segment.distanceKm)} km`}
type="button"
/>)}</div>;
}
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 fallback = useMemo(defaultTrackWindow, []);
const initialDraft = useMemo<Draft>(() => ({
keyword: searchParams.get('vin') || searchParams.get('keyword') || '',
dateFrom: searchParams.get('dateFrom') || fallback.dateFrom,
dateTo: searchParams.get('dateTo') || fallback.dateTo,
protocol: searchParams.get('protocol') || ''
}), []);
const [draft, setDraft] = useState(initialDraft);
const [criteria, setCriteria] = useState(initialDraft);
const [activeIndex, setActiveIndex] = useState(0);
const [playing, setPlaying] = useState(false);
const [playbackSpeed, setPlaybackSpeed] = useState<(typeof speedOptions)[number]>(1);
const [playbackSpeed, setPlaybackSpeed] = useState<PlaybackSpeed>(1);
const [follow, setFollow] = useState(true);
const [showStops, setShowStops] = useState(true);
const [panelTab, setPanelTab] = useState<PanelTab>('stops');
const [railCollapsed, setRailCollapsed] = useState(false);
const animationRef = useRef<number>();
const lastFrameRef = useRef(0);
const params = useMemo(() => {
const next = new URLSearchParams({ keyword: criteria.keyword, maxPoints: '1200' });
const next = new URLSearchParams({ keyword: criteria.keyword, maxPoints: '1600' });
if (criteria.dateFrom) next.set('dateFrom', criteria.dateFrom);
if (criteria.dateTo) next.set('dateTo', criteria.dateTo);
if (criteria.protocol) next.set('protocol', criteria.protocol);
@@ -118,12 +195,13 @@ export default function TrackPage() {
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 boundedIndex = Math.min(activeIndex, Math.max(points.length - 1, 0));
const current = points[boundedIndex];
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);
const timer = window.setTimeout(() => setAddressPoint({ longitude: current.longitude, latitude: current.latitude }), 360);
return () => window.clearTimeout(timer);
}, [current?.latitude, current?.longitude, playing]);
const addressQuery = useQuery({
@@ -132,14 +210,26 @@ export default function TrackPage() {
queryFn: () => api.reverseGeocode(new URLSearchParams({ longitude: addressPoint!.longitude.toFixed(6), latitude: addressPoint!.latitude.toFixed(6) }))
});
useEffect(() => { setActiveIndex(0); setPlaying(false); }, [track?.asOf]);
useEffect(() => { setActiveIndex(0); setPlaying(false); setFollow(true); if (track?.points.length) setRailCollapsed(window.matchMedia('(max-width: 700px)').matches); }, [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);
lastFrameRef.current = 0;
const interval = Math.max(55, 300 / playbackSpeed);
const tick = (timestamp: number) => {
if (!lastFrameRef.current) lastFrameRef.current = timestamp;
if (timestamp - lastFrameRef.current >= interval) {
const steps = Math.max(1, Math.floor((timestamp - lastFrameRef.current) / interval));
lastFrameRef.current = timestamp;
setActiveIndex((index) => {
const next = Math.min(points.length - 1, index + steps);
if (next >= points.length - 1) setPlaying(false);
return next;
});
}
animationRef.current = window.requestAnimationFrame(tick);
};
animationRef.current = window.requestAnimationFrame(tick);
return () => { if (animationRef.current) window.cancelAnimationFrame(animationRef.current); };
}, [playing, playbackSpeed, points.length]);
const submit = (event: FormEvent) => {
@@ -148,44 +238,64 @@ export default function TrackPage() {
if (!keyword) return;
const next = { ...draft, keyword };
setCriteria(next);
const url = new URLSearchParams({ vin: keyword });
const url = new URLSearchParams({ 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);
const selectIndex = (index: number) => { setPlaying(false); setActiveIndex(Math.max(0, Math.min(points.length - 1, index))); };
const togglePlayback = () => {
if (points.length < 2) return;
if (!playing && boundedIndex >= points.length - 1) setActiveIndex(0);
setFollow(true);
setPlaying((value) => !value);
};
const progress = points.length > 1 ? boundedIndex / (points.length - 1) * 100 : 0;
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>
return <div className={`v2-track-page${railCollapsed ? ' is-rail-collapsed' : ''}`}>
<TrackRail draft={draft} track={track} activeIndex={boundedIndex} tab={panelTab} onDraft={setDraft} onSubmit={submit} onTab={setPanelTab} onSelectIndex={selectIndex} onCollapse={() => setRailCollapsed(true)} />
<section className="v2-track-stage">
<TrackMap points={points} stops={track?.stops ?? []} activeIndex={boundedIndex} showStops={showStops} follow={follow} onSelectIndex={selectIndex} onFollowChange={setFollow} />
{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 className="v2-track-stage-tools">
{railCollapsed ? <button type="button" aria-label="展开查询与明细" onClick={() => setRailCollapsed(false)}><IconList /><span></span></button> : null}
<button type="button" aria-label={follow ? '关闭车辆跟随' : '开启车辆跟随'} className={follow ? 'is-active' : ''} disabled={!points.length} onClick={() => setFollow((value) => !value)}><IconMapPin /><span>{follow ? '跟随车辆' : '自由浏览'}</span></button>
<button type="button" aria-label={showStops ? '隐藏停留点' : '显示停留点'} className={showStops ? 'is-active' : ''} disabled={!track?.stops.length} onClick={() => setShowStops((value) => !value)}>{showStops ? <IconEyeOpened /> : <IconEyeClosed />}<span></span></button>
<button type="button" aria-label="导出轨迹 CSV" disabled={!track?.points.length} onClick={() => track && downloadTrackCsv(track)}><IconDownload /><span></span></button>
</div>
{track && points.length ? <TripInspector track={track} onEvent={selectEvent} /> : <aside className="v2-track-inspector is-empty"><strong></strong><p></p></aside>}
</div>
{track?.points.length ? <>
<div className={`v2-track-coverage-float${track.coverage.complete ? '' : ' is-warning'}`}><i />
<span><strong>{track.coverage.complete ? '时间窗完整' : '仅展示最新切片'}</strong>{track.coverage.evidence}</span>
<em>{track.coverage.processedPoints.toLocaleString('zh-CN')} {track.coverage.returnedPoints.toLocaleString('zh-CN')} </em>
</div>
<article className="v2-track-current-card">
<header><div><strong>{track.plate || track.vin}</strong><span>{dateTime(current?.deviceTime)}</span></div><b>{number(progress, 0)}%</b></header>
<div><span><small></small><strong>{number(current?.speedKmh ?? 0)}<em> km/h</em></strong></span><span><small></small><strong>{direction(current?.directionDeg)}</strong></span><span><small>SOC</small><strong>{current?.socAvailable ? `${number(current.socPercent)}%` : '—'}</strong></span></div>
<p title={addressQuery.data?.formattedAddress}>{playing ? '播放中,暂停地址解析' : addressQuery.isFetching ? '地址解析中…' : addressQuery.data?.formattedAddress || `${current?.longitude.toFixed(6)}, ${current?.latitude.toFixed(6)}`}</p>
</article>
</> : <div className="v2-track-empty-state"><IconMapPin /><strong></strong><p></p><button type="button" onClick={() => setRailCollapsed(false)}><IconSearch /></button></div>}
{query.isFetching ? <div className="v2-track-loading"><span className="v2-spinner" /></div> : null}
{query.isError ? <div className="v2-track-error"><InlineError message={query.error instanceof Error ? query.error.message : '轨迹查询失败'} onRetry={() => query.refetch()} /></div> : null}
<footer className="v2-track-playback-dock">
<div className="v2-track-dock-summary"><strong>{track ? `${number(track.summary.distanceKm)} km` : '等待查询'}</strong><span>{track ? `${formatDuration(track.summary.durationSeconds)} · ${track.summary.stopCount} 次停留` : '查询后可播放完整轨迹'}</span></div>
<div className="v2-track-dock-progress">
{track ? <SegmentRail track={track} onSelectIndex={selectIndex} /> : <div className="v2-track-segment-placeholder" />}
<input aria-label="轨迹播放进度" type="range" min="0" max={Math.max(0, points.length - 1)} value={boundedIndex} onChange={(event) => selectIndex(Number(event.target.value))} disabled={!points.length} style={{ '--track-progress': `${progress}%` } as React.CSSProperties} />
<div><time>{time(current?.deviceTime)}</time><span> {points.length ? boundedIndex + 1 : 0} / {points.length}</span><time>{time(track?.summary.endTime)}</time></div>
</div>
<div className="v2-track-dock-controls">
<button type="button" aria-label="上一个轨迹点" onClick={() => selectIndex(boundedIndex - 1)} disabled={!boundedIndex}><IconChevronLeft /></button>
<button type="button" className="is-primary" aria-label={playing ? '暂停轨迹播放' : '开始轨迹播放'} onClick={togglePlayback} disabled={points.length < 2}>{playing ? <IconPause /> : <IconPlay />}</button>
<button type="button" aria-label="下一个轨迹点" onClick={() => selectIndex(boundedIndex + 1)} disabled={!points.length || boundedIndex >= points.length - 1}><IconChevronRight /></button>
<label><span></span><select value={playbackSpeed} onChange={(event) => setPlaybackSpeed(Number(event.target.value) as PlaybackSpeed)}>{speedOptions.map((speed) => <option value={speed} key={speed}>{speed}×</option>)}</select></label>
<button type="button" aria-label="回到起点" onClick={() => selectIndex(0)} disabled={!boundedIndex}><IconRefresh /></button>
</div>
<div className="v2-track-dock-metrics"><span><small></small><strong>{number(current?.totalMileageKm ?? 0)} km</strong></span><span><small></small><strong>{current?.protocol || '—'}</strong></span><span><small></small><strong>{alarm(current?.alarmFlag)}</strong></span></div>
</footer>
</section>
</div>;
}