feat(web): finish Semi UI track workspace
This commit is contained in:
@@ -1,10 +1,12 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
import type { TrackPlaybackResponse } from '../../api/types';
|
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', () => {
|
describe('track domain', () => {
|
||||||
it('formats durations and maps original event indices to sampled points', () => {
|
it('formats durations and maps original event indices to sampled points', () => {
|
||||||
expect(formatDuration(3671)).toBe('01:01:11');
|
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 } as never, 11, 101)).toBe(5);
|
||||||
expect(sampledEventIndex({ index: 50, sampledIndex: 7 } as never, 11, 101)).toBe(7);
|
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(':');
|
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) {
|
export function sampledEventIndex(event: TrackPlaybackEvent, sampledCount: number, originalCount: number) {
|
||||||
if (sampledCount <= 1 || originalCount <= 1) return 0;
|
if (sampledCount <= 1 || originalCount <= 1) return 0;
|
||||||
if (Number.isInteger(event.sampledIndex) && event.sampledIndex >= 0) {
|
if (Number.isInteger(event.sampledIndex) && event.sampledIndex >= 0) {
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import { usePlatformSession } from '../auth/AuthGate';
|
|||||||
import { hasMenu } from '../auth/session';
|
import { hasMenu } from '../auth/session';
|
||||||
import { useMobileLayout } from '../hooks/useMobileLayout';
|
import { useMobileLayout } from '../hooks/useMobileLayout';
|
||||||
import { preloadRoute, scheduleIdleRoutePreloads, shouldPreloadRouteOnIntent } from '../routing/routeModules';
|
import { preloadRoute, scheduleIdleRoutePreloads, shouldPreloadRouteOnIntent } from '../routing/routeModules';
|
||||||
|
import { resetWorkspaceScroll } from '../routing/resetWorkspaceScroll';
|
||||||
import { PasswordInput } from '../shared/PasswordInput';
|
import { PasswordInput } from '../shared/PasswordInput';
|
||||||
import { WorkspaceDialog } from '../shared/WorkspaceDialog';
|
import { WorkspaceDialog } from '../shared/WorkspaceDialog';
|
||||||
import { WorkspaceSideSheet } from '../shared/WorkspaceSideSheet';
|
import { WorkspaceSideSheet } from '../shared/WorkspaceSideSheet';
|
||||||
@@ -141,16 +142,7 @@ export function AppShell() {
|
|||||||
useEffect(() => scheduleIdleRoutePreloads({ activePathname: activeRoutePath }), [activeRoutePath]);
|
useEffect(() => scheduleIdleRoutePreloads({ activePathname: activeRoutePath }), [activeRoutePath]);
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
const reset = () => {
|
const reset = () => {
|
||||||
for (const content of document.querySelectorAll<HTMLElement>('.v2-content')) {
|
for (const content of document.querySelectorAll<HTMLElement>('.v2-content')) resetWorkspaceScroll(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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
reset();
|
reset();
|
||||||
let trailingFrame = 0;
|
let trailingFrame = 0;
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import { useSearchParams } from 'react-router-dom';
|
|||||||
import { api } from '../../api/client';
|
import { api } from '../../api/client';
|
||||||
import type { TrackPlaybackEvent, TrackPlaybackResponse, VehicleRow } from '../../api/types';
|
import type { TrackPlaybackEvent, TrackPlaybackResponse, VehicleRow } from '../../api/types';
|
||||||
import { protocolDisplayLabel } from '../domain/protocols';
|
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 { TrackMap } from '../map/TrackMap';
|
||||||
import { InlineError, WorkspaceEmptyGuide } from '../shared/AsyncState';
|
import { InlineError, WorkspaceEmptyGuide } from '../shared/AsyncState';
|
||||||
import { MonitorReturnBar } from '../shared/MonitorReturnBar';
|
import { MonitorReturnBar } from '../shared/MonitorReturnBar';
|
||||||
@@ -442,6 +442,7 @@ export default function TrackPage() {
|
|||||||
const tripSummaryItems = useMemo(() => trackSummaryItems(track), [track]);
|
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 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 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 appliedSource = draft.protocol ? protocolDisplayLabel(draft.protocol) : '自动来源';
|
||||||
const mobileSheetTitle = track?.plate || draft.keyword.trim() || '轨迹查询与明细';
|
const mobileSheetTitle = track?.plate || draft.keyword.trim() || '轨迹查询与明细';
|
||||||
const mobileSheetDescription = track
|
const mobileSheetDescription = track
|
||||||
@@ -459,9 +460,9 @@ export default function TrackPage() {
|
|||||||
tone: item.tone
|
tone: item.tone
|
||||||
})) : [
|
})) : [
|
||||||
{ label: '查询状态', value: '待查询', detail: '请先选择车辆', tone: 'primary' as const },
|
{ label: '查询状态', value: '待查询', detail: '请先选择车辆', tone: 'primary' as const },
|
||||||
{ label: '时间范围', value: appliedWindow, detail: '最长连续 7 天' },
|
{ label: '时间范围', value: mobileAppliedWindow, detail: '最长连续 7 天' },
|
||||||
{ label: '数据来源', value: appliedSource, detail: '支持自动优选' }
|
{ 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' : ''}`}>
|
return <div className={`v2-track-page${mobileLayout ? ' is-mobile-layout' : ''}${railCollapsed ? ' is-rail-collapsed' : ''}${monitorReturn ? ' has-monitor-return' : ''}`}>
|
||||||
<MonitorReturnBar />
|
<MonitorReturnBar />
|
||||||
|
|||||||
@@ -98,11 +98,14 @@ describe('route recovery boundary', () => {
|
|||||||
useEffect(() => { mounts += 1; }, []);
|
useEffect(() => { mounts += 1; }, []);
|
||||||
return <><strong>本地状态 {count}</strong><button onClick={() => setCount((value) => value + 1)}>修改本地状态</button><button onClick={() => setSearchParams({ page: '2' })}>修改查询参数</button></>;
|
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: '修改本地状态' }));
|
||||||
fireEvent.click(screen.getByRole('button', { name: '修改查询参数' }));
|
fireEvent.click(screen.getByRole('button', { name: '修改查询参数' }));
|
||||||
expect(screen.getByText('本地状态 1')).toBeInTheDocument();
|
expect(screen.getByText('本地状态 1')).toBeInTheDocument();
|
||||||
expect(mounts).toBe(1);
|
expect(mounts).toBe(1);
|
||||||
|
expect(content.scrollTop).toBe(240);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('retries a failed route when its search scope changes without remounting healthy scopes', () => {
|
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 { IconAlertTriangle } from '@douyinfe/semi-icons';
|
||||||
import { Card, Tag, Typography } from '@douyinfe/semi-ui';
|
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 { useLocation } from 'react-router-dom';
|
||||||
import { PageLoading } from '../shared/AsyncState';
|
import { PageLoading } from '../shared/AsyncState';
|
||||||
import { RecoveryActions } from '../shared/RecoveryActions';
|
import { RecoveryActions } from '../shared/RecoveryActions';
|
||||||
|
import { resetWorkspaceScroll } from './resetWorkspaceScroll';
|
||||||
|
|
||||||
const { Title, Text } = Typography;
|
const { Title, Text } = Typography;
|
||||||
const CHUNK_RELOAD_KEY = 'vehicle-platform:route-chunk-reload';
|
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 }) {
|
export function RoutePage({ page, recreatePage, label }: { page: ElementType; recreatePage?: () => ElementType; label: string }) {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const routeKey = `${location.pathname}${location.search}`;
|
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 Page = useMemo(() => retryKey && recreatePage ? recreatePage() : page, [page, recreatePage, retryKey]);
|
||||||
const retry = useCallback(() => setRetryKey((value) => value + 1), []);
|
const retry = useCallback(() => setRetryKey((value) => value + 1), []);
|
||||||
return <RecoverableRouteBoundary key={location.pathname} routeKey={routeKey} retryKey={retryKey} onRetry={retry}>
|
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>;
|
</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.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}>
|
{summaryItems.map((item) => <span className={`is-${item.tone ?? 'neutral'}`} role="listitem" key={item.label}>
|
||||||
<small>{item.label}</small>
|
<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}
|
{item.detail ? <em>{item.detail}</em> : null}
|
||||||
</span>)}
|
</span>)}
|
||||||
</div> : null}
|
</div> : null}
|
||||||
|
|||||||
Reference in New Issue
Block a user