feat(web): finish Semi UI track workspace
This commit is contained in:
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user