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 778e48e6..bb6ed4b9 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 @@ -89,6 +89,8 @@ test('polls lightweight single-vehicle realtime data and passes its report inter expect(commandCard?.querySelector('.v2-live-grid > div:nth-child(2)')).toHaveTextContent('SOC'); expect(commandCard?.querySelector('.v2-live-grid > div:nth-child(4)')).toHaveTextContent('当日里程'); expect(commandCard?.querySelector('.v2-live-grid > div:nth-child(5)')).toHaveTextContent('推荐来源JT/T 808'); + expect(commandCard?.querySelector('.v2-live-primary-protocol')).toHaveClass('v2-protocol-tag', 'semi-tag-cyan-light'); + expect(commandCard?.querySelector('.v2-identity-sources .v2-protocol-tag')).toHaveClass('semi-tag-cyan-light'); expect(screen.getByLabelText('粤A12345 车辆档案')).toHaveFocus(); expect(screen.getByText('扫描 0 帧 · 上海时间 10:00:00')).toBeInTheDocument(); expect(screen.getByRole('heading', { name: '最新上报', level: 5 })).toBeInTheDocument(); @@ -117,6 +119,31 @@ test('polls lightweight single-vehicle realtime data and passes its report inter await waitFor(() => expect(sourceEvidence).toHaveBeenCalledTimes(1)); }); +test('holds the vehicle identity at the top through late layout shifts and yields to deliberate scrolling', async () => { + vi.spyOn(api, 'vehicleDetail').mockResolvedValue(detail); + vi.spyOn(api, 'vehicleRealtime').mockResolvedValue({ items: [initialRealtime], total: 1, limit: 1, offset: 0 }); + vi.spyOn(api, 'latestTelemetry').mockResolvedValue({ values: [], categories: [], scannedFrames: 0 } as never); + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + const workspace = document.createElement('main'); + workspace.className = 'v2-content'; + document.body.appendChild(workspace); + + render(} />, { container: workspace }); + + await screen.findByLabelText('粤A12345 车辆档案'); + await new Promise((resolve) => window.setTimeout(resolve, 420)); + workspace.scrollTop = 312; + fireEvent.scroll(workspace); + await waitFor(() => expect(workspace.scrollTop).toBe(0)); + + fireEvent.wheel(workspace); + workspace.scrollTop = 188; + fireEvent.scroll(workspace); + await new Promise((resolve) => window.setTimeout(resolve, 32)); + expect(workspace.scrollTop).toBe(188); + workspace.remove(); +}); + test('uses the shared Semi empty recovery surface when a vehicle cannot be resolved', async () => { vi.spyOn(api, 'vehicleDetail').mockResolvedValue({ ...detail, lookupResolved: false }); vi.spyOn(api, 'vehicleRealtime').mockResolvedValue({ items: [], total: 0, limit: 1, offset: 0 }); @@ -184,6 +211,7 @@ test('shows deduplicated Semi vehicle candidates and opens the selected VIN', as expect(view.container.querySelector('.v2-vehicle-directory-table.semi-table-wrapper')).toBeInTheDocument(); expect(view.container.querySelector('.v2-vehicle-search-page')).toHaveClass('v2-vehicle-directory-v3'); expect(view.container.querySelector('.v2-vehicle-directory-profile')).toHaveTextContent('测试品牌13800000000'); + expect(view.container.querySelector('.v2-vehicle-directory-protocols .v2-protocol-tag')).toHaveClass('semi-tag-cyan-light'); expect(view.container.querySelector('.v2-vehicle-discovery-shell')).toContainElement(screen.getByLabelText('查车操作')); expect(screen.getByRole('button', { name: `打开 ${initialRealtime.vin} 车辆档案` })).toBeInTheDocument(); fireEvent.focus(input); @@ -492,6 +520,9 @@ test('uses compact Semi telemetry evidence cards on mobile', async () => { expect(screen.getByRole('group', { name: '车辆快捷操作' })).toHaveTextContent('切换车辆轨迹回放历史数据里程查询告警事件'); expect(document.querySelectorAll('.v2-identity-sources .semi-tag')).toHaveLength(3); expect(document.querySelector('.v2-identity-sources')).toHaveTextContent('GB/T 32960JT/T 808宇通 MQTT'); + expect(document.querySelector('.v2-identity-sources .semi-tag-blue-light')).toHaveTextContent('GB/T 32960'); + expect(document.querySelector('.v2-identity-sources .semi-tag-cyan-light')).toHaveTextContent('JT/T 808'); + expect(document.querySelector('.v2-identity-sources .semi-tag-violet-light')).toHaveTextContent('宇通 MQTT'); expect(document.querySelector('.v2-vehicle-command-card .v2-live-grid')).toHaveAttribute('aria-label', '车辆实时指标'); expect(document.querySelectorAll('.v2-vehicle-command-card .v2-live-grid > [role="listitem"]')).toHaveLength(6); expect(document.querySelector('.v2-live-metric.is-speed')).toHaveTextContent('速度'); 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 614a32e1..6fb9ba15 100644 --- a/vehicle-data-platform/apps/web/src/v2/pages/VehiclePage.tsx +++ b/vehicle-data-platform/apps/web/src/v2/pages/VehiclePage.tsx @@ -19,6 +19,7 @@ import { FleetMap } from '../map/FleetMap'; import { InlineError, PageLoading } from '../shared/AsyncState'; import { MonitorReturnBar } from '../shared/MonitorReturnBar'; import { PlatformTime } from '../shared/PlatformTime'; +import { ProtocolTag } from '../shared/ProtocolTag'; import { SegmentedTabs } from '../shared/SegmentedTabs'; import { VehicleSourceEvidencePanel } from '../shared/VehicleSourceEvidencePanel'; import { WorkspaceCommandBar } from '../shared/WorkspaceCommandBar'; @@ -223,7 +224,7 @@ function TelemetryTimeCell({ item }: { item: LatestTelemetryValue }) { } function TelemetrySourceCell({ item }: { item: LatestTelemetryValue }) { - return
{protocolDisplayLabel(item.protocol)}{item.sourceEndpoint || '协议默认来源'}
; + return
{item.sourceEndpoint || '协议默认来源'}
; } function telemetryRowKey(item?: LatestTelemetryValue) { @@ -431,27 +432,41 @@ function VehicleRecord({ detail, liveRealtime, telemetry, telemetryPending, tele useLayoutEffect(() => { const root = recordRootRef.current; if (!root) return; - let cancelled = false; + const scrollOwner = root.closest('.v2-content') ?? document.querySelector('.v2-content'); + let active = true; + let resetFrame = 0; const timers: number[] = []; const revealIdentity = () => { - if (!cancelled) revealVehicleIdentity(root); + if (active) revealVehicleIdentity(root); }; - const cancelEntryReset = () => { - cancelled = true; + const stopEntryGuard = () => { + if (!active) return; + active = false; + window.cancelAnimationFrame(resetFrame); for (const timer of timers) window.clearTimeout(timer); + resizeObserver?.disconnect(); + scrollOwner?.removeEventListener('scroll', restoreEntryPosition); + for (const eventName of interactionEvents) window.removeEventListener(eventName, stopEntryGuard, true); + }; + const restoreEntryPosition = () => { + if (!active || !scrollOwner || scrollOwner.scrollTop === 0) return; + window.cancelAnimationFrame(resetFrame); + resetFrame = window.requestAnimationFrame(revealIdentity); }; const interactionEvents = ['wheel', 'touchstart', 'pointerdown', 'keydown'] as const; - for (const eventName of interactionEvents) window.addEventListener(eventName, cancelEntryReset, { capture: true, passive: true }); + for (const eventName of interactionEvents) window.addEventListener(eventName, stopEntryGuard, { capture: true, passive: true }); + scrollOwner?.addEventListener('scroll', restoreEntryPosition, { passive: true }); + const resizeObserver = typeof ResizeObserver === 'undefined' ? undefined : new ResizeObserver(revealIdentity); + resizeObserver?.observe(root); revealIdentity(); const frame = window.requestAnimationFrame(revealIdentity); - // The map and live evidence arrive independently. Keep the identity band - // anchored only during initial settlement, then immediately yield to input. - for (const delay of [80, 180, 360, 720, 1200, 1800, 2400]) timers.push(window.setTimeout(revealIdentity, delay)); + // Map tiles, source evidence and live data settle independently. Guard the + // first viewport through those late layout changes, but yield immediately + // as soon as the user starts scrolling or interacting. + for (const delay of [80, 180, 360, 720, 1200, 1800, 2400, 3600, 5200]) timers.push(window.setTimeout(revealIdentity, delay)); return () => { - cancelled = true; window.cancelAnimationFrame(frame); - for (const timer of timers) window.clearTimeout(timer); - for (const eventName of interactionEvents) window.removeEventListener(eventName, cancelEntryReset, true); + stopEntryGuard(); }; }, [detail.vin]); return
@@ -467,7 +482,7 @@ function VehicleRecord({ detail, liveRealtime, telemetry, telemetryPending, tele description={VIN{detail.vin})} @@ -484,7 +499,7 @@ function VehicleRecord({ detail, liveRealtime, telemetry, telemetryPending, tele
SOC{availableMetric(realtime?.socPercent, realtime?.socAvailable)}%剩余电量
总里程推荐口径
当日里程今日累计
-
推荐来源{protocolDisplayLabel(realtime?.primaryProtocol)}{protocolSourceLabel(realtime?.locationSource) || '当前最优来源'}
+
推荐来源{protocolSourceLabel(realtime?.locationSource) || '当前最优来源'}
来源状态{realtime?.onlineSourceCount ?? 0} / {detail.sourceStatus.length}在线来源 / 全部
diff --git a/vehicle-data-platform/apps/web/src/v2/pages/VehicleSearchWorkspace.tsx b/vehicle-data-platform/apps/web/src/v2/pages/VehicleSearchWorkspace.tsx index 33c56ccb..9dd57b74 100644 --- a/vehicle-data-platform/apps/web/src/v2/pages/VehicleSearchWorkspace.tsx +++ b/vehicle-data-platform/apps/web/src/v2/pages/VehicleSearchWorkspace.tsx @@ -8,9 +8,9 @@ import type { Page, VehicleCoverageRow } from '../../api/types'; import { usePlatformSession } from '../auth/AuthGate'; import { canAdminister } from '../auth/session'; import { formatZhNumber } from '../domain/formatters'; -import { protocolDisplayLabel } from '../domain/protocols'; import { useMobileLayout } from '../hooks/useMobileLayout'; import { QUERY_MEMORY, queryScopeKey, retainPreviousPageWithinScope } from '../queryPolicy'; +import { ProtocolTag } from '../shared/ProtocolTag'; import { TablePagination } from '../shared/TablePagination'; import { VehicleCandidateList } from '../shared/VehicleCandidateList'; import { mergeVehicleCandidates } from '../shared/vehicleCandidates'; @@ -194,7 +194,7 @@ export default function VehicleSearchWorkspace() { {vehicle.online ? '在线' : '离线'} {vehicleLastSeenLabel(vehicle.lastSeen)} - {vehicle.protocols.slice(0, 2).map((protocol) => {protocolDisplayLabel(protocol)})} + {vehicle.protocols.slice(0, 2).map((protocol) => )} )} @@ -222,7 +222,7 @@ export default function VehicleSearchWorkspace() { { title: '协议来源', dataIndex: 'protocols', - render: (protocols: string[]) => {protocols.length ? protocols.slice(0, 3).map((protocol) => {protocolDisplayLabel(protocol)}) : 暂无来源} + render: (protocols: string[]) => {protocols.length ? protocols.slice(0, 3).map((protocol) => ) : 暂无来源} }, { title: '', diff --git a/vehicle-data-platform/apps/web/src/v2/shared/VehicleCandidateList.test.tsx b/vehicle-data-platform/apps/web/src/v2/shared/VehicleCandidateList.test.tsx index 4b5c4f4b..1c3171a3 100644 --- a/vehicle-data-platform/apps/web/src/v2/shared/VehicleCandidateList.test.tsx +++ b/vehicle-data-platform/apps/web/src/v2/shared/VehicleCandidateList.test.tsx @@ -27,6 +27,8 @@ test('renders consistent Semi vehicle options and keeps plate first', () => { const option = screen.getByRole('option', { name: '粤A00001 VIN001 GB/T 32960 JT/T 808 选择' }); expect(option).toHaveClass('semi-button', 'v2-vehicle-option'); expect(option).toHaveTextContent('粤A00001VIN001GB/T 32960JT/T 808选择'); + expect(option.querySelector('.semi-tag-blue-light')).toHaveTextContent('GB/T 32960'); + expect(option.querySelector('.semi-tag-cyan-light')).toHaveTextContent('JT/T 808'); expect(screen.getByRole('option', { name: '粤A00002 VIN002 JT/T 808 已分配' })).toHaveAttribute('aria-selected', 'true'); fireEvent.click(option); diff --git a/vehicle-data-platform/apps/web/src/v2/shared/VehicleCandidateList.tsx b/vehicle-data-platform/apps/web/src/v2/shared/VehicleCandidateList.tsx index 73ea5d21..abf79d16 100644 --- a/vehicle-data-platform/apps/web/src/v2/shared/VehicleCandidateList.tsx +++ b/vehicle-data-platform/apps/web/src/v2/shared/VehicleCandidateList.tsx @@ -1,6 +1,7 @@ import { Button, Spin, Tag } from '@douyinfe/semi-ui'; import type { ReactNode } from 'react'; import { protocolDisplayLabel } from '../domain/protocols'; +import { ProtocolTag } from './ProtocolTag'; import { VehicleOptionError } from './VehicleOptionError'; export interface VehicleCandidateItem { @@ -75,7 +76,7 @@ export function VehicleCandidateList({ {vehicle.plate || '未绑定车牌'} {vehicle.vin} - {protocols.length ? {protocols.slice(0, 3).map((protocol) => {protocolDisplayLabel(protocol)})} : null} + {protocols.length ? {protocols.slice(0, 3).map((protocol) => )} : null} {label} ; }) : null} diff --git a/vehicle-data-platform/apps/web/src/v2/styles/workspace.css b/vehicle-data-platform/apps/web/src/v2/styles/workspace.css index c98221c4..722ae7f7 100644 --- a/vehicle-data-platform/apps/web/src/v2/styles/workspace.css +++ b/vehicle-data-platform/apps/web/src/v2/styles/workspace.css @@ -6513,6 +6513,14 @@ letter-spacing: 0; } +.v2-vehicle-command-card .v2-live-primary-protocol.semi-tag { + min-height: 24px; + margin: 7px 0 0; + border-radius: 7px; + padding-inline: 8px; + font-size: 10px; +} + .v2-vehicle-command-card .v2-live-source-link .semi-button-content { display: flex; min-width: 0;