polish single vehicle entry workspace
This commit is contained in:
@@ -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');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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 = () => {
|
||||||
content.scrollTop = 0;
|
for (const content of document.querySelectorAll<HTMLElement>('.v2-content')) {
|
||||||
content.scrollLeft = 0;
|
content.scrollTop = 0;
|
||||||
content.scrollTo?.({ top: 0, left: 0, behavior: 'auto' });
|
content.scrollLeft = 0;
|
||||||
|
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;
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user