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}} onClick={() => navigator.clipboard?.writeText(detail.vin)} />}
meta={
车辆品牌 / 车型{[detail.profile?.brandName, detail.profile?.modelName].filter(Boolean).join(' / ') || fmt(identity?.oem || realtime?.oem)}
- 可用数据来源{detail.sources.length ? detail.sources.map((source) => {protocolDisplayLabel(source)}) : 暂无来源}
+ 可用数据来源{detail.sources.length ? detail.sources.map((source) => ) : 暂无来源}
}
actions={
{actions.map((action) =>
)}
@@ -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;