Files
lingniu-vehicle-ingest/vehicle-data-platform/apps/web/src/v2/pages/TrackPage.tsx
2026-07-18 07:57:53 +08:00

433 lines
30 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useQuery } from '@tanstack/react-query';
import {
IconChevronLeft, IconChevronRight, IconClose, IconDownload, IconEyeClosed,
IconEyeOpened, IconList, IconMapPin, IconPause, IconPlay, IconRefresh, IconSearch
} from '@douyinfe/semi-icons';
import { Button, Card, Descriptions, Empty, Input, List, Select, SideSheet, Slider, Tag } from '@douyinfe/semi-ui';
import { FormEvent, memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useSearchParams } from 'react-router-dom';
import { api } from '../../api/client';
import type { TrackPlaybackEvent, TrackPlaybackResponse, VehicleRow } from '../../api/types';
import { buildTrackRailSelection, downloadTrackCsv, formatDuration, sampledEventIndex, TRACK_ADDRESS_SETTLE_MS, trackAddressCoordinate, trackPlaybackInterval, type TrackAddressCoordinate } from '../domain/track';
import { TrackMap } from '../map/TrackMap';
import { InlineError } from '../shared/AsyncState';
import { MonitorReturnBar } from '../shared/MonitorReturnBar';
import { SegmentedTabs } from '../shared/SegmentedTabs';
import { VehicleCandidateList } from '../shared/VehicleCandidateList';
import { mergeVehicleCandidates } from '../shared/vehicleCandidates';
import { WorkspacePanelHeader } from '../shared/WorkspacePanelHeader';
import { QUERY_MEMORY } from '../queryPolicy';
import { monitorReturnFromParams, preserveMonitorReturn } from '../routing/monitorContext';
import { useMobileLayout } from '../hooks/useMobileLayout';
import { useSideSheetA11y } from '../hooks/useSideSheetA11y';
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 };
type TrackPreset = 'today' | 'yesterday' | 'three-days';
const EMPTY_INDEXES: readonly number[] = [];
const numberFormatters = new Map<number, Intl.NumberFormat>();
const timeFormatter = new Intl.DateTimeFormat('zh-CN', { timeZone: 'Asia/Shanghai', hour12: false, hour: '2-digit', minute: '2-digit', second: '2-digit' });
const dateTimeFormatter = 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' });
function number(value: number, digits = 1) {
let formatter = numberFormatters.get(digits);
if (!formatter) {
formatter = new Intl.NumberFormat('zh-CN', { maximumFractionDigits: digits });
numberFormatters.set(digits, formatter);
}
return formatter.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 timeFormatter.format(parsed);
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 dateTimeFormatter.format(parsed);
return value.replace('T', ' ').slice(5, 19);
}
function localDateTime(value: Date) {
const local = new Date(value.getTime() - value.getTimezoneOffset() * 60_000);
return local.toISOString().slice(0, 16);
}
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(end) };
}
function defaultTrackWindow() {
return trackWindow();
}
function trackPreset(draft: Pick<Draft, 'dateFrom' | 'dateTo'>, now = new Date()): TrackPreset | undefined {
const today = localDateTime(now).slice(0, 10);
const yesterdayDate = new Date(now);
yesterdayDate.setDate(yesterdayDate.getDate() - 1);
const yesterday = localDateTime(yesterdayDate).slice(0, 10);
const threeDayStartDate = new Date(now);
threeDayStartDate.setDate(threeDayStartDate.getDate() - 2);
const threeDayStart = localDateTime(threeDayStartDate).slice(0, 10);
const from = draft.dateFrom.slice(0, 10);
const to = draft.dateTo.slice(0, 10);
if (from === today && to === today) return 'today';
if (from === yesterday && to === yesterday) return 'yesterday';
if (from === threeDayStart && to === today) return 'three-days';
return undefined;
}
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 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());
const closeTimerRef = useRef<number>();
useEffect(() => { const timer = window.setTimeout(() => setDebounced(value.trim()), 220); return () => window.clearTimeout(timer); }, [value]);
useEffect(() => () => window.clearTimeout(closeTimerRef.current), []);
const openPicker = () => {
window.clearTimeout(closeTimerRef.current);
setOpen(true);
};
const closePicker = () => {
window.clearTimeout(closeTimerRef.current);
closeTimerRef.current = window.setTimeout(() => setOpen(false), 140);
};
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: ({ signal }) => api.vehicles(params, signal), enabled: open, staleTime: 30_000, gcTime: QUERY_MEMORY.optionGcTime });
const options = useMemo(() => mergeVehicleCandidates(candidates.data?.items ?? []), [candidates.data?.items]);
return <div className={`v2-track-vehicle-picker${open ? ' is-open' : ''}`}>
<Input
aria-label="搜索轨迹车辆" prefix={<IconSearch />} autoComplete="off" placeholder="输入车牌 / VIN / 终端标识"
value={value} onFocus={openPicker} onBlur={closePicker}
onChange={(next) => { onChange(next); setOpen(true); }}
/>
{value ? <Button className="v2-track-vehicle-clear" theme="borderless" aria-label="清空车辆" icon={<IconClose />} onMouseDown={(event) => event.preventDefault()} onClick={() => onChange('')} /> : null}
{open ? <VehicleCandidateList
className="v2-track-vehicle-options"
items={options}
loading={candidates.isFetching}
loadingText="正在搜索车辆"
error={candidates.isError ? (candidates.error instanceof Error ? candidates.error.message : '车辆候选加载失败') : undefined}
onRetry={() => candidates.refetch()}
header="车辆候选"
meta="车牌优先 · 合并来源"
showProtocols
onSelect={(vehicle) => { onSelect(vehicle); setOpen(false); }}
/> : null}
</div>;
}
function OverviewPanel({ track }: { track: TrackPlaybackResponse }) {
return <div className="v2-track-overview-panel">
<section className="v2-track-overview-section">
<WorkspacePanelHeader variant="compact" title="行程概览" meta={<Tag color={track.sampled ? 'orange' : 'green'} type="light" size="small">{track.sampled ? '地图已抽稀' : '完整点集'}</Tag>} />
<div className="v2-track-overview-card-body"><Descriptions className="v2-track-overview-descriptions" align="left" size="small" data={[
{ key: '开始时间', value: dateTime(track.summary.startTime) },
{ key: '结束时间', value: dateTime(track.summary.endTime) },
{ key: '行驶里程', value: `${number(track.summary.distanceKm)} km` },
{ key: '行驶 / 停车', value: `${formatDuration(track.summary.movingSeconds)} / ${formatDuration(track.summary.stoppedSeconds)}` },
{ key: '平均 / 最高速度', value: `${number(track.summary.averageSpeedKmh)} / ${number(track.summary.maximumSpeedKmh)} km/h` },
{ key: '停车 / 分段', value: `${track.summary.stopCount} / ${track.summary.segmentCount}` }
]} /></div>
</section>
<section className="v2-track-overview-section v2-track-source-card">
<WorkspacePanelHeader variant="compact" title="数据来源" meta={<Tag color="blue" type="light" size="small">{track.coverage.totalPoints.toLocaleString('zh-CN')} </Tag>} />
<div className="v2-track-overview-card-body"><List className="v2-track-source-list">{track.sources.map((source) => <List.Item className="v2-track-source-item" key={source.protocol}><Tag color="blue" type="light" size="small">{source.protocol}</Tag><strong>{source.pointCount.toLocaleString('zh-CN')} </strong><small>{time(source.startTime)}{time(source.endTime)}</small></List.Item>)}</List></div>
</section>
<section className={`v2-track-overview-section v2-track-quality-card is-${track.quality.status}`}>
<WorkspacePanelHeader variant="compact" title="轨迹质量" meta={<Tag color={track.quality.status === 'good' ? 'green' : 'orange'} type="light" size="small">{track.quality.status === 'good' ? '通过' : '需关注'}</Tag>} />
<div className="v2-track-overview-card-body"><p>{track.quality.evidence}</p><small> {track.quality.invalidCoordinatePoints} · {track.quality.duplicatePoints} · {track.quality.driftPoints} · {track.quality.largeGapCount}</small></div>
</section>
</div>;
}
const TrackRail = memo(function TrackRail({ draft, track, loading, activeStopIndexes, activeEventIndexes, tab, queryCollapsed, onDraft, onSubmit, onTab, onSelectIndex, onToggleQuery, onCollapse }: {
draft: Draft;
track?: TrackPlaybackResponse;
loading: boolean;
activeStopIndexes: readonly number[];
activeEventIndexes: readonly number[];
tab: PanelTab;
queryCollapsed: boolean;
onDraft: (draft: Draft) => void;
onSubmit: (event: FormEvent) => void;
onTab: (tab: PanelTab) => void;
onSelectIndex: (index: number) => void;
onToggleQuery: () => void;
onCollapse: () => void;
}) {
const activePreset = trackPreset(draft);
const choosePreset = (daysBack: number) => onDraft({ ...draft, ...trackWindow(daysBack, daysBack > 0) });
return <aside className="v2-track-rail">
<Card className="v2-track-rail-shell" bodyStyle={{ padding: 0 }}>
<section className={`v2-track-query-panel${queryCollapsed ? ' is-collapsed' : ''}`}>
<WorkspacePanelHeader
className="v2-track-query-header"
title="轨迹查询"
description={queryCollapsed ? '条件已应用,可随时修改' : '最长支持连续 7 天'}
actions={<>
<Button
className="v2-track-query-toggle"
theme="borderless"
type="tertiary"
size="small"
icon={<IconChevronRight />}
aria-label={queryCollapsed ? '修改条件' : '收起条件'}
aria-expanded={!queryCollapsed}
onClick={onToggleQuery}
>{queryCollapsed ? '修改条件' : '收起条件'}</Button>
<Button className="v2-track-rail-hide" theme="borderless" aria-label="隐藏查询与明细" icon={<IconChevronLeft />} onClick={onCollapse}><span className="v2-track-rail-hide-label"></span></Button>
</>}
/>
{queryCollapsed ? <div className="v2-track-query-summary">
<span><strong>{draft.keyword || '尚未选择车辆'}</strong><small>{draft.dateFrom.replace('T', ' ').slice(5)} {draft.dateTo.replace('T', ' ').slice(5)}</small></span>
<Tag color={draft.protocol ? 'blue' : 'grey'} type="light" size="small">{draft.protocol || '自动来源'}</Tag>
</div> : <form className="v2-track-query" onSubmit={onSubmit}>
<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 theme={activePreset === 'today' ? 'solid' : 'light'} aria-pressed={activePreset === 'today'} onClick={() => choosePreset(0)}></Button>
<Button theme={activePreset === 'yesterday' ? 'solid' : 'light'} aria-pressed={activePreset === 'yesterday'} onClick={() => choosePreset(1)}></Button>
<Button theme={activePreset === 'three-days' ? 'solid' : 'light'} aria-pressed={activePreset === 'three-days'} 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 aria-label="开始时间" type="datetime-local" value={draft.dateFrom} onChange={(value) => onDraft({ ...draft, dateFrom: value })} /></label><label><span></span><Input aria-label="结束时间" type="datetime-local" value={draft.dateTo} onChange={(value) => onDraft({ ...draft, dateTo: value })} /></label></div>
<label><span id="track-source-label"></span><Select aria-labelledby="track-source-label" value={draft.protocol} onChange={(value) => onDraft({ ...draft, protocol: String(value) })} optionList={[{ value: '', label: '自动选择最佳来源' }, { value: 'GB32960', label: 'GB32960 · 仪表盘里程' }, { value: 'JT808', label: 'JT808 · GPS 里程' }, { value: 'YUTONG_MQTT', label: 'YUTONG · 仪表盘里程' }]} /></label>
<Button className="v2-track-query-button" theme="solid" htmlType="submit" icon={<IconSearch />} loading={loading} disabled={!draft.keyword.trim()}></Button>
</form>}
</section>
<section className="v2-track-rail-result">
<WorkspacePanelHeader
variant="compact"
className="v2-track-result-header"
title={track ? track.plate || track.vin : '轨迹证据'}
description={track ? track.vin : '停留、事件与来源统一呈现'}
meta={<Tag color={track ? 'blue' : 'grey'} type="light" size="small">{track ? `${number(track.summary.distanceKm)} km` : '等待查询'}</Tag>}
/>
<SegmentedTabs className="v2-track-rail-tabs" ariaLabel="轨迹明细分类" value={tab} onChange={onTab} items={[{ key: 'stops', label: '停留点', count: track?.stops.length ?? 0 }, { key: 'events', label: '事件点', count: track?.events.length ?? 0 }, { key: 'overview', label: '概览' }]} />
<div className="v2-track-rail-scroll">
{!track ? <Empty className="v2-track-rail-empty" image={<IconMapPin />} title="选择车辆后查询轨迹" description="停留点、轨迹事件与行程证据会在这里统一呈现。" /> : null}
{track && tab === 'stops' ? <List className="v2-track-evidence-list v2-track-stop-list">{track.stops.map((stop, index) => <List.Item className="v2-track-evidence-item" key={`${stop.startTime}-${index}`}><Button theme="borderless" type="tertiary" className={`v2-track-list-action${activeStopIndexes.includes(index) ? ' is-active' : ''}`} 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></List.Item>)}{!track.stops.length ? <Empty className="v2-track-list-empty" image={<IconMapPin />} title="当前没有停留点" description="时间窗内没有超过 3 分钟的连续停留。" /> : null}</List> : null}
{track && tab === 'events' ? <List className="v2-track-evidence-list v2-track-event-list">{track.events.map((event, index) => { const sampled = sampledEventIndex(event, track.points.length, track.summary.pointCount); return <List.Item className="v2-track-evidence-item" key={`${event.type}-${event.time}-${index}`}><Button theme="borderless" type="tertiary" className={`v2-track-list-action${activeEventIndexes.includes(index) ? ' is-active' : ''}`} 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></List.Item>; })}{!track.events.length ? <Empty className="v2-track-list-empty" image={<IconMapPin />} title="当前没有事件点" description="时间窗内没有识别到启停、急加速或异常间隔。" /> : null}</List> : null}
{track && tab === 'overview' ? <OverviewPanel track={track} /> : null}
</div>
</section>
</Card>
</aside>;
});
const SegmentRail = memo(function SegmentRail({ track }: { track: TrackPlaybackResponse }) {
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) => <span
aria-label={`${segment.title} ${time(segment.startTime)}${time(segment.endTime)}`}
className={`is-${segment.type}`} key={`${segment.index}-${segment.startTime}`}
style={{ flexGrow: Math.max(1, segment.durationSeconds) / total }}
title={`${segment.title} · ${formatDuration(segment.durationSeconds)} · ${number(segment.distanceKm)} km`}
/>)}</div>;
});
export default function TrackPage() {
const [searchParams, setSearchParams] = useSearchParams();
const mobileLayout = useMobileLayout();
const monitorReturn = monitorReturnFromParams(searchParams);
const fallback = useMemo(defaultTrackWindow, []);
const routeKey = searchParams.toString();
const criteria = useMemo<Draft>(() => {
const params = new URLSearchParams(routeKey);
return {
keyword: params.get('vin') || params.get('keyword') || '',
dateFrom: params.get('dateFrom') || fallback.dateFrom,
dateTo: params.get('dateTo') || fallback.dateTo,
protocol: params.get('protocol') || ''
};
}, [fallback, routeKey]);
const [draft, setDraft] = useState(criteria);
const [activeIndex, setActiveIndex] = useState(0);
const [playing, setPlaying] = useState(false);
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(() => typeof window !== 'undefined' && window.matchMedia('(max-width: 960px)').matches);
const [queryCollapsed, setQueryCollapsed] = useState(() => Boolean(criteria.keyword));
const animationRef = useRef<number>();
const lastFrameRef = useRef(0);
useSideSheetA11y(mobileLayout && !railCollapsed, '.v2-track-detail-sidesheet', 'v2-track-detail-sheet', '轨迹查询与明细', '关闭轨迹查询与明细');
const params = useMemo(() => {
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);
return next;
}, [criteria]);
const query = useQuery({ queryKey: ['track-playback', params.toString()], enabled: Boolean(criteria.keyword), queryFn: ({ signal }) => api.trackPlayback(params, signal), gcTime: QUERY_MEMORY.highVolumeGcTime });
const track = query.data;
const points = track?.points ?? [];
const boundedIndex = Math.min(activeIndex, Math.max(points.length - 1, 0));
const current = points[boundedIndex];
const railSelection = useMemo(() => buildTrackRailSelection(track), [track]);
const activeStopIndexes = railSelection.stopIndexesByPoint[boundedIndex] ?? EMPTY_INDEXES;
const activeEventIndexes = railSelection.eventIndexesByPoint[boundedIndex] ?? EMPTY_INDEXES;
const currentAddressPoint = useMemo(() => trackAddressCoordinate(current?.longitude, current?.latitude), [current?.latitude, current?.longitude]);
const [addressPoint, setAddressPoint] = useState<TrackAddressCoordinate>();
useEffect(() => {
setDraft(criteria);
setQueryCollapsed(Boolean(criteria.keyword));
}, [criteria]);
useEffect(() => {
if (playing || !currentAddressPoint) return;
const timer = window.setTimeout(() => setAddressPoint(currentAddressPoint), TRACK_ADDRESS_SETTLE_MS);
return () => window.clearTimeout(timer);
}, [currentAddressPoint?.key, playing]);
const addressQuery = useQuery({
queryKey: ['track-address', addressPoint?.key],
enabled: Boolean(addressPoint), staleTime: 60 * 60 * 1000,
queryFn: ({ signal }) => api.reverseGeocode(new URLSearchParams({ longitude: addressPoint!.longitude.toFixed(4), latitude: addressPoint!.latitude.toFixed(4) }), signal),
gcTime: QUERY_MEMORY.highVolumeGcTime
});
const addressSettling = Boolean(!playing && currentAddressPoint && currentAddressPoint.key !== addressPoint?.key);
useEffect(() => { setActiveIndex(0); setPlaying(false); setFollow(true); setAddressPoint(undefined); if (track?.points.length) setRailCollapsed(window.matchMedia('(max-width: 960px)').matches); }, [track?.asOf]);
useEffect(() => {
if (!playing || points.length < 2) return;
lastFrameRef.current = 0;
const interval = trackPlaybackInterval(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 = useCallback((event: FormEvent) => {
event.preventDefault();
const keyword = draft.keyword.trim();
if (!keyword) return;
const next = { ...draft, 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);
setQueryCollapsed(true);
setSearchParams(preserveMonitorReturn(url, monitorReturn), { replace: true });
}, [draft, monitorReturn, setSearchParams]);
const selectIndex = useCallback((index: number) => { setPlaying(false); setActiveIndex(Math.max(0, Math.min(points.length - 1, index))); }, [points.length]);
const collapseRail = useCallback(() => setRailCollapsed(true), []);
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;
const trackRail = <TrackRail draft={draft} track={track} loading={query.isFetching} activeStopIndexes={activeStopIndexes} activeEventIndexes={activeEventIndexes} tab={panelTab} queryCollapsed={queryCollapsed} onDraft={setDraft} onSubmit={submit} onTab={setPanelTab} onSelectIndex={selectIndex} onToggleQuery={() => setQueryCollapsed((value) => !value)} onCollapse={collapseRail} />;
return <div className={`v2-track-page${mobileLayout ? ' is-mobile-layout' : ''}${railCollapsed ? ' is-rail-collapsed' : ''}${monitorReturn ? ' has-monitor-return' : ''}`}>
<MonitorReturnBar />
{mobileLayout ? <SideSheet
className="v2-track-detail-sidesheet"
visible={!railCollapsed}
placement="bottom"
height="min(82dvh, 720px)"
aria-label="轨迹查询与明细"
title={<div className="v2-track-detail-sheet-title"><strong></strong><span>{track ? `${track.plate || track.vin} · ${number(track.summary.distanceKm)} km` : '选择车辆和时间范围'}</span></div>}
footer={null}
onCancel={collapseRail}
>{trackRail}</SideSheet> : trackRail}
<section className="v2-track-stage">
<TrackMap points={points} stops={track?.stops ?? []} activeIndex={boundedIndex} showStops={showStops} follow={follow} followDurationMs={playing ? Math.max(40, trackPlaybackInterval(playbackSpeed) - 10) : 180} onSelectIndex={selectIndex} onFollowChange={setFollow} />
{railCollapsed ? <Button className="v2-track-rail-expand" theme="light" aria-label="展开查询与明细" icon={<IconList />} onClick={() => setRailCollapsed(false)}></Button> : null}
<div className="v2-track-stage-tools" role="toolbar" aria-label="地图轨迹工具">
<Button theme="borderless" aria-label={follow ? '关闭车辆跟随' : '开启车辆跟随'} className={follow ? 'is-active' : ''} icon={<IconMapPin />} disabled={!points.length} onClick={() => setFollow((value) => !value)}>{follow ? '跟随车辆' : '自由浏览'}</Button>
<Button theme="borderless" aria-label={showStops ? '隐藏停留点' : '显示停留点'} className={showStops ? 'is-active' : ''} icon={showStops ? <IconEyeOpened /> : <IconEyeClosed />} disabled={!track?.stops.length} onClick={() => setShowStops((value) => !value)}></Button>
<Button theme="borderless" aria-label="导出轨迹 CSV" icon={<IconDownload />} disabled={!track?.points.length} onClick={() => track && downloadTrackCsv(track)}></Button>
</div>
{track?.points.length ? <>
<Card className="v2-track-current-card" bodyStyle={{ padding: 0 }}>
<WorkspacePanelHeader variant="compact" title={track.plate || track.vin} description={dateTime(current?.deviceTime)} meta={<Tag color="blue" type="light" size="small">{number(progress, 0)}%</Tag>} />
<div className="v2-track-current-metrics">
<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>
<span><small></small><strong>{number(current?.totalMileageKm ?? 0)}<em> km</em></strong></span>
</div>
<div className={`v2-track-current-evidence v2-track-current-coverage${track.coverage.complete ? '' : ' is-warning'}`}>
<Tag color="blue" type="light" size="small">{current?.protocol || '未知来源'}</Tag>
<span title={track.coverage.evidence}><i /><strong>{track.coverage.complete ? '时间窗完整' : '最新切片'}</strong></span>
<em>{track.coverage.processedPoints.toLocaleString('zh-CN')} {track.coverage.returnedPoints.toLocaleString('zh-CN')} </em>
<small>{alarm(current?.alarmFlag)}</small>
</div>
<p title={addressSettling ? undefined : addressQuery.data?.formattedAddress}>{playing ? '播放中,暂停地址解析' : addressSettling ? '位置已变化,等待地址解析…' : addressQuery.isFetching ? '地址解析中…' : addressQuery.data?.formattedAddress || `${current?.longitude.toFixed(6)}, ${current?.latitude.toFixed(6)}`}</p>
</Card>
</> : <Card className="v2-track-empty-state" bodyStyle={{ padding: 0 }}><Empty image={<IconMapPin />} title="先选择车辆,再开始轨迹回放" description="地图会显示完整路径、停留点、事件点和播放位置。"><Button theme="solid" icon={<IconSearch />} onClick={() => setRailCollapsed(false)}></Button></Empty></Card>}
{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}
<Card className="v2-track-playback-dock" bodyStyle={{ padding: 0 }}>
<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} /> : <div className="v2-track-segment-placeholder" />}
<Slider key={`track-progress-${points.length}`} className="v2-track-progress-slider" aria-label="轨迹播放进度" min={0} max={Math.max(0, points.length - 1)} step={1} value={boundedIndex} onChange={(value) => selectIndex(Number(value))} disabled={!points.length} showBoundary={false} tipFormatter={(value) => `${Number(value) + 1} / ${points.length} 个数据点`} />
<div className="v2-track-progress-meta"><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 theme="borderless" aria-label="上一个轨迹点" icon={<IconChevronLeft />} onClick={() => selectIndex(boundedIndex - 1)} disabled={!boundedIndex} />
<Button theme="solid" className="is-primary" aria-label={playing ? '暂停轨迹播放' : '开始轨迹播放'} icon={playing ? <IconPause /> : <IconPlay />} onClick={togglePlayback} disabled={points.length < 2} />
<Button theme="borderless" aria-label="下一个轨迹点" icon={<IconChevronRight />} onClick={() => selectIndex(boundedIndex + 1)} disabled={!points.length || boundedIndex >= points.length - 1} />
<label><span id="track-playback-speed-label"></span><Select aria-labelledby="track-playback-speed-label" value={playbackSpeed} onChange={(value) => setPlaybackSpeed(Number(value) as PlaybackSpeed)} optionList={speedOptions.map((speed) => ({ value: speed, label: `${speed}×` }))} /></label>
<Button theme="borderless" aria-label="回到起点" icon={<IconRefresh />} onClick={() => selectIndex(0)} disabled={!boundedIndex} />
</div>
</Card>
</section>
</div>;
}