feat(web): finish Semi UI track workspace

This commit is contained in:
lingniu
2026-07-19 23:33:53 +08:00
parent 4c64c4c738
commit 1c18941980
8 changed files with 52 additions and 18 deletions

View File

@@ -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:0023:59');
expect(compactTrackWindowLabel('2026-07-18T00:00', '2026-07-19T23:59')).toBe('07-18 00:0007-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);
});

View File

@@ -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) {

View File

@@ -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<HTMLElement>('.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<HTMLElement>('.v2-content')) resetWorkspaceScroll(content);
};
reset();
let trailingFrame = 0;

View File

@@ -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 = <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
@@ -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 <div className={`v2-track-page${mobileLayout ? ' is-mobile-layout' : ''}${railCollapsed ? ' is-rail-collapsed' : ''}${monitorReturn ? ' has-monitor-return' : ''}`}>
<MonitorReturnBar />

View File

@@ -98,11 +98,14 @@ describe('route recovery boundary', () => {
useEffect(() => { mounts += 1; }, []);
return <><strong> {count}</strong><button onClick={() => setCount((value) => value + 1)}></button><button onClick={() => setSearchParams({ page: '2' })}></button></>;
}
render(<MemoryRouter future={ROUTER_FUTURE} initialEntries={['/alerts']}><RoutePage page={FilterablePage} label="告警中心" /></MemoryRouter>);
const view = render(<main className="v2-content"><MemoryRouter future={ROUTER_FUTURE} initialEntries={['/alerts']}><RoutePage page={FilterablePage} label="告警中心" /></MemoryRouter></main>);
const content = view.container.querySelector<HTMLElement>('.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', () => {

View File

@@ -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 <RecoverableRouteBoundary key={location.pathname} routeKey={routeKey} retryKey={retryKey} onRetry={retry}>
<Suspense fallback={<PageLoading label={`正在加载${label}`} />}><Page key={retryKey} /></Suspense>
<Suspense fallback={<PageLoading label={`正在加载${label}`} />}>
<RouteScrollReset routeKey={location.pathname} />
<Page key={retryKey} />
</Suspense>
</RecoverableRouteBoundary>;
}

View File

@@ -0,0 +1,11 @@
export function resetWorkspaceScroll(content = document.querySelector<HTMLElement>('.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;
}

View File

@@ -129,7 +129,7 @@ export function WorkspaceSideSheet({
{summaryItems.length ? <div className="v2-workspace-config-summary" role="list" aria-label={`${ariaLabel}摘要`}>
{summaryItems.map((item) => <span className={`is-${item.tone ?? 'neutral'}`} role="listitem" key={item.label}>
<small>{item.label}</small>
<strong>{item.value}</strong>
<strong title={typeof item.value === 'string' ? item.value : undefined}>{item.value}</strong>
{item.detail ? <em>{item.detail}</em> : null}
</span>)}
</div> : null}