561 lines
36 KiB
TypeScript
561 lines
36 KiB
TypeScript
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<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' });
|
||
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<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 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<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 }) {
|
||
const distanceLabel = track.summary.distanceMethod === 'coordinates' ? 'GPS 轨迹累计' : track.summary.distanceMethod === 'odometer' ? '协议总里程差' : '行驶里程';
|
||
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: distanceLabel, 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}><ProtocolTag className="v2-track-protocol-tag" protocol={source.protocol} compact /><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, 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 <aside className="v2-track-rail">
|
||
<Card className="v2-track-rail-shell" bodyStyle={{ padding: 0 }}>
|
||
<section className={`v2-track-query-panel${queryCollapsed ? ' is-collapsed' : ''}${showCommandBar ? '' : ' is-command-hidden'}`}>
|
||
{showCommandBar ? <WorkspaceCommandBar
|
||
className="v2-track-query-header"
|
||
ariaLabel="轨迹查询任务栏"
|
||
eyebrow="轨迹回放"
|
||
icon={<IconRoute />}
|
||
title={contextTitle}
|
||
description={queryCollapsed ? `${appliedWindow} · ${appliedSource}` : '最长支持连续 7 天'}
|
||
status={loading ? '读取中' : track ? `${track.points.length.toLocaleString('zh-CN')} 点` : '等待查询'}
|
||
statusColor={loading ? 'blue' : track ? 'green' : 'grey'}
|
||
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>
|
||
</>}
|
||
/> : null}
|
||
{!queryCollapsed ? <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>
|
||
<WorkspaceDateTimeRange
|
||
id="v2-track-date-time"
|
||
className="v2-track-date-time-range"
|
||
pickerClassName="v2-track-date-time-picker"
|
||
presetsClassName="v2-track-date-time-presets"
|
||
presetsAriaLabel="轨迹快捷时间范围"
|
||
value={draft}
|
||
onChange={(range) => onDraft({ ...draft, ...range })}
|
||
presets={trackRangePresets}
|
||
/>
|
||
<label><span id="track-source-label">数据来源</span><Select aria-labelledby="track-source-label" value={draft.protocol} onChange={(value) => onDraft({ ...draft, protocol: String(value) })} optionList={TRACK_SOURCE_OPTIONS} /></label>
|
||
<Button className="v2-track-query-button" theme="solid" htmlType="submit" icon={<IconSearch />} loading={loading} disabled={!draft.keyword.trim()}>查询轨迹</Button>
|
||
</form> : null}
|
||
</section>
|
||
|
||
<section className="v2-track-rail-result">
|
||
<WorkspaceMetricRail
|
||
variant="queue"
|
||
ariaLabel="轨迹行程摘要"
|
||
className="v2-track-result-metric-rail"
|
||
items={summaryItems}
|
||
/>
|
||
<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);
|
||
|
||
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 selectRailIndex = useCallback((index: number) => {
|
||
selectIndex(index);
|
||
if (mobileLayout) setRailCollapsed(true);
|
||
}, [mobileLayout, selectIndex]);
|
||
const collapseRail = useCallback(() => setRailCollapsed(true), []);
|
||
const toggleQuery = useCallback(() => setQueryCollapsed((value) => !value), []);
|
||
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 tripSummaryItems = useMemo(() => trackSummaryItems(track), [track]);
|
||
const trackRail = <TrackRail draft={draft} track={track} summaryItems={tripSummaryItems} loading={query.isFetching} activeStopIndexes={activeStopIndexes} activeEventIndexes={activeEventIndexes} tab={panelTab} queryCollapsed={queryCollapsed} showCommandBar={!mobileLayout} onDraft={setDraft} onSubmit={submit} onTab={setPanelTab} onSelectIndex={selectRailIndex} onToggleQuery={toggleQuery} onCollapse={collapseRail} />;
|
||
const appliedWindow = `${draft.dateFrom.replace('T', ' ').slice(5)} – ${draft.dateTo.replace('T', ' ').slice(5)}`;
|
||
const mobileAppliedWindow = compactTrackWindowLabel(draft.dateFrom, draft.dateTo);
|
||
const appliedSource = draft.protocol ? protocolDisplayLabel(draft.protocol) : '自动来源';
|
||
const mobileSheetTitle = track?.plate || draft.keyword.trim() || '轨迹查询与明细';
|
||
const mobileSheetDescription = track
|
||
? `${appliedWindow} · ${appliedSource}`
|
||
: '选择车辆、时间范围与数据来源';
|
||
const mobileSheetBadge = query.isFetching
|
||
? '读取中'
|
||
: track
|
||
? track.coverage.complete ? '时间窗完整' : '最新切片'
|
||
: '待查询';
|
||
const mobileSheetSummary = useMemo(() => track ? tripSummaryItems.map((item) => ({
|
||
label: item.label,
|
||
value: item.value,
|
||
detail: item.note,
|
||
tone: item.tone
|
||
})) : [
|
||
{ label: '查询状态', value: '待查询', detail: '请先选择车辆', tone: 'primary' as const },
|
||
{ label: '时间范围', value: mobileAppliedWindow, detail: '最长连续 7 天' },
|
||
{ label: '数据来源', value: appliedSource, detail: '支持自动优选' }
|
||
], [appliedSource, mobileAppliedWindow, track, tripSummaryItems]);
|
||
|
||
return <div className={`v2-track-page${mobileLayout ? ' is-mobile-layout' : ''}${railCollapsed ? ' is-rail-collapsed' : ''}${monitorReturn ? ' has-monitor-return' : ''}`}>
|
||
<MonitorReturnBar />
|
||
{mobileLayout ? <WorkspaceSideSheet
|
||
className="v2-track-detail-sidesheet"
|
||
variant="editor"
|
||
visible={!railCollapsed}
|
||
ariaLabel="轨迹查询与明细"
|
||
closeLabel="关闭轨迹查询与明细"
|
||
dialogId="v2-track-mobile-workbench"
|
||
placement="bottom"
|
||
height={mobileTrackSheetHeight(track, queryCollapsed, panelTab)}
|
||
title={mobileSheetTitle}
|
||
description={mobileSheetDescription}
|
||
icon={<IconMapPin />}
|
||
badge={mobileSheetBadge}
|
||
badgeColor={query.isFetching ? 'blue' : track ? 'green' : 'grey'}
|
||
summaryItems={mobileSheetSummary}
|
||
footerNote={`${appliedWindow} · ${appliedSource}`}
|
||
secondaryActions={[{
|
||
label: queryCollapsed ? '修改查询' : '收起查询',
|
||
ariaLabel: queryCollapsed ? '修改轨迹查询' : '收起轨迹查询',
|
||
icon: <IconSearch />,
|
||
onClick: toggleQuery
|
||
}]}
|
||
primaryAction={{ label: '查看地图', icon: <IconMapPin />, onClick: collapseRail }}
|
||
onCancel={collapseRail}
|
||
>{trackRail}</WorkspaceSideSheet> : 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 && track?.points.length ? <Button className="v2-track-rail-expand" theme="light" aria-label="打开轨迹详情" icon={<IconList />} onClick={() => setRailCollapsed(false)}>
|
||
<span className="v2-track-rail-expand-copy">
|
||
<strong>轨迹详情</strong>
|
||
<small>{number(track.summary.distanceKm)} km · {track.stops.length} 个停留</small>
|
||
</span>
|
||
</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>{trackPointMileageAvailable(current) ? number(current?.totalMileageKm ?? 0) : '—'}{trackPointMileageAvailable(current) ? <em> km</em> : null}</strong></span>
|
||
</div>
|
||
<div className={`v2-track-current-evidence v2-track-current-coverage${track.coverage.complete ? '' : ' is-warning'}`}>
|
||
<ProtocolTag className="v2-track-protocol-tag" protocol={current?.protocol} compact />
|
||
<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 }}><WorkspaceEmptyGuide
|
||
eyebrow="轨迹回放"
|
||
icon={<IconMapPin />}
|
||
title="先选择车辆,再开始轨迹回放"
|
||
description="地图会呈现完整路径、停留点、事件点与逐帧播放位置。"
|
||
steps={[
|
||
{ icon: <IconSearch />, title: '选择车辆', description: '车牌或 VIN 精确定位' },
|
||
{ icon: <IconMapPin />, title: '确认来源', description: '自动选择最佳定位来源' },
|
||
{ icon: <IconPlay />, title: '播放核验', description: '逐点查看速度、里程与事件' }
|
||
]}
|
||
action={<Button theme="solid" icon={<IconSearch />} onClick={() => setRailCollapsed(false)}>选择车辆</Button>}
|
||
/></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-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>{playing ? '播放中' : '已暂停'} · 数据点 {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>;
|
||
}
|