feat: unify track and telemetry workbenches
This commit is contained in:
@@ -254,7 +254,7 @@ test('renders a map-first replay workspace and connects stop, event, and panel i
|
||||
await waitFor(() => expect(client.getQueryCache().findAll({ queryKey: ['track-address'] })).toHaveLength(0));
|
||||
});
|
||||
|
||||
test('uses a Semi bottom SideSheet for mobile track criteria and evidence', async () => {
|
||||
test('uses the shared Semi editor SideSheet for mobile track criteria and evidence', async () => {
|
||||
Object.defineProperty(window, 'matchMedia', { configurable: true, value: vi.fn(() => ({ matches: true, addEventListener: vi.fn(), removeEventListener: vi.fn() })) });
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
const view = render(<QueryClientProvider client={client}><MemoryRouter future={ROUTER_FUTURE} initialEntries={['/tracks?keyword=LTEST000000000001&dateFrom=2026-07-15T00:00&dateTo=2026-07-15T23:59']}><TrackPage /></MemoryRouter></QueryClientProvider>);
|
||||
@@ -270,6 +270,7 @@ test('uses a Semi bottom SideSheet for mobile track criteria and evidence', asyn
|
||||
return element;
|
||||
});
|
||||
expect(sheet).toHaveClass('semi-sidesheet-inner');
|
||||
expect(document.querySelector('.v2-track-detail-sidesheet')).toHaveClass('v2-workspace-editor-sidesheet', 'semi-sidesheet-bottom');
|
||||
expect(document.querySelector('.v2-track-detail-sidesheet .v2-track-rail')).toBeInTheDocument();
|
||||
expect(document.querySelector('.v2-track-detail-sidesheet .v2-track-query-summary')).not.toBeInTheDocument();
|
||||
expect(screen.getByLabelText('轨迹查询任务栏')).toHaveTextContent('粤A12345');
|
||||
|
||||
@@ -3,7 +3,7 @@ import {
|
||||
IconChevronLeft, IconChevronRight, IconClose, IconDownload, IconEyeClosed,
|
||||
IconEyeOpened, IconList, IconMapPin, IconPause, IconPlay, IconRefresh, IconSearch
|
||||
} from '@douyinfe/semi-icons';
|
||||
import { Button, Card, Descriptions, Empty, Input, List, Select, SideSheet, Slider, Tag } from '@douyinfe/semi-ui';
|
||||
import { Button, Card, Descriptions, Empty, Input, List, Select, Slider, Tag } from '@douyinfe/semi-ui';
|
||||
import { FormEvent, memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { api } from '../../api/client';
|
||||
@@ -19,10 +19,10 @@ import { VehicleCandidateList } from '../shared/VehicleCandidateList';
|
||||
import { mergeVehicleCandidates } from '../shared/vehicleCandidates';
|
||||
import { WorkspaceCommandBar } from '../shared/WorkspaceCommandBar';
|
||||
import { WorkspacePanelHeader } from '../shared/WorkspacePanelHeader';
|
||||
import { WorkspaceSideSheet } from '../shared/WorkspaceSideSheet';
|
||||
import { QUERY_MEMORY } from '../queryPolicy';
|
||||
import { monitorReturnFromParams, preserveMonitorReturn } from '../routing/monitorContext';
|
||||
import { useMobileLayout } from '../hooks/useMobileLayout';
|
||||
import { useSideSheetA11y } from '../hooks/useSideSheetA11y';
|
||||
|
||||
const speedOptions = [0.5, 1, 2, 4] as const;
|
||||
type PlaybackSpeed = (typeof speedOptions)[number];
|
||||
@@ -309,7 +309,6 @@ export default function TrackPage() {
|
||||
const [queryCollapsed, setQueryCollapsed] = useState(() => Boolean(criteria.keyword));
|
||||
const animationRef = useRef<number>();
|
||||
const lastFrameRef = useRef(0);
|
||||
useSideSheetA11y(mobileLayout && !railCollapsed, '.v2-track-detail-sidesheet', 'v2-track-detail-sheet', '轨迹查询与明细', '关闭轨迹查询与明细');
|
||||
|
||||
const params = useMemo(() => {
|
||||
const next = new URLSearchParams({ keyword: criteria.keyword, maxPoints: '1600' });
|
||||
@@ -394,19 +393,31 @@ export default function TrackPage() {
|
||||
};
|
||||
const progress = points.length > 1 ? boundedIndex / (points.length - 1) * 100 : 0;
|
||||
const trackRail = <TrackRail draft={draft} track={track} loading={query.isFetching} activeStopIndexes={activeStopIndexes} activeEventIndexes={activeEventIndexes} tab={panelTab} queryCollapsed={queryCollapsed} onDraft={setDraft} onSubmit={submit} onTab={setPanelTab} onSelectIndex={selectRailIndex} onToggleQuery={() => setQueryCollapsed((value) => !value)} onCollapse={collapseRail} />;
|
||||
const mobileSheetTitle = track?.plate || draft.keyword.trim() || '轨迹查询与明细';
|
||||
const mobileSheetDescription = track
|
||||
? `${number(track.summary.distanceKm)} km · ${track.stops.length} 个停留 · ${track.events.length} 个事件`
|
||||
: '选择车辆和时间范围';
|
||||
const mobileSheetBadge = query.isFetching
|
||||
? '读取中'
|
||||
: track
|
||||
? `${track.points.length.toLocaleString('zh-CN')} 点`
|
||||
: '待查询';
|
||||
|
||||
return <div className={`v2-track-page${mobileLayout ? ' is-mobile-layout' : ''}${railCollapsed ? ' is-rail-collapsed' : ''}${monitorReturn ? ' has-monitor-return' : ''}`}>
|
||||
<MonitorReturnBar />
|
||||
{mobileLayout ? <SideSheet
|
||||
{mobileLayout ? <WorkspaceSideSheet
|
||||
className="v2-track-detail-sidesheet"
|
||||
variant="editor"
|
||||
visible={!railCollapsed}
|
||||
ariaLabel="轨迹查询与明细"
|
||||
placement="bottom"
|
||||
height={mobileTrackSheetHeight(track, queryCollapsed, panelTab)}
|
||||
aria-label="轨迹查询与明细"
|
||||
title={<div className="v2-track-detail-sheet-title"><strong>轨迹查询与明细</strong><span>{track ? `${number(track.summary.distanceKm)} km · ${track.stops.length} 个停留 · ${track.events.length} 个事件` : '选择车辆和时间范围'}</span></div>}
|
||||
footer={null}
|
||||
title={mobileSheetTitle}
|
||||
description={mobileSheetDescription}
|
||||
badge={mobileSheetBadge}
|
||||
badgeColor={query.isFetching ? 'blue' : track ? 'green' : 'grey'}
|
||||
onCancel={collapseRail}
|
||||
>{trackRail}</SideSheet> : trackRail}
|
||||
>{trackRail}</WorkspaceSideSheet> : trackRail}
|
||||
<section className="v2-track-stage">
|
||||
<TrackMap points={points} stops={track?.stops ?? []} activeIndex={boundedIndex} showStops={showStops} follow={follow} followDurationMs={playing ? Math.max(40, trackPlaybackInterval(playbackSpeed) - 10) : 180} onSelectIndex={selectIndex} onFollowChange={setFollow} />
|
||||
|
||||
|
||||
@@ -495,10 +495,17 @@ test('keeps protocol telemetry independent and explains mileage semantics', asyn
|
||||
expect(complexValue).toHaveTextContent('3.21,3.22,3.23');
|
||||
fireEvent.click(complexValue);
|
||||
expect(await screen.findByRole('dialog', { name: '遥测字段详情' })).toBeInTheDocument();
|
||||
expect(document.querySelector('.v2-telemetry-evidence-sidesheet')).toHaveClass('v2-workspace-detail-sidesheet');
|
||||
expect(document.querySelector('.v2-telemetry-detail-protocol')).toHaveClass('semi-tag-blue-light');
|
||||
expect(document.querySelector('.v2-telemetry-value-meta')).toHaveTextContent('数据类型列表');
|
||||
const detailSummary = screen.getByRole('list', { name: '遥测字段详情摘要' });
|
||||
expect(detailSummary).toHaveTextContent('协议来源');
|
||||
expect(detailSummary).toHaveTextContent('10.0.0.1:8080');
|
||||
expect(detailSummary).toHaveTextContent('完整值');
|
||||
expect(detailSummary).toHaveTextContent('4 个值');
|
||||
expect(detailSummary).toHaveTextContent('数据质量');
|
||||
expect(detailSummary).toHaveTextContent('正常');
|
||||
expect(document.querySelector('.v2-telemetry-value-detail pre')).toHaveTextContent('3.24');
|
||||
expect(document.querySelector('.v2-telemetry-value-detail > footer strong')).toHaveTextContent('10.0.0.1:8080');
|
||||
fireEvent.click(screen.getByRole('tab', { name: /JT\/T 808/ }));
|
||||
expect(screen.getByRole('tab', { name: /JT\/T 808/, selected: true })).toBeInTheDocument();
|
||||
expect(screen.getByText('GPS 总里程')).toBeInTheDocument();
|
||||
@@ -530,7 +537,7 @@ test('uses compact Semi telemetry evidence cards on mobile', async () => {
|
||||
expect(screen.getByRole('button', { name: '查看 GPS 速度 字段详情' })).toHaveClass('v2-telemetry-mobile-action');
|
||||
fireEvent.click(screen.getByRole('button', { name: '查看 GPS 速度 字段详情' }));
|
||||
expect(await screen.findByRole('dialog', { name: '遥测字段详情' })).toBeInTheDocument();
|
||||
expect(document.querySelector('.v2-telemetry-value-sidesheet')).toHaveClass('semi-sidesheet-bottom');
|
||||
expect(document.querySelector('.v2-telemetry-evidence-sidesheet')).toHaveClass('v2-workspace-detail-sidesheet', 'semi-sidesheet-bottom');
|
||||
expect(document.querySelector('.v2-telemetry-value-meta')).toHaveTextContent('数据类型数值');
|
||||
expect(document.querySelector('.v2-telemetry-detail-protocol')).toHaveClass('semi-tag-cyan-light');
|
||||
expect(screen.getByRole('button', { name: '关闭遥测字段详情' })).toBeInTheDocument();
|
||||
|
||||
@@ -3,7 +3,7 @@ import {
|
||||
IconAlarm, IconBox, IconCalendar, IconClock, IconCopy,
|
||||
IconChevronRight, IconMapPin, IconSearch, IconTickCircle
|
||||
} from '@douyinfe/semi-icons';
|
||||
import { Button, Card, Descriptions, Empty, Input, List, Select, SideSheet, Table, Tag } from '@douyinfe/semi-ui';
|
||||
import { Button, Card, Descriptions, Empty, Input, List, Select, Table, Tag } from '@douyinfe/semi-ui';
|
||||
import { FormEvent, lazy, Suspense, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Link, useNavigate, useParams, useSearchParams } from 'react-router-dom';
|
||||
import { api } from '../../api/client';
|
||||
@@ -24,9 +24,9 @@ import { SegmentedTabs } from '../shared/SegmentedTabs';
|
||||
import { VehicleSourceEvidencePanel } from '../shared/VehicleSourceEvidencePanel';
|
||||
import { WorkspaceCommandBar } from '../shared/WorkspaceCommandBar';
|
||||
import { WorkspacePanelHeader } from '../shared/WorkspacePanelHeader';
|
||||
import { WorkspaceSideSheet, type WorkspaceSideSheetBadgeColor } from '../shared/WorkspaceSideSheet';
|
||||
import { monitorReturnFromParams, withMonitorReturn } from '../routing/monitorContext';
|
||||
import { useMobileLayout } from '../hooks/useMobileLayout';
|
||||
import { useSideSheetA11y } from '../hooks/useSideSheetA11y';
|
||||
|
||||
function fmt(value?: string) { return value?.trim() || '—'; }
|
||||
function metric(value: number | undefined) { return Number.isFinite(value as number) ? formatZhNumber(value!, 1) : '—'; }
|
||||
@@ -245,7 +245,10 @@ function TelemetryPanel({ data, pending, error }: { data?: LatestTelemetryRespon
|
||||
const [copiedValue, setCopiedValue] = useState(false);
|
||||
const inspectedPresentation = useMemo(() => inspectedValue ? telemetryValuePresentation(inspectedValue) : undefined, [inspectedValue]);
|
||||
const inspectedDetail = useMemo(() => inspectedValue ? telemetryValueDetail(inspectedValue) : '', [inspectedValue]);
|
||||
useSideSheetA11y(!!inspectedValue, '.v2-telemetry-value-sidesheet', 'v2-telemetry-value-detail', '遥测字段详情', '关闭遥测字段详情');
|
||||
const closeInspection = () => {
|
||||
setInspectedValue(undefined);
|
||||
setCopiedValue(false);
|
||||
};
|
||||
const indexed = useMemo(() => {
|
||||
const valuesByProtocolCategory = new Map<string, LatestTelemetryResponse['values']>();
|
||||
const sources = new Map<string, { protocol: string; endpoint?: string }>();
|
||||
@@ -311,16 +314,40 @@ function TelemetryPanel({ data, pending, error }: { data?: LatestTelemetryRespon
|
||||
</div>
|
||||
{state}
|
||||
<footer><b>来源</b>{indexed.sources.map((source) => <span key={`${source.protocol}-${source.endpoint ?? ''}`} title={source.endpoint}>{protocolDisplayLabel(source.protocol)}</span>)}<small>扫描 {data?.scannedFrames ?? 0} 帧 · 上海时间 {formatTelemetryTime(data?.asOf)}</small></footer>
|
||||
<SideSheet
|
||||
className="v2-telemetry-value-sidesheet"
|
||||
<WorkspaceSideSheet
|
||||
className="v2-telemetry-evidence-sidesheet"
|
||||
variant="detail"
|
||||
visible={!!inspectedValue}
|
||||
ariaLabel="遥测字段详情"
|
||||
placement={mobileLayout ? 'bottom' : 'right'}
|
||||
width={mobileLayout ? undefined : 520}
|
||||
height={mobileLayout ? 'min(88dvh, 780px)' : undefined}
|
||||
aria-label="遥测字段详情"
|
||||
title={inspectedValue ? <div className="v2-telemetry-value-title"><strong>{inspectedValue.label}</strong><span>{inspectedValue.sourceField}</span></div> : null}
|
||||
onCancel={() => { setInspectedValue(undefined); setCopiedValue(false); }}
|
||||
footer={<div className="v2-telemetry-value-footer"><span>完整值仅按需渲染,不影响实时遥测刷新。</span><Button theme="solid" onClick={() => { setInspectedValue(undefined); setCopiedValue(false); }}>完成</Button></div>}
|
||||
title={inspectedValue?.label}
|
||||
description={inspectedValue?.sourceField}
|
||||
badge={inspectedValue ? telemetryQualityLabel(inspectedValue.quality) : undefined}
|
||||
badgeColor={inspectedValue ? telemetryQualityColor(inspectedValue.quality) as WorkspaceSideSheetBadgeColor : 'grey'}
|
||||
summaryItems={inspectedValue ? [
|
||||
{
|
||||
label: '协议来源',
|
||||
value: <ProtocolTag className="v2-telemetry-detail-protocol" protocol={inspectedValue.protocol} compact />,
|
||||
detail: inspectedValue.sourceEndpoint || '协议默认来源',
|
||||
tone: 'primary'
|
||||
},
|
||||
{
|
||||
label: '完整值',
|
||||
value: inspectedPresentation?.summary || '—',
|
||||
detail: inspectedValue.unit ? `单位 ${inspectedValue.unit}` : '无单位'
|
||||
},
|
||||
{
|
||||
label: '数据质量',
|
||||
value: telemetryQualityLabel(inspectedValue.quality),
|
||||
detail: `${formatZhNumber(inspectedValue.freshnessSeconds, 0)}s · ${inspectedValue.qualityReason || '未提供说明'}`,
|
||||
tone: inspectedValue.quality === 'good' ? 'success' : inspectedValue.quality === 'stale' ? 'warning' : 'danger'
|
||||
}
|
||||
] : []}
|
||||
footerNote="完整值按需渲染,不影响实时遥测刷新。"
|
||||
primaryAction={{ label: '完成', onClick: closeInspection }}
|
||||
onCancel={closeInspection}
|
||||
>
|
||||
{inspectedValue ? <div className="v2-telemetry-value-detail">
|
||||
<Descriptions
|
||||
@@ -328,12 +355,11 @@ function TelemetryPanel({ data, pending, error }: { data?: LatestTelemetryRespon
|
||||
size="small"
|
||||
row
|
||||
data={[
|
||||
{ key: '协议', value: <ProtocolTag className="v2-telemetry-detail-protocol" protocol={inspectedValue.protocol} compact /> },
|
||||
{ key: '数据类型', value: telemetryValueTypeLabel(inspectedValue.valueType) },
|
||||
{ key: '单位', value: inspectedValue.unit || '—' },
|
||||
{ key: '质量', value: <Tag className="v2-telemetry-detail-quality" color={telemetryQualityColor(inspectedValue.quality)} type="light" size="small">{telemetryQualityLabel(inspectedValue.quality)}</Tag> },
|
||||
{ key: '设备时间', value: formatTelemetryTime(inspectedValue.deviceTime) },
|
||||
{ key: '接收时间', value: formatTelemetryTime(inspectedValue.serverTime) }
|
||||
{ key: '接收时间', value: formatTelemetryTime(inspectedValue.serverTime) },
|
||||
{ key: '质量说明', value: inspectedValue.qualityReason || '未提供质量说明' }
|
||||
]}
|
||||
/>
|
||||
<section>
|
||||
@@ -347,9 +373,8 @@ function TelemetryPanel({ data, pending, error }: { data?: LatestTelemetryRespon
|
||||
>{copiedValue ? '已复制' : '复制完整值'}</Button></header>
|
||||
<pre>{inspectedDetail}</pre>
|
||||
</section>
|
||||
<footer><span>来源端点</span><strong>{inspectedValue.sourceEndpoint || '协议默认来源'}</strong></footer>
|
||||
</div> : null}
|
||||
</SideSheet>
|
||||
</WorkspaceSideSheet>
|
||||
</Card>;
|
||||
}
|
||||
|
||||
|
||||
@@ -95,6 +95,10 @@ describe('V2 production entry', () => {
|
||||
expect(corePageSources.VehiclePage).toContain('<List.Item className="v2-telemetry-mobile-item"');
|
||||
expect(corePageSources.VehiclePage).toContain('className="v2-telemetry-mobile-action"');
|
||||
expect(corePageSources.VehiclePage).toContain("placement={mobileLayout ? 'bottom' : 'right'}");
|
||||
expect(corePageSources.VehiclePage).toContain("from '../shared/WorkspaceSideSheet'");
|
||||
expect(corePageSources.VehiclePage).toContain('<WorkspaceSideSheet');
|
||||
expect(corePageSources.VehiclePage).toContain('className="v2-telemetry-evidence-sidesheet"');
|
||||
expect(corePageSources.VehiclePage).not.toContain('useSideSheetA11y(!!inspectedValue');
|
||||
expect(corePageSources.VehiclePage).toContain("item?.sourceEndpoint ?? ''");
|
||||
expect(corePageSources.VehiclePage).toContain('const columns = useMemo(() => [');
|
||||
expect(corePageSources.VehiclePage).toContain('<List');
|
||||
@@ -368,6 +372,10 @@ describe('V2 production entry', () => {
|
||||
expect(corePageSources.TrackPage).toContain('<List className="v2-track-evidence-list v2-track-event-list"');
|
||||
expect(corePageSources.TrackPage).toContain('<List.Item className="v2-track-evidence-item"');
|
||||
expect(corePageSources.TrackPage).toContain('<Empty image={<IconMapPin />} title="先选择车辆,再开始轨迹回放"');
|
||||
expect(corePageSources.TrackPage).toContain("from '../shared/WorkspaceSideSheet'");
|
||||
expect(corePageSources.TrackPage).toContain('<WorkspaceSideSheet');
|
||||
expect(corePageSources.TrackPage).toContain('variant="editor"');
|
||||
expect(corePageSources.TrackPage).not.toContain('useSideSheetA11y(mobileLayout');
|
||||
expect(corePageSources.TrackPage).not.toContain('<Card className="v2-track-overview-card"><header><strong>行程概览</strong><Tag color={track.sampled ? \'orange\' : \'green\'} type="light" size="small">{track.sampled ? \'地图已抽稀\' : \'完整点集\'}</Tag></header><dl>');
|
||||
expect(corePageSources.TrackPage).toContain('<section className="v2-track-overview-section v2-track-source-card">');
|
||||
expect(corePageSources.TrackPage).toContain('<List className="v2-track-source-list"');
|
||||
|
||||
@@ -21129,6 +21129,75 @@
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.v2-track-detail-sidesheet.v2-workspace-editor-sidesheet .v2-workspace-config-body,
|
||||
.v2-track-detail-sidesheet.v2-workspace-editor-sidesheet .v2-workspace-config-content,
|
||||
.v2-track-detail-sidesheet.v2-workspace-editor-sidesheet .v2-track-rail {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.v2-track-detail-sidesheet.v2-workspace-editor-sidesheet .v2-workspace-config-title small {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.v2-telemetry-evidence-sidesheet .v2-workspace-config-content {
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.v2-telemetry-evidence-sidesheet .v2-workspace-config-summary > span:first-child strong {
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.v2-telemetry-evidence-sidesheet .v2-telemetry-detail-protocol.semi-tag {
|
||||
min-height: 22px;
|
||||
margin: 0;
|
||||
border-radius: 999px;
|
||||
font-size: 9px;
|
||||
}
|
||||
|
||||
.v2-telemetry-evidence-sidesheet .v2-telemetry-value-detail {
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.v2-telemetry-evidence-sidesheet .v2-telemetry-value-meta.semi-descriptions,
|
||||
.v2-telemetry-evidence-sidesheet .v2-telemetry-value-detail > section {
|
||||
border-color: #dce5ef;
|
||||
border-radius: 11px;
|
||||
box-shadow: 0 7px 20px rgba(31, 53, 80, .045);
|
||||
}
|
||||
|
||||
.v2-telemetry-evidence-sidesheet .v2-telemetry-value-detail pre {
|
||||
min-height: 220px;
|
||||
max-height: none;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
@media (max-width: 680px) {
|
||||
.v2-telemetry-evidence-sidesheet .v2-workspace-config-content {
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.v2-telemetry-evidence-sidesheet .v2-telemetry-value-meta .semi-descriptions-item {
|
||||
min-width: 112px;
|
||||
}
|
||||
|
||||
.v2-telemetry-evidence-sidesheet .v2-telemetry-value-detail > section > header {
|
||||
min-height: 50px;
|
||||
align-items: center;
|
||||
flex-direction: row;
|
||||
padding: 8px 10px 8px 12px;
|
||||
}
|
||||
|
||||
.v2-telemetry-evidence-sidesheet .v2-telemetry-value-detail > section > header .semi-button {
|
||||
width: auto;
|
||||
min-width: 108px;
|
||||
}
|
||||
|
||||
.v2-telemetry-evidence-sidesheet .v2-telemetry-value-detail pre {
|
||||
min-height: 260px;
|
||||
font-size: 9px;
|
||||
}
|
||||
}
|
||||
|
||||
.v2-history-export-sidesheet .v2-export-jobs.semi-card {
|
||||
min-height: 100%;
|
||||
border-color: #dce5ef;
|
||||
|
||||
Reference in New Issue
Block a user