feat: unify track replay summary

This commit is contained in:
lingniu
2026-07-19 11:18:16 +08:00
parent 1be76c1dfa
commit a82bb28af9
4 changed files with 129 additions and 87 deletions

View File

@@ -18,6 +18,7 @@ import { SegmentedTabs } from '../shared/SegmentedTabs';
import { VehicleCandidateList } from '../shared/VehicleCandidateList';
import { mergeVehicleCandidates } from '../shared/vehicleCandidates';
import { WorkspaceCommandBar } from '../shared/WorkspaceCommandBar';
import { WorkspaceMetricRail, type WorkspaceQueueMetricRailItem } from '../shared/WorkspaceMetricRail';
import { WorkspacePanelHeader } from '../shared/WorkspacePanelHeader';
import { WorkspaceSideSheet } from '../shared/WorkspaceSideSheet';
import { QUERY_MEMORY } from '../queryPolicy';
@@ -127,6 +128,39 @@ function mobileTrackSheetHeight(track: TrackPlaybackResponse | undefined, queryC
return `min(88dvh, ${contentHeight}px)`;
}
function trackSummaryItems(track?: TrackPlaybackResponse): WorkspaceQueueMetricRailItem[] {
return [
{
label: '行程里程',
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());
@@ -195,9 +229,10 @@ function OverviewPanel({ track }: { track: TrackPlaybackResponse }) {
</div>;
}
const TrackRail = memo(function TrackRail({ draft, track, loading, activeStopIndexes, activeEventIndexes, tab, queryCollapsed, showCommandBar, onDraft, onSubmit, onTab, onSelectIndex, onToggleQuery, onCollapse }: {
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[];
@@ -254,12 +289,11 @@ const TrackRail = memo(function TrackRail({ draft, track, loading, activeStopInd
</section>
<section className="v2-track-rail-result">
<WorkspacePanelHeader
variant="compact"
className="v2-track-result-header"
title="轨迹证据"
description={track ? track.plate ? `${track.plate} · ${track.vin}` : track.vin : '停留、事件与来源统一呈现'}
meta={<Tag color={track ? 'blue' : 'grey'} type="light" size="small">{track ? `${number(track.summary.distanceKm)} km` : '等待查询'}</Tag>}
<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">
@@ -386,6 +420,7 @@ export default function TrackPage() {
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);
@@ -393,7 +428,8 @@ export default function TrackPage() {
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} showCommandBar={!mobileLayout} onDraft={setDraft} onSubmit={submit} onTab={setPanelTab} onSelectIndex={selectRailIndex} onToggleQuery={() => setQueryCollapsed((value) => !value)} onCollapse={collapseRail} />;
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 appliedSource = draft.protocol ? protocolDisplayLabel(draft.protocol) : '自动来源';
const mobileSheetTitle = track?.plate || draft.keyword.trim() || '轨迹查询与明细';
@@ -405,29 +441,16 @@ export default function TrackPage() {
: track
? track.coverage.complete ? '时间窗完整' : '最新切片'
: '待查询';
const mobileSheetSummary = track ? [
{
label: '行程里程',
value: `${number(track.summary.distanceKm)} km`,
detail: formatDuration(track.summary.durationSeconds),
tone: 'primary' as const
},
{
label: '停留 / 事件',
value: `${track.stops.length} / ${track.events.length}`,
detail: '地图证据点'
},
{
label: '数据点',
value: track.points.length.toLocaleString('zh-CN'),
detail: track.coverage.complete ? '时间窗完整' : '已返回切片',
tone: track.coverage.complete ? 'success' as const : 'warning' as const
}
] : [
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: appliedWindow, detail: '最长连续 7 天' },
{ label: '数据来源', value: appliedSource, detail: '支持自动优选' }
];
], [appliedSource, appliedWindow, track, tripSummaryItems]);
return <div className={`v2-track-page${mobileLayout ? ' is-mobile-layout' : ''}${railCollapsed ? ' is-rail-collapsed' : ''}${monitorReturn ? ' has-monitor-return' : ''}`}>
<MonitorReturnBar />
@@ -451,7 +474,7 @@ export default function TrackPage() {
label: queryCollapsed ? '修改查询' : '收起查询',
ariaLabel: queryCollapsed ? '修改轨迹查询' : '收起轨迹查询',
icon: <IconSearch />,
onClick: () => setQueryCollapsed((value) => !value)
onClick: toggleQuery
}]}
primaryAction={{ label: '查看地图', icon: <IconMapPin />, onClick: collapseRail }}
onCancel={collapseRail}
@@ -494,7 +517,6 @@ export default function TrackPage() {
{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"><small></small><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} 个数据点`} />