import { useQuery } from '@tanstack/react-query'; import { IconChevronLeft, IconChevronRight, IconClose, IconDownload, IconEyeClosed, IconEyeOpened, IconList, IconMapPin, IconPause, IconPlay, IconRefresh, IconRoute, IconSearch } from '@douyinfe/semi-icons'; import { Button, Card, Descriptions, Empty, Input, List, Select, 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 { protocolDisplayLabel } from '../domain/protocols'; import { buildTrackRailSelection, compactTrackWindowLabel, downloadTrackCsv, formatDuration, sampledEventIndex, TRACK_ADDRESS_SETTLE_MS, trackAddressCoordinate, trackPlaybackInterval, trackPointMileageAvailable, type TrackAddressCoordinate } from '../domain/track'; import { TrackMap } from '../map/TrackMap'; import { InlineError, WorkspaceEmptyGuide } from '../shared/AsyncState'; import { MonitorReturnBar } from '../shared/MonitorReturnBar'; import { ProtocolTag } from '../shared/ProtocolTag'; import { SegmentedTabs } from '../shared/SegmentedTabs'; import { VehicleCandidateList } from '../shared/VehicleCandidateList'; import { mergeVehicleCandidates } from '../shared/vehicleCandidates'; import { WorkspaceCommandBar } from '../shared/WorkspaceCommandBar'; import { WorkspaceDateTimeRange, workspaceDateTimePresetWindow } from '../shared/WorkspaceDateTimeRange'; import { WorkspaceMetricRail, type WorkspaceQueueMetricRailItem } from '../shared/WorkspaceMetricRail'; import { WorkspacePanelHeader } from '../shared/WorkspacePanelHeader'; import { WorkspaceSideSheet } from '../shared/WorkspaceSideSheet'; import { QUERY_MEMORY } from '../queryPolicy'; import { monitorReturnFromParams, preserveMonitorReturn } from '../routing/monitorContext'; import { useMobileLayout } from '../hooks/useMobileLayout'; 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 TRACK_SOURCE_OPTIONS = [ { value: '', label: '自动选择最佳来源' }, { value: 'GB32960', label: `${protocolDisplayLabel('GB32960')} · 仪表盘里程` }, { value: 'JT808', label: `${protocolDisplayLabel('JT808')} · GPS 里程` }, { value: 'YUTONG_MQTT', label: `${protocolDisplayLabel('YUTONG_MQTT')} · 仪表盘里程` } ]; 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' }); const TRACK_SHEET_MAX_HEIGHT_PX = 780; const TRACK_SHEET_CHROME_HEIGHT_PX = 332; const TRACK_SHEET_ROW_HEIGHT_PX = 62; 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, 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 mobileTrackSheetHeight(track: TrackPlaybackResponse | undefined, queryCollapsed: boolean, tab: PanelTab) { if (!track || !queryCollapsed || tab === 'overview') return `min(88dvh, ${TRACK_SHEET_MAX_HEIGHT_PX}px)`; const itemCount = tab === 'stops' ? track.stops.length : track.events.length; const visibleRows = itemCount ? Math.min(itemCount, 7) : 2; const contentHeight = Math.min(TRACK_SHEET_MAX_HEIGHT_PX, TRACK_SHEET_CHROME_HEIGHT_PX + visibleRows * TRACK_SHEET_ROW_HEIGHT_PX); return `min(88dvh, ${contentHeight}px)`; } function trackSummaryItems(track?: TrackPlaybackResponse): WorkspaceQueueMetricRailItem[] { const distanceLabel = track?.summary.distanceMethod === 'coordinates' ? 'GPS 轨迹累计' : track?.summary.distanceMethod === 'odometer' ? '协议总里程差' : '行程里程'; return [ { label: distanceLabel, value: track ? `${number(track.summary.distanceKm)} km` : '—', note: track ? formatDuration(track.summary.durationSeconds) : '等待查询', tone: 'primary', emphasis: 'primary' }, { label: '停留点', value: track ? track.stops.length.toLocaleString('zh-CN') : '—', note: track ? '超过 3 分钟' : '等待查询', tone: track?.stops.length ? 'warning' : 'neutral', emphasis: 'secondary' }, { label: '事件点', value: track ? track.events.length.toLocaleString('zh-CN') : '—', note: track ? '轨迹事件' : '等待查询', tone: 'neutral', emphasis: 'secondary' }, { label: '数据点', value: track ? track.points.length.toLocaleString('zh-CN') : '—', note: track ? track.coverage.complete ? '时间窗完整' : '已返回切片' : '等待查询', tone: track?.coverage.complete ? 'success' : track ? 'warning' : 'neutral', emphasis: 'secondary' } ]; } 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 }); const options = useMemo(() => mergeVehicleCandidates(candidates.data?.items ?? []), [candidates.data?.items]); return
} autoComplete="off" placeholder="输入车牌 / VIN / 终端标识" value={value} onFocus={openPicker} onBlur={closePicker} onChange={(next) => { onChange(next); setOpen(true); }} /> {value ?
; } function OverviewPanel({ track }: { track: TrackPlaybackResponse }) { const distanceLabel = track.summary.distanceMethod === 'coordinates' ? 'GPS 轨迹累计' : track.summary.distanceMethod === 'odometer' ? '协议总里程差' : '行驶里程'; return
{track.sampled ? '地图已抽稀' : '完整点集'}} />
{track.coverage.totalPoints.toLocaleString('zh-CN')} 个源点} />
{track.sources.map((source) => {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, summaryItems, loading, activeStopIndexes, activeEventIndexes, tab, queryCollapsed, showCommandBar, onDraft, onSubmit, onTab, onSelectIndex, onToggleQuery, onCollapse }: { draft: Draft; track?: TrackPlaybackResponse; summaryItems: WorkspaceQueueMetricRailItem[]; loading: boolean; activeStopIndexes: readonly number[]; activeEventIndexes: readonly number[]; tab: PanelTab; queryCollapsed: boolean; showCommandBar: 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 trackRangePresets = useMemo(() => [ { key: 'today', label: '今天', range: workspaceDateTimePresetWindow('today') }, { key: 'yesterday', label: '昨天', range: workspaceDateTimePresetWindow('yesterday') }, { key: 'three-days', label: '近 3 天', range: workspaceDateTimePresetWindow('last3Days') } ], []).map((preset) => ({ ...preset, active: activePreset === preset.key })); const appliedWindow = `${draft.dateFrom.replace('T', ' ').slice(5)} – ${draft.dateTo.replace('T', ' ').slice(5)}`; const appliedSource = draft.protocol ? protocolDisplayLabel(draft.protocol) : '自动来源'; const contextTitle = queryCollapsed ? track?.plate || draft.keyword || '轨迹查询' : '轨迹查询'; return