polish single vehicle entry workspace

This commit is contained in:
lingniu
2026-07-18 11:09:27 +08:00
parent 5805635ccf
commit 622c4398ec
7 changed files with 45 additions and 28 deletions

View File

@@ -18,5 +18,6 @@ describe('latest telemetry presentation', () => {
it('keeps telemetry timestamps compact for local and RFC3339 values', () => { it('keeps telemetry timestamps compact for local and RFC3339 values', () => {
expect(formatTelemetryTime('2026-07-14T09:24:34+08:00')).toBe('09:24:34'); expect(formatTelemetryTime('2026-07-14T09:24:34+08:00')).toBe('09:24:34');
expect(formatTelemetryTime('2026-07-14 09:24:34')).toBe('09:24:34'); expect(formatTelemetryTime('2026-07-14 09:24:34')).toBe('09:24:34');
expect(formatTelemetryTime('2026-07-18T02:57:02Z')).toBe('10:57:02');
}); });
}); });

View File

@@ -1,4 +1,4 @@
import { formatZhNumber } from './formatters'; import { formatShanghaiDateTime, formatZhNumber } from './formatters';
export function formatTelemetryValue(value: unknown, displayValue?: string) { export function formatTelemetryValue(value: unknown, displayValue?: string) {
if (displayValue) return displayValue; if (displayValue) return displayValue;
@@ -18,5 +18,6 @@ export function telemetryQualityLabel(quality: string) {
export function formatTelemetryTime(value?: string) { export function formatTelemetryTime(value?: string) {
if (!value) return '—'; if (!value) return '—';
return value.match(/[T ](\d{2}:\d{2}:\d{2})/)?.[1] ?? value; const local = formatShanghaiDateTime(value);
return local.match(/[T ](\d{2}:\d{2}:\d{2})/)?.[1] ?? local;
} }

View File

@@ -77,11 +77,14 @@ test('marks the content as a full-height track workspace after sidebar navigatio
<Routes><Route element={<AppShell />}><Route path="*" element={<span></span>} /></Route></Routes> <Routes><Route element={<AppShell />}><Route path="*" element={<span></span>} /></Route></Routes>
</MemoryRouter>); </MemoryRouter>);
expect(document.querySelector('.v2-content')).not.toHaveClass('is-track-workspace'); const content = document.querySelector<HTMLElement>('.v2-content')!;
expect(content).not.toHaveClass('is-track-workspace');
content.scrollTop = 240;
scrollTo.mockClear(); scrollTo.mockClear();
fireEvent.click(screen.getByRole('link', { name: '轨迹回放' })); fireEvent.click(screen.getByRole('link', { name: '轨迹回放' }));
await waitFor(() => expect(document.querySelector('.v2-content')).toHaveClass('is-track-workspace')); await waitFor(() => expect(document.querySelector('.v2-content')).toHaveClass('is-track-workspace'));
expect(document.querySelector<HTMLElement>('.v2-content')?.scrollTop).toBe(0);
expect(scrollTo).toHaveBeenCalledWith({ top: 0, left: 0, behavior: 'auto' }); expect(scrollTo).toHaveBeenCalledWith({ top: 0, left: 0, behavior: 'auto' });
expect(screen.getByRole('heading', { name: '轨迹回放' })).toBeInTheDocument(); expect(screen.getByRole('heading', { name: '轨迹回放' })).toBeInTheDocument();
}); });

View File

@@ -124,12 +124,17 @@ export function AppShell() {
const roleLabel = { viewer: '只读', operator: '处置员', admin: '管理员', customer: '客户' }[session.role]; const roleLabel = { viewer: '只读', operator: '处置员', admin: '管理员', customer: '客户' }[session.role];
useEffect(() => scheduleIdleRoutePreloads({ activePathname: activeRoutePath }), [activeRoutePath]); useEffect(() => scheduleIdleRoutePreloads({ activePathname: activeRoutePath }), [activeRoutePath]);
useLayoutEffect(() => { useLayoutEffect(() => {
const content = document.querySelector<HTMLElement>('.v2-content');
if (!content) return;
const reset = () => { const reset = () => {
for (const content of document.querySelectorAll<HTMLElement>('.v2-content')) {
content.scrollTop = 0; content.scrollTop = 0;
content.scrollLeft = 0; content.scrollLeft = 0;
content.scrollTo?.({ top: 0, left: 0, behavior: 'auto' }); 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;

View File

@@ -55,7 +55,7 @@ test('polls lightweight single-vehicle realtime data and passes its report inter
const movedRealtime = { ...initialRealtime, longitude: 113.28, latitude: 23.15, lastSeen: '2026-07-16 10:00:30' }; const movedRealtime = { ...initialRealtime, longitude: 113.28, latitude: 23.15, lastSeen: '2026-07-16 10:00:30' };
vi.spyOn(api, 'vehicleDetail').mockResolvedValue(detail); vi.spyOn(api, 'vehicleDetail').mockResolvedValue(detail);
const realtimeSpy = vi.spyOn(api, 'vehicleRealtime').mockResolvedValue({ items: [movedRealtime], total: 1, limit: 1, offset: 0 }); const realtimeSpy = vi.spyOn(api, 'vehicleRealtime').mockResolvedValue({ items: [movedRealtime], total: 1, limit: 1, offset: 0 });
vi.spyOn(api, 'latestTelemetry').mockResolvedValue({ values: [], categories: [], scannedFrames: 0 } as never); vi.spyOn(api, 'latestTelemetry').mockResolvedValue({ values: [], categories: [], scannedFrames: 0, asOf: '2026-07-16T02:00:00Z' } as never);
const sourceEvidence = vi.spyOn(api, 'vehicleSourceEvidence').mockResolvedValue({ const sourceEvidence = vi.spyOn(api, 'vehicleSourceEvidence').mockResolvedValue({
vin: initialRealtime.vin, plate: initialRealtime.plate, mileageDate: '2026-07-16', vin: initialRealtime.vin, plate: initialRealtime.plate, mileageDate: '2026-07-16',
recommendedLocationProtocol: 'JT808', recommendedLocationLabel: 'JT808', locationConflict: false, recommendedLocationProtocol: 'JT808', recommendedLocationLabel: 'JT808', locationConflict: false,
@@ -84,6 +84,8 @@ test('polls lightweight single-vehicle realtime data and passes its report inter
expect(commandCard?.querySelector('.v2-live-grid > div:first-child')).toHaveTextContent('速度'); expect(commandCard?.querySelector('.v2-live-grid > div:first-child')).toHaveTextContent('速度');
expect(commandCard?.querySelector('.v2-live-grid > div:nth-child(2)')).toHaveTextContent('SOC'); 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(4)')).toHaveTextContent('当日里程');
expect(screen.getByLabelText('粤A12345 车辆档案')).toHaveFocus();
expect(screen.getByText('扫描 0 帧 · 上海时间 10:00:00')).toBeInTheDocument();
expect(screen.getByRole('heading', { name: '最新上报', level: 5 })).toBeInTheDocument(); expect(screen.getByRole('heading', { name: '最新上报', level: 5 })).toBeInTheDocument();
expect(screen.getByRole('heading', { name: '实时位置', level: 5 })).toBeInTheDocument(); expect(screen.getByRole('heading', { name: '实时位置', level: 5 })).toBeInTheDocument();
expect(screen.getByRole('heading', { name: '最近事件', level: 5 })).toBeInTheDocument(); expect(screen.getByRole('heading', { name: '最近事件', level: 5 })).toBeInTheDocument();

View File

@@ -17,6 +17,7 @@ import { isValidAMapCoordinate } from '../../integrations/amap';
import { FleetMap } from '../map/FleetMap'; import { FleetMap } from '../map/FleetMap';
import { InlineError, PageLoading } from '../shared/AsyncState'; import { InlineError, PageLoading } from '../shared/AsyncState';
import { MonitorReturnBar } from '../shared/MonitorReturnBar'; import { MonitorReturnBar } from '../shared/MonitorReturnBar';
import { PlatformTime } from '../shared/PlatformTime';
import { SegmentedTabs } from '../shared/SegmentedTabs'; import { SegmentedTabs } from '../shared/SegmentedTabs';
import { VehicleSourceEvidencePanel } from '../shared/VehicleSourceEvidencePanel'; import { VehicleSourceEvidencePanel } from '../shared/VehicleSourceEvidencePanel';
import { WorkspacePanelHeader } from '../shared/WorkspacePanelHeader'; import { WorkspacePanelHeader } from '../shared/WorkspacePanelHeader';
@@ -34,15 +35,17 @@ const operationStatusLabels = { unknown: '待维护', active: '运营中', inact
const SINGLE_VEHICLE_REFRESH_MS = 10_000; const SINGLE_VEHICLE_REFRESH_MS = 10_000;
const VehicleSearch = lazy(() => import('./VehicleSearchWorkspace')); const VehicleSearch = lazy(() => import('./VehicleSearchWorkspace'));
function resetWorkspaceScroll() { function resetWorkspaceScroll(routeRoot?: HTMLElement | null) {
const targets = [ const targets = new Set<Element | null>([
routeRoot ?? null,
routeRoot?.closest('.v2-content') ?? null,
document.scrollingElement, document.scrollingElement,
document.documentElement, document.documentElement,
document.body, document.body,
document.querySelector<HTMLElement>('.v2-shell'), document.querySelector<HTMLElement>('.v2-shell'),
document.querySelector<HTMLElement>('.v2-main'), document.querySelector<HTMLElement>('.v2-main')
document.querySelector<HTMLElement>('.v2-content') ]);
]; for (const content of document.querySelectorAll<HTMLElement>('.v2-content')) targets.add(content);
for (const target of targets) { for (const target of targets) {
if (!(target instanceof HTMLElement)) continue; if (!(target instanceof HTMLElement)) continue;
target.scrollTop = 0; target.scrollTop = 0;
@@ -52,11 +55,11 @@ function resetWorkspaceScroll() {
} }
function revealVehicleIdentity(root: HTMLElement | null) { function revealVehicleIdentity(root: HTMLElement | null) {
resetWorkspaceScroll(); resetWorkspaceScroll(root);
if (!root) return; if (!root) return;
root.scrollTop = 0; root.scrollTop = 0;
root.scrollLeft = 0; root.scrollLeft = 0;
root.scrollIntoView?.({ behavior: 'auto', block: 'start', inline: 'nearest' }); root.focus({ preventScroll: true });
} }
function eventTimeLabel(value?: string) { function eventTimeLabel(value?: string) {
@@ -296,7 +299,7 @@ function TelemetryPanel({ data, pending, error }: { data?: LatestTelemetryRespon
<SegmentedTabs className="v2-telemetry-categories" ariaLabel="字段分类" value={activeCategory} onChange={setSelectedCategory} items={categories} /> <SegmentedTabs className="v2-telemetry-categories" ariaLabel="字段分类" value={activeCategory} onChange={setSelectedCategory} items={categories} />
</div> </div>
{state} {state}
<footer><b></b>{indexed.sources.map((source) => <span key={`${source.protocol}-${source.endpoint ?? ''}`} title={source.endpoint}>{source.protocol}</span>)}<small> {data?.scannedFrames ?? 0} · {formatTelemetryTime(data?.asOf)}</small></footer> <footer><b></b>{indexed.sources.map((source) => <span key={`${source.protocol}-${source.endpoint ?? ''}`} title={source.endpoint}>{source.protocol}</span>)}<small> {data?.scannedFrames ?? 0} · {formatTelemetryTime(data?.asOf)}</small></footer>
<SideSheet <SideSheet
className="v2-telemetry-value-sidesheet" className="v2-telemetry-value-sidesheet"
visible={Boolean(inspectedValue)} visible={Boolean(inspectedValue)}
@@ -450,7 +453,7 @@ function VehicleRecord({ detail, liveRealtime, telemetry, telemetryPending, tele
for (const eventName of interactionEvents) window.removeEventListener(eventName, cancelEntryReset, true); for (const eventName of interactionEvents) window.removeEventListener(eventName, cancelEntryReset, true);
}; };
}, [detail.vin]); }, [detail.vin]);
return <div ref={recordRootRef} className={`v2-vehicle-record-page${mobileLayout ? ' is-mobile-layout' : ''}`}> return <div ref={recordRootRef} className={`v2-vehicle-record-page${mobileLayout ? ' is-mobile-layout' : ''}`} tabIndex={-1} aria-label={`${fmt(identity?.plate || realtime?.plate)} 车辆档案`}>
<MonitorReturnBar /> <MonitorReturnBar />
<Card className="v2-identity-band v2-record-card v2-live-card v2-live-overview v2-vehicle-command-card" bodyStyle={{ padding: 0 }}> <Card className="v2-identity-band v2-record-card v2-live-card v2-live-overview v2-vehicle-command-card" bodyStyle={{ padding: 0 }}>
<div className="v2-vehicle-command-header"> <div className="v2-vehicle-command-header">
@@ -471,7 +474,7 @@ function VehicleRecord({ detail, liveRealtime, telemetry, telemetryPending, tele
<WorkspacePanelHeader <WorkspacePanelHeader
title={<span id="vehicle-live-heading"></span>} title={<span id="vehicle-live-heading"></span>}
description="关键运行指标与推荐数据来源" description="关键运行指标与推荐数据来源"
meta={<span className="v2-live-report-meta"><i className={realtime?.online ? 'is-online' : ''} />{realtime?.online ? '实时在线' : '当前离线'} · <IconClock />{fmt(realtime?.lastSeen || identity?.lastSeen)}</span>} meta={<span className="v2-live-report-meta"><i className={realtime?.online ? 'is-online' : ''} />{realtime?.online ? '实时在线' : '当前离线'} · <IconClock /><PlatformTime value={realtime?.lastSeen || identity?.lastSeen} /></span>}
/> />
<div className="v2-live-grid" role="list" aria-label="车辆实时指标"> <div className="v2-live-grid" role="list" aria-label="车辆实时指标">
<div role="listitem"><small></small><strong>{availableMetric(realtime?.speedKmh, realtime?.speedAvailable)}<em>km/h</em></strong><span></span></div> <div role="listitem"><small></small><strong>{availableMetric(realtime?.speedKmh, realtime?.speedAvailable)}<em>km/h</em></strong><span></span></div>
@@ -497,7 +500,7 @@ function VehicleRecord({ detail, liveRealtime, telemetry, telemetryPending, tele
meta={realtime?.online ? '实时跟随' : '保留最后位置'} meta={realtime?.online ? '实时跟随' : '保留最后位置'}
/> />
<FleetMap vehicles={mapVehicles} selectedVin={hasLocation ? detail.vin : undefined} onSelect={() => undefined} /> <FleetMap vehicles={mapVehicles} selectedVin={hasLocation ? detail.vin : undefined} onSelect={() => undefined} />
<footer><Button theme="borderless" type="tertiary" className="v2-map-source-link" title="展开全部位置来源" aria-label="查看全部位置来源" icon={<IconMapPin />} onClick={() => setSourceEvidenceOpen(true)}>{hasLocation && realtime ? `实时坐标 ${realtime.longitude.toFixed(6)}, ${realtime.latitude.toFixed(6)}` : '暂无有效实时坐标'}{identity?.locationText ? ` · 档案区域 ${identity.locationText}` : ''}</Button><time>{fmt(realtime?.lastSeen)}</time></footer> <footer><Button theme="borderless" type="tertiary" className="v2-map-source-link" title="展开全部位置来源" aria-label="查看全部位置来源" icon={<IconMapPin />} onClick={() => setSourceEvidenceOpen(true)}>{hasLocation && realtime ? `实时坐标 ${realtime.longitude.toFixed(6)}, ${realtime.latitude.toFixed(6)}` : '暂无有效实时坐标'}{identity?.locationText ? ` · 档案区域 ${identity.locationText}` : ''}</Button><span className="v2-map-update-time"><PlatformTime value={realtime?.lastSeen} /></span></footer>
</Card> </Card>
</div> </div>
<div id={vehicleSectionIDs.events} tabIndex={-1} className="v2-record-section-anchor"><Events detail={detail} /></div> <div id={vehicleSectionIDs.events} tabIndex={-1} className="v2-record-section-anchor"><Events detail={detail} /></div>
@@ -528,7 +531,7 @@ export default function VehiclePage() {
let trailingFrame = 0; let trailingFrame = 0;
const frame = window.requestAnimationFrame(() => { const frame = window.requestAnimationFrame(() => {
resetWorkspaceScroll(); resetWorkspaceScroll();
trailingFrame = window.requestAnimationFrame(resetWorkspaceScroll); trailingFrame = window.requestAnimationFrame(() => resetWorkspaceScroll());
}); });
const shortTimer = window.setTimeout(resetWorkspaceScroll, 120); const shortTimer = window.setTimeout(resetWorkspaceScroll, 120);
const settledTimer = window.setTimeout(resetWorkspaceScroll, 360); const settledTimer = window.setTimeout(resetWorkspaceScroll, 360);

View File

@@ -2199,17 +2199,19 @@
} }
} }
.v2-shell,
.v2-main,
.v2-content,
.v2-vehicle-record-page {
overflow-anchor: none;
}
.v2-vehicle-record-page { .v2-vehicle-record-page {
overflow: visible !important; overflow: visible !important;
overflow-anchor: none;
} }
.v2-content { .v2-vehicle-record-page:focus {
overflow-anchor: none; outline: 0;
}
.v2-content:has(> .v2-vehicle-record-page) {
overflow-anchor: none;
} }
.v2-track-current-card.semi-card { .v2-track-current-card.semi-card {