import { useQuery } from '@tanstack/react-query'; import { IconBox, IconChevronLeft, IconChevronRight, IconClose, IconDownload, IconEyeClosed, IconEyeOpened, IconList, IconMapPin, IconPause, IconPlay, IconRefresh, IconSearch } from '@douyinfe/semi-icons'; 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, trackPlaybackInterval } from '../domain/track'; import { TrackMap } from '../map/TrackMap'; import { InlineError } from '../shared/AsyncState'; import { QUERY_MEMORY } from '../queryPolicy'; 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 }; const EMPTY_INDEXES: readonly number[] = []; const numberFormatters = new Map(); 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 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(); 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 }); return
{ onChange(event.target.value); setOpen(true); }} /> {value ? : null} {open ?
车辆候选支持车牌或 VIN
{candidates.isFetching ?

正在搜索车辆

: null} {!candidates.isFetching && (candidates.data?.items ?? []).map((vehicle) => )} {!candidates.isFetching && !(candidates.data?.items.length) ?

没有匹配车辆

: null}
: null}
; } function OverviewPanel({ track }: { track: TrackPlaybackResponse }) { return
行程概览{track.sampled ? '地图已抽稀' : '完整点集'}
开始时间
{dateTime(track.summary.startTime)}
结束时间
{dateTime(track.summary.endTime)}
行驶里程
{number(track.summary.distanceKm)} km
行驶 / 停车
{formatDuration(track.summary.movingSeconds)} / {formatDuration(track.summary.stoppedSeconds)}
平均 / 最高速度
{number(track.summary.averageSpeedKmh)} / {number(track.summary.maximumSpeedKmh)} km/h
停车 / 分段
{track.summary.stopCount} / {track.summary.segmentCount}
数据来源{track.coverage.totalPoints.toLocaleString('zh-CN')} 个源点
{track.sources.map((source) =>
{source.protocol}{source.pointCount.toLocaleString('zh-CN')} 点{time(source.startTime)}–{time(source.endTime)}
)}
轨迹质量{track.quality.status === 'good' ? '通过' : '需关注'}

{track.quality.evidence}

无效 {track.quality.invalidCoordinatePoints} · 重复 {track.quality.duplicatePoints} · 漂移 {track.quality.driftPoints} · 大间隔 {track.quality.largeGapCount}
; } const TrackRail = memo(function TrackRail({ draft, track, activeStopIndexes, activeEventIndexes, tab, onDraft, onSubmit, onTab, onSelectIndex, onCollapse }: { draft: Draft; track?: TrackPlaybackResponse; activeStopIndexes: readonly number[]; activeEventIndexes: readonly 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 ; }); const SegmentRail = memo(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
{segments.map((segment) =>
; }); export default function TrackPage() { const [searchParams, setSearchParams] = useSearchParams(); const fallback = useMemo(defaultTrackWindow, []); const initialDraft = useMemo(() => ({ 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(1); const [follow, setFollow] = useState(true); const [showStops, setShowStops] = useState(true); const [panelTab, setPanelTab] = useState('stops'); const [railCollapsed, setRailCollapsed] = useState(false); const animationRef = useRef(); const lastFrameRef = useRef(0); 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 [addressPoint, setAddressPoint] = useState<{ longitude: number; latitude: number }>(); useEffect(() => { if (playing || !current) return; const timer = window.setTimeout(() => setAddressPoint({ longitude: current.longitude, latitude: current.latitude }), 360); 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: ({ signal }) => api.reverseGeocode(new URLSearchParams({ longitude: addressPoint!.longitude.toFixed(6), latitude: addressPoint!.latitude.toFixed(6) }), signal), gcTime: QUERY_MEMORY.highVolumeGcTime }); useEffect(() => { setActiveIndex(0); setPlaying(false); setFollow(true); setAddressPoint(undefined); if (track?.points.length) setRailCollapsed(window.matchMedia('(max-width: 700px)').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 }; setCriteria(next); 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 }); }, [draft, 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; return
{railCollapsed ? : null}
{track?.points.length ? <>
{track.coverage.complete ? '时间窗完整' : '仅展示最新切片'}{track.coverage.evidence} {track.coverage.processedPoints.toLocaleString('zh-CN')} 有效点 → {track.coverage.returnedPoints.toLocaleString('zh-CN')} 地图点
{track.plate || track.vin}{dateTime(current?.deviceTime)}
{number(progress, 0)}%
速度{number(current?.speedKmh ?? 0)} km/h方向{direction(current?.directionDeg)}SOC{current?.socAvailable ? `${number(current.socPercent)}%` : '—'}

{playing ? '播放中,暂停地址解析' : addressQuery.isFetching ? '地址解析中…' : addressQuery.data?.formattedAddress || `${current?.longitude.toFixed(6)}, ${current?.latitude.toFixed(6)}`}

:
先选择车辆,再开始轨迹回放

地图会显示完整路径、停留点、事件点和播放位置。

} {query.isFetching ?
正在读取并加工历史轨迹
: null} {query.isError ?
query.refetch()} />
: null}
{track ? `${number(track.summary.distanceKm)} km` : '等待查询'}{track ? `${formatDuration(track.summary.durationSeconds)} · ${track.summary.stopCount} 次停留` : '查询后可播放完整轨迹'}
{track ? :
} selectIndex(Number(event.target.value))} disabled={!points.length} style={{ '--track-progress': `${progress}%` } as React.CSSProperties} />
数据点 {points.length ? boundedIndex + 1 : 0} / {points.length}
总里程{number(current?.totalMileageKm ?? 0)} km数据来源{current?.protocol || '—'}车辆状态{alarm(current?.alarmFlag)}
; }