From f2fe5404d9078680119c3622a770f96e10657a6c Mon Sep 17 00:00:00 2001 From: lingniu Date: Sun, 19 Jul 2026 06:29:55 +0800 Subject: [PATCH] feat: unify track and telemetry workbenches --- .../apps/web/src/v2/pages/TrackPage.test.tsx | 3 +- .../apps/web/src/v2/pages/TrackPage.tsx | 27 +++++--- .../web/src/v2/pages/VehiclePage.test.tsx | 11 ++- .../apps/web/src/v2/pages/VehiclePage.tsx | 53 ++++++++++---- .../apps/web/src/v2/productionEntry.test.ts | 8 +++ .../apps/web/src/v2/styles/workspace.css | 69 +++++++++++++++++++ 6 files changed, 146 insertions(+), 25 deletions(-) diff --git a/vehicle-data-platform/apps/web/src/v2/pages/TrackPage.test.tsx b/vehicle-data-platform/apps/web/src/v2/pages/TrackPage.test.tsx index e59c0e4b..8abbb6c6 100644 --- a/vehicle-data-platform/apps/web/src/v2/pages/TrackPage.test.tsx +++ b/vehicle-data-platform/apps/web/src/v2/pages/TrackPage.test.tsx @@ -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(); @@ -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'); 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 4208af26..fdb461d7 100644 --- a/vehicle-data-platform/apps/web/src/v2/pages/TrackPage.tsx +++ b/vehicle-data-platform/apps/web/src/v2/pages/TrackPage.tsx @@ -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(); 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 = 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
- {mobileLayout ? 轨迹查询与明细{track ? `${number(track.summary.distanceKm)} km · ${track.stops.length} 个停留 · ${track.events.length} 个事件` : '选择车辆和时间范围'}
} - footer={null} + title={mobileSheetTitle} + description={mobileSheetDescription} + badge={mobileSheetBadge} + badgeColor={query.isFetching ? 'blue' : track ? 'green' : 'grey'} onCancel={collapseRail} - >{trackRail} : trackRail} + >{trackRail} : trackRail}
diff --git a/vehicle-data-platform/apps/web/src/v2/pages/VehiclePage.test.tsx b/vehicle-data-platform/apps/web/src/v2/pages/VehiclePage.test.tsx index 1c2fc2c8..05818756 100644 --- a/vehicle-data-platform/apps/web/src/v2/pages/VehiclePage.test.tsx +++ b/vehicle-data-platform/apps/web/src/v2/pages/VehiclePage.test.tsx @@ -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(); diff --git a/vehicle-data-platform/apps/web/src/v2/pages/VehiclePage.tsx b/vehicle-data-platform/apps/web/src/v2/pages/VehiclePage.tsx index 81bce7fa..726a3aa7 100644 --- a/vehicle-data-platform/apps/web/src/v2/pages/VehiclePage.tsx +++ b/vehicle-data-platform/apps/web/src/v2/pages/VehiclePage.tsx @@ -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(); const sources = new Map(); @@ -311,16 +314,40 @@ function TelemetryPanel({ data, pending, error }: { data?: LatestTelemetryRespon {state}
来源{indexed.sources.map((source) => {protocolDisplayLabel(source.protocol)})}扫描 {data?.scannedFrames ?? 0} 帧 · 上海时间 {formatTelemetryTime(data?.asOf)}
- {inspectedValue.label}{inspectedValue.sourceField} : null} - onCancel={() => { setInspectedValue(undefined); setCopiedValue(false); }} - footer={
完整值仅按需渲染,不影响实时遥测刷新。
} + title={inspectedValue?.label} + description={inspectedValue?.sourceField} + badge={inspectedValue ? telemetryQualityLabel(inspectedValue.quality) : undefined} + badgeColor={inspectedValue ? telemetryQualityColor(inspectedValue.quality) as WorkspaceSideSheetBadgeColor : 'grey'} + summaryItems={inspectedValue ? [ + { + label: '协议来源', + value: , + 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 ?
}, { key: '数据类型', value: telemetryValueTypeLabel(inspectedValue.valueType) }, { key: '单位', value: inspectedValue.unit || '—' }, - { key: '质量', value: {telemetryQualityLabel(inspectedValue.quality)} }, { key: '设备时间', value: formatTelemetryTime(inspectedValue.deviceTime) }, - { key: '接收时间', value: formatTelemetryTime(inspectedValue.serverTime) } + { key: '接收时间', value: formatTelemetryTime(inspectedValue.serverTime) }, + { key: '质量说明', value: inspectedValue.qualityReason || '未提供质量说明' } ]} />
@@ -347,9 +373,8 @@ function TelemetryPanel({ data, pending, error }: { data?: LatestTelemetryRespon >{copiedValue ? '已复制' : '复制完整值'}
{inspectedDetail}
-
来源端点{inspectedValue.sourceEndpoint || '协议默认来源'}
: null} -
+ ; } diff --git a/vehicle-data-platform/apps/web/src/v2/productionEntry.test.ts b/vehicle-data-platform/apps/web/src/v2/productionEntry.test.ts index 3aae51d3..83b6f6ad 100644 --- a/vehicle-data-platform/apps/web/src/v2/productionEntry.test.ts +++ b/vehicle-data-platform/apps/web/src/v2/productionEntry.test.ts @@ -95,6 +95,10 @@ describe('V2 production entry', () => { expect(corePageSources.VehiclePage).toContain(' ['); expect(corePageSources.VehiclePage).toContain(' { expect(corePageSources.TrackPage).toContain('} title="先选择车辆,再开始轨迹回放"'); + expect(corePageSources.TrackPage).toContain("from '../shared/WorkspaceSideSheet'"); + expect(corePageSources.TrackPage).toContain('
行程概览{track.sampled ? \'地图已抽稀\' : \'完整点集\'}
'); expect(corePageSources.TrackPage).toContain('
'); expect(corePageSources.TrackPage).toContain(' 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;