diff --git a/vehicle-data-platform/apps/web/src/v2/domain/track.test.ts b/vehicle-data-platform/apps/web/src/v2/domain/track.test.ts index c1b4f186..c7a19e12 100644 --- a/vehicle-data-platform/apps/web/src/v2/domain/track.test.ts +++ b/vehicle-data-platform/apps/web/src/v2/domain/track.test.ts @@ -1,10 +1,12 @@ import { describe, expect, it } from 'vitest'; import type { TrackPlaybackResponse } from '../../api/types'; -import { buildTrackRailSelection, formatDuration, sampledEventIndex, trackAddressCoordinate, trackCsv, trackPlaybackInterval, trackPointMileageAvailable } from './track'; +import { buildTrackRailSelection, compactTrackWindowLabel, formatDuration, sampledEventIndex, trackAddressCoordinate, trackCsv, trackPlaybackInterval, trackPointMileageAvailable } from './track'; describe('track domain', () => { it('formats durations and maps original event indices to sampled points', () => { expect(formatDuration(3671)).toBe('01:01:11'); + expect(compactTrackWindowLabel('2026-07-19T00:00', '2026-07-19T23:59')).toBe('00:00–23:59'); + expect(compactTrackWindowLabel('2026-07-18T00:00', '2026-07-19T23:59')).toBe('07-18 00:00–07-19 23:59'); expect(sampledEventIndex({ index: 50 } as never, 11, 101)).toBe(5); expect(sampledEventIndex({ index: 50, sampledIndex: 7 } as never, 11, 101)).toBe(7); }); diff --git a/vehicle-data-platform/apps/web/src/v2/domain/track.ts b/vehicle-data-platform/apps/web/src/v2/domain/track.ts index a0f1e486..9222b37c 100644 --- a/vehicle-data-platform/apps/web/src/v2/domain/track.ts +++ b/vehicle-data-platform/apps/web/src/v2/domain/track.ts @@ -26,6 +26,17 @@ export function formatDuration(seconds: number) { return [hours, minutes, remainder].map((value) => String(value).padStart(2, '0')).join(':'); } +export function compactTrackWindowLabel(dateFrom: string, dateTo: string) { + const from = dateFrom.replace('T', ' '); + const to = dateTo.replace('T', ' '); + const fromDate = from.slice(5, 10); + const toDate = to.slice(5, 10); + const fromTime = from.slice(11, 16); + const toTime = to.slice(11, 16); + if (fromDate && fromDate === toDate && fromTime && toTime) return `${fromTime}–${toTime}`; + return `${from.slice(5, 16)}–${to.slice(5, 16)}`; +} + export function sampledEventIndex(event: TrackPlaybackEvent, sampledCount: number, originalCount: number) { if (sampledCount <= 1 || originalCount <= 1) return 0; if (Number.isInteger(event.sampledIndex) && event.sampledIndex >= 0) { diff --git a/vehicle-data-platform/apps/web/src/v2/layout/AppShell.tsx b/vehicle-data-platform/apps/web/src/v2/layout/AppShell.tsx index 65ea24c4..ac387947 100644 --- a/vehicle-data-platform/apps/web/src/v2/layout/AppShell.tsx +++ b/vehicle-data-platform/apps/web/src/v2/layout/AppShell.tsx @@ -24,6 +24,7 @@ import { usePlatformSession } from '../auth/AuthGate'; import { hasMenu } from '../auth/session'; import { useMobileLayout } from '../hooks/useMobileLayout'; import { preloadRoute, scheduleIdleRoutePreloads, shouldPreloadRouteOnIntent } from '../routing/routeModules'; +import { resetWorkspaceScroll } from '../routing/resetWorkspaceScroll'; import { PasswordInput } from '../shared/PasswordInput'; import { WorkspaceDialog } from '../shared/WorkspaceDialog'; import { WorkspaceSideSheet } from '../shared/WorkspaceSideSheet'; @@ -141,16 +142,7 @@ export function AppShell() { useEffect(() => scheduleIdleRoutePreloads({ activePathname: activeRoutePath }), [activeRoutePath]); useLayoutEffect(() => { const reset = () => { - for (const content of document.querySelectorAll('.v2-content')) { - content.scrollTop = 0; - content.scrollLeft = 0; - content.scrollTo?.({ top: 0, left: 0, behavior: 'auto' }); - const routeRoot = content.firstElementChild; - if (routeRoot instanceof HTMLElement) { - routeRoot.scrollTop = 0; - routeRoot.scrollLeft = 0; - } - } + for (const content of document.querySelectorAll('.v2-content')) resetWorkspaceScroll(content); }; reset(); let trailingFrame = 0; 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 a7692f52..390637f9 100644 --- a/vehicle-data-platform/apps/web/src/v2/pages/TrackPage.tsx +++ b/vehicle-data-platform/apps/web/src/v2/pages/TrackPage.tsx @@ -9,7 +9,7 @@ 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, downloadTrackCsv, formatDuration, sampledEventIndex, TRACK_ADDRESS_SETTLE_MS, trackAddressCoordinate, trackPlaybackInterval, trackPointMileageAvailable, type TrackAddressCoordinate } from '../domain/track'; +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'; @@ -442,6 +442,7 @@ export default function TrackPage() { const tripSummaryItems = useMemo(() => trackSummaryItems(track), [track]); const trackRail = ; 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 @@ -459,9 +460,9 @@ export default function TrackPage() { tone: item.tone })) : [ { label: '查询状态', value: '待查询', detail: '请先选择车辆', tone: 'primary' as const }, - { label: '时间范围', value: appliedWindow, detail: '最长连续 7 天' }, + { label: '时间范围', value: mobileAppliedWindow, detail: '最长连续 7 天' }, { label: '数据来源', value: appliedSource, detail: '支持自动优选' } - ], [appliedSource, appliedWindow, track, tripSummaryItems]); + ], [appliedSource, mobileAppliedWindow, track, tripSummaryItems]); return
diff --git a/vehicle-data-platform/apps/web/src/v2/routing/RouteBoundary.test.tsx b/vehicle-data-platform/apps/web/src/v2/routing/RouteBoundary.test.tsx index be24eb55..b810cc3e 100644 --- a/vehicle-data-platform/apps/web/src/v2/routing/RouteBoundary.test.tsx +++ b/vehicle-data-platform/apps/web/src/v2/routing/RouteBoundary.test.tsx @@ -98,11 +98,14 @@ describe('route recovery boundary', () => { useEffect(() => { mounts += 1; }, []); return <>本地状态 {count}; } - render(); + const view = render(
); + const content = view.container.querySelector('.v2-content')!; + content.scrollTop = 240; fireEvent.click(screen.getByRole('button', { name: '修改本地状态' })); fireEvent.click(screen.getByRole('button', { name: '修改查询参数' })); expect(screen.getByText('本地状态 1')).toBeInTheDocument(); expect(mounts).toBe(1); + expect(content.scrollTop).toBe(240); }); it('retries a failed route when its search scope changes without remounting healthy scopes', () => { diff --git a/vehicle-data-platform/apps/web/src/v2/routing/RouteBoundary.tsx b/vehicle-data-platform/apps/web/src/v2/routing/RouteBoundary.tsx index 80574a72..198e891c 100644 --- a/vehicle-data-platform/apps/web/src/v2/routing/RouteBoundary.tsx +++ b/vehicle-data-platform/apps/web/src/v2/routing/RouteBoundary.tsx @@ -1,9 +1,10 @@ import { IconAlertTriangle } from '@douyinfe/semi-icons'; import { Card, Tag, Typography } from '@douyinfe/semi-ui'; -import { Component, type ElementType, type ErrorInfo, type ReactNode, Suspense, useCallback, useMemo, useState } from 'react'; +import { Component, type ElementType, type ErrorInfo, type ReactNode, Suspense, useCallback, useLayoutEffect, useMemo, useState } from 'react'; import { useLocation } from 'react-router-dom'; import { PageLoading } from '../shared/AsyncState'; import { RecoveryActions } from '../shared/RecoveryActions'; +import { resetWorkspaceScroll } from './resetWorkspaceScroll'; const { Title, Text } = Typography; const CHUNK_RELOAD_KEY = 'vehicle-platform:route-chunk-reload'; @@ -141,6 +142,16 @@ export class PlatformErrorBoundary extends Component<{ children: ReactNode }, { } } +function RouteScrollReset({ routeKey }: { routeKey: string }) { + useLayoutEffect(() => { + const reset = () => resetWorkspaceScroll(); + reset(); + const frame = window.requestAnimationFrame(reset); + return () => window.cancelAnimationFrame(frame); + }, [routeKey]); + return null; +} + export function RoutePage({ page, recreatePage, label }: { page: ElementType; recreatePage?: () => ElementType; label: string }) { const location = useLocation(); const routeKey = `${location.pathname}${location.search}`; @@ -148,6 +159,9 @@ export function RoutePage({ page, recreatePage, label }: { page: ElementType; re const Page = useMemo(() => retryKey && recreatePage ? recreatePage() : page, [page, recreatePage, retryKey]); const retry = useCallback(() => setRetryKey((value) => value + 1), []); return - }> + }> + + + ; } diff --git a/vehicle-data-platform/apps/web/src/v2/routing/resetWorkspaceScroll.ts b/vehicle-data-platform/apps/web/src/v2/routing/resetWorkspaceScroll.ts new file mode 100644 index 00000000..8c6a6583 --- /dev/null +++ b/vehicle-data-platform/apps/web/src/v2/routing/resetWorkspaceScroll.ts @@ -0,0 +1,11 @@ +export function resetWorkspaceScroll(content = document.querySelector('.v2-content')) { + if (!content) return; + content.scrollTop = 0; + content.scrollLeft = 0; + content.scrollTo?.({ top: 0, left: 0, behavior: 'auto' }); + + const routeRoot = content.firstElementChild; + if (!(routeRoot instanceof HTMLElement)) return; + routeRoot.scrollTop = 0; + routeRoot.scrollLeft = 0; +} diff --git a/vehicle-data-platform/apps/web/src/v2/shared/WorkspaceSideSheet.tsx b/vehicle-data-platform/apps/web/src/v2/shared/WorkspaceSideSheet.tsx index 60d924d2..4abb2ada 100644 --- a/vehicle-data-platform/apps/web/src/v2/shared/WorkspaceSideSheet.tsx +++ b/vehicle-data-platform/apps/web/src/v2/shared/WorkspaceSideSheet.tsx @@ -129,7 +129,7 @@ export function WorkspaceSideSheet({ {summaryItems.length ?
{summaryItems.map((item) => {item.label} - {item.value} + {item.value} {item.detail ? {item.detail} : null} )}
: null}