From a82bb28af977b03b11d2d1a7c45c041ebd668dc0 Mon Sep 17 00:00:00 2001 From: lingniu Date: Sun, 19 Jul 2026 11:18:16 +0800 Subject: [PATCH] feat: unify track replay summary --- .../apps/web/src/v2/pages/TrackPage.test.tsx | 18 ++- .../apps/web/src/v2/pages/TrackPage.tsx | 82 ++++++++----- .../apps/web/src/v2/productionEntry.test.ts | 4 + .../apps/web/src/v2/styles/workspace.css | 112 +++++++++--------- 4 files changed, 129 insertions(+), 87 deletions(-) diff --git a/vehicle-data-platform/apps/web/src/v2/pages/TrackPage.test.tsx b/vehicle-data-platform/apps/web/src/v2/pages/TrackPage.test.tsx index 69151500..2107c6ac 100644 --- a/vehicle-data-platform/apps/web/src/v2/pages/TrackPage.test.tsx +++ b/vehicle-data-platform/apps/web/src/v2/pages/TrackPage.test.tsx @@ -179,7 +179,14 @@ test('renders a map-first replay workspace and connects stop, event, and panel i expect(view.container.querySelector('.v2-track-query-panel')).toHaveClass('is-collapsed'); expect(view.container.querySelector('.v2-track-query-summary')).not.toBeInTheDocument(); expect(view.container.querySelector('.v2-track-rail-result')).toBeInTheDocument(); - expect(view.container.querySelector('.v2-track-result-header')).toHaveTextContent('轨迹证据粤A12345 · LTEST0000000000018 km'); + const tripSummary = screen.getByRole('list', { name: '轨迹行程摘要' }); + expect(tripSummary.closest('.v2-track-result-metric-rail')).toHaveClass('semi-card', 'v2-workspace-metric-rail', 'is-queue'); + expect(Array.from(tripSummary.querySelectorAll(':scope > [role="listitem"] > small')).map((item) => item.textContent)).toEqual(['行程里程', '停留点', '事件点', '数据点']); + expect(tripSummary).toHaveTextContent('行程里程8 km00:20:00'); + expect(tripSummary).toHaveTextContent('停留点1超过 3 分钟'); + expect(tripSummary).toHaveTextContent('事件点3轨迹事件'); + expect(tripSummary).toHaveTextContent('数据点3时间窗完整'); + expect(view.container.querySelector('.v2-track-result-header')).not.toBeInTheDocument(); expect(screen.getByRole('button', { name: '修改条件' })).toHaveAttribute('aria-expanded', 'false'); expect(screen.queryByRole('textbox', { name: '搜索轨迹车辆' })).not.toBeInTheDocument(); expect(view.container.querySelector('.v2-track-current-card.semi-card')).toBeInTheDocument(); @@ -196,7 +203,7 @@ test('renders a map-first replay workspace and connects stop, event, and panel i expect(screen.getByRole('button', { name: /停留 00:05:00/ }).closest('.semi-list-item')).toHaveClass('v2-track-evidence-item'); expect(view.container.querySelector('.v2-track-evidence-list.semi-list')).toBeInTheDocument(); expect(screen.getByRole('slider', { name: '轨迹播放进度' })).toBeInTheDocument(); - expect(view.container.querySelector('.v2-track-dock-summary')).toHaveTextContent('行程里程8 km00:20:00 · 1 次停留'); + expect(view.container.querySelector('.v2-track-dock-summary')).not.toBeInTheDocument(); expect(view.container.querySelector('.v2-track-progress-meta')).toHaveTextContent('已暂停 · 数据点 1 / 3'); expect(screen.queryByRole('button', { name: '暂停轨迹播放' })).not.toBeInTheDocument(); await waitFor(() => expect(mocks.reverseGeocode).toHaveBeenCalledTimes(1)); @@ -277,7 +284,12 @@ test('uses the shared Semi editor SideSheet for mobile track criteria and eviden expect(screen.queryByLabelText('轨迹查询任务栏')).not.toBeInTheDocument(); expect(screen.queryByRole('textbox', { name: '搜索轨迹车辆' })).not.toBeInTheDocument(); expect(screen.getAllByText('07-15 00:00 – 07-15 23:59 · 自动来源')).toHaveLength(2); - expect(screen.getByRole('list', { name: '轨迹查询与明细摘要' })).toHaveTextContent('行程里程8 km00:20:00停留 / 事件1 / 3地图证据点数据点3时间窗完整'); + const mobileSummary = screen.getByRole('list', { name: '轨迹查询与明细摘要' }); + expect(mobileSummary).toHaveTextContent('行程里程8 km00:20:00'); + expect(mobileSummary).toHaveTextContent('停留点1超过 3 分钟'); + expect(mobileSummary).toHaveTextContent('事件点3轨迹事件'); + expect(mobileSummary).toHaveTextContent('数据点3时间窗完整'); + expect(mobileSummary.querySelectorAll(':scope > [role="listitem"]')).toHaveLength(4); expect(screen.getByRole('button', { name: '修改轨迹查询' })).toHaveTextContent('修改查询'); expect(screen.getByRole('button', { name: /查看地图$/ })).toBeInTheDocument(); expect(sheet).toHaveStyle({ height: 'min(88dvh, 394px)' }); diff --git a/vehicle-data-platform/apps/web/src/v2/pages/TrackPage.tsx b/vehicle-data-platform/apps/web/src/v2/pages/TrackPage.tsx index 7788443a..2e8914ea 100644 --- a/vehicle-data-platform/apps/web/src/v2/pages/TrackPage.tsx +++ b/vehicle-data-platform/apps/web/src/v2/pages/TrackPage.tsx @@ -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 }) { ; } -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
- {track ? `${number(track.summary.distanceKm)} km` : '等待查询'}} +
@@ -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 = setQueryCollapsed((value) => !value)} onCollapse={collapseRail} />; + const tripSummaryItems = useMemo(() => trackSummaryItems(track), [track]); + const trackRail = ; 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
@@ -451,7 +474,7 @@ export default function TrackPage() { label: queryCollapsed ? '修改查询' : '收起查询', ariaLabel: queryCollapsed ? '修改轨迹查询' : '收起轨迹查询', icon: , - onClick: () => setQueryCollapsed((value) => !value) + onClick: toggleQuery }]} primaryAction={{ label: '查看地图', icon: , onClick: collapseRail }} onCancel={collapseRail} @@ -494,7 +517,6 @@ export default function TrackPage() { {query.isError ?
query.refetch()} />
: null} -
行程里程{track ? `${number(track.summary.distanceKm)} km` : '等待查询'}{track ? `${formatDuration(track.summary.durationSeconds)} · ${track.summary.stopCount} 次停留` : '查询后可播放完整轨迹'}
{track ? :
} selectIndex(Number(value))} disabled={!points.length} showBoundary={false} tipFormatter={(value) => `第 ${Number(value) + 1} / ${points.length} 个数据点`} /> diff --git a/vehicle-data-platform/apps/web/src/v2/productionEntry.test.ts b/vehicle-data-platform/apps/web/src/v2/productionEntry.test.ts index 4c804c51..5dec05cb 100644 --- a/vehicle-data-platform/apps/web/src/v2/productionEntry.test.ts +++ b/vehicle-data-platform/apps/web/src/v2/productionEntry.test.ts @@ -392,6 +392,10 @@ describe('V2 production entry', () => { expect(corePageSources.TrackPage).toContain(''); + expect(corePageSources.TrackPage).toContain("from '../shared/WorkspaceMetricRail'"); + expect(corePageSources.TrackPage).toContain(' h5.semi-typography { - color: #25384f; - font-size: 15px; -} - -.v2-track-result-header .v2-workspace-panel-copy > .semi-typography { - color: #8592a3; - font-size: 11px; -} - -.v2-track-result-header .v2-workspace-panel-meta .semi-tag { - border-radius: 999px; - padding-inline: 10px; - font-size: 11px; - font-weight: 700; -} - .v2-track-rail-tabs { height: 46px; flex-basis: 46px; @@ -1935,37 +1912,11 @@ .v2-track-playback-dock > .semi-card-body { min-height: 104px; - grid-template-columns: 150px minmax(240px, 1fr) auto; + grid-template-columns: minmax(240px, 1fr) auto; gap: 16px; padding: 11px 16px !important; } -.v2-track-dock-summary small, -.v2-track-dock-summary strong, -.v2-track-dock-summary span { - display: block; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.v2-track-dock-summary small { - margin-bottom: 2px; - color: #8593a5; - font-size: 9px; - font-weight: 650; - letter-spacing: .04em; -} - -.v2-track-dock-summary strong { - font-size: 20px; -} - -.v2-track-dock-summary span { - margin-top: 3px; - font-size: 10px; -} - .v2-track-segment-rail, .v2-track-segment-placeholder { height: 8px; @@ -2028,9 +1979,6 @@ grid-template-columns: minmax(220px, 1fr) auto; } - .v2-track-dock-summary { - display: none; - } } @media (max-width: 700px) { @@ -2055,7 +2003,7 @@ max-height: 156px; } - .v2-track-detail-sidesheet .v2-track-result-header { + .v2-track-detail-sidesheet .v2-track-result-metric-rail { display: none; } @@ -23162,6 +23110,62 @@ } } +/* + * Track replay trip summary. + * The narrow evidence rail uses the same Semi metric language as monitor and + * vehicle workspaces, while playback controls remain focused on time and + * transport instead of repeating trip totals. + */ +.v2-track-result-metric-rail.is-queue.semi-card { + flex: 0 0 auto; + border-bottom-color: #dfe6ef; +} + +.v2-track-result-metric-rail.is-queue > .semi-card-body { + min-height: 64px; + grid-template-columns: minmax(0, 1fr); +} + +.v2-track-result-metric-rail .v2-workspace-metric-list { + grid-template-columns: 1.18fr .9fr .9fr 1fr; +} + +.v2-track-result-metric-rail .v2-workspace-metric-list > span { + min-height: 64px; + grid-template-columns: minmax(0, 1fr); + column-gap: 0; + padding: 7px 8px; +} + +.v2-track-result-metric-rail .v2-workspace-metric-list > span > :is(small, strong) { + overflow: hidden; + text-overflow: ellipsis; +} + +.v2-track-result-metric-rail .v2-workspace-metric-list > span > small { + font-size: 8px; +} + +.v2-track-result-metric-rail .v2-workspace-metric-list > span > strong, +.v2-track-result-metric-rail .v2-workspace-metric-list > span.is-secondary > strong { + font-size: clamp(14px, 1.2vw, 17px); + line-height: 20px; +} + +.v2-track-result-metric-rail .v2-workspace-metric-list > span > em { + display: none; +} + +@media (max-width: 680px) { + .v2-track-detail-sidesheet .v2-workspace-config-summary { + grid-template-columns: repeat(4, minmax(0, 1fr)); + } + + .v2-track-detail-sidesheet .v2-workspace-config-summary > span { + padding-inline: 8px; + } +} + /* * Monitor fleet summary. * Operational state remains prominent; high-volume ingest evidence is kept