Files
lingniu-vehicle-ingest/vehicle-data-platform/apps/web/src/v2/pages/VehiclePage.tsx
T

572 lines
34 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useQuery } from '@tanstack/react-query';
import {
IconAlarm, IconBox, IconClock, IconCopy,
IconChevronRight, IconMapPin, IconSearch, IconTickCircle, IconUserGroup
} from '@douyinfe/semi-icons';
import { Button, Card, Descriptions, List, Tag } from '@douyinfe/semi-ui';
import { lazy, Suspense, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { Link, useNavigate, useParams, useSearchParams } from 'react-router-dom';
import { api } from '../../api/client';
import type { LatestTelemetryResponse, LatestTelemetryValue, QualityIssueRow, VehicleDetail, VehicleRealtimeRow } from '../../api/types';
import { usePlatformSession } from '../auth/AuthGate';
import { canAdminister, hasMenu } from '../auth/session';
import { LIVE_QUERY_POLICY, QUERY_MEMORY } from '../queryPolicy';
import { formatTelemetryTime, formatTelemetryValue, telemetryQualityColor, telemetryQualityLabel, telemetryValueTypeLabel } from '../domain/telemetry';
import { formatZhNumber } from '../domain/formatters';
import { protocolDisplayLabel, protocolSourceLabel } from '../domain/protocols';
import { isValidAMapCoordinate } from '../../integrations/amap';
import { FleetMap } from '../map/FleetMap';
import { InlineError, PageLoading, PanelEmpty, PanelLoading } from '../shared/AsyncState';
import { MonitorReturnBar } from '../shared/MonitorReturnBar';
import { PlatformTime } from '../shared/PlatformTime';
import { ProtocolTag } from '../shared/ProtocolTag';
import { SegmentedTabs } from '../shared/SegmentedTabs';
import { VehicleSourceEvidenceButton, VehicleSourceEvidencePanel } from '../shared/VehicleSourceEvidencePanel';
import { WorkspaceCommandBar } from '../shared/WorkspaceCommandBar';
import { WorkspaceMetricRail, type WorkspaceQueueMetricRailItem } from '../shared/WorkspaceMetricRail';
import { WorkspacePanelHeader } from '../shared/WorkspacePanelHeader';
import { WorkspaceSideSheet, type WorkspaceSideSheetBadgeColor } from '../shared/WorkspaceSideSheet';
import { monitorReturnFromParams, withMonitorReturn } from '../routing/monitorContext';
import { vehicleDirectoryReturnFromParams } from '../routing/vehicleContext';
import { useMobileLayout } from '../hooks/useMobileLayout';
function fmt(value?: string) { return value?.trim() || '—'; }
function metric(value: number | undefined) { return Number.isFinite(value as number) ? formatZhNumber(value!, 1) : '—'; }
function availableMetric(value: number | undefined, available?: boolean) { return available === false ? '—' : metric(value); }
function issueTone(issue: QualityIssueRow) { return issue.severity === 'error' ? 'error' : 'warning'; }
const SINGLE_VEHICLE_REFRESH_MS = 10_000;
const LIVE_UNIT_CLASS = 'v2-vehicle-live-unit';
const VehicleSearch = lazy(() => import('./VehicleSearchWorkspace'));
const VehicleActions = lazy(() => import('./VehicleActions'));
const VehicleArchiveCard = lazy(() => import('./VehicleArchiveCard'));
const VehicleBusinessRelationCard = lazy(() => import('./VehicleBusinessRelationCard'));
function resetWorkspaceScroll(routeRoot?: HTMLElement | null) {
const targets = new Set<Element | null>([
routeRoot ?? null,
routeRoot?.closest('.v2-content') ?? null,
document.scrollingElement,
document.documentElement,
document.body,
document.querySelector<HTMLElement>('.v2-shell'),
document.querySelector<HTMLElement>('.v2-main')
]);
for (const content of document.querySelectorAll<HTMLElement>('.v2-content')) targets.add(content);
for (const target of targets) {
if (!(target instanceof HTMLElement)) continue;
target.scrollTop = 0;
target.scrollLeft = 0;
target.scrollTo?.({ top: 0, left: 0, behavior: 'auto' });
}
}
function revealVehicleIdentity(root: HTMLElement | null) {
resetWorkspaceScroll(root);
if (!root) return;
root.scrollTop = 0;
root.scrollLeft = 0;
root.focus({ preventScroll: true });
}
function eventTimeLabel(value?: string) {
if (!value) return '—';
const normalized = value.replace('T', ' ');
return normalized.length >= 16 ? normalized.slice(5, 16) : normalized;
}
function Events({ detail }: { detail: VehicleDetail }) {
const events = [
...detail.quality.items.slice(0, 3).map((item) => ({ tone: issueTone(item), title: item.severity === 'error' ? '质量异常' : '质量提醒', detail: item.detail, time: item.lastSeen })),
...detail.sourceStatus.slice(0, 3).map((item) => ({ tone: item.online ? 'success' : 'muted', title: item.online ? '数据上报' : '来源离线', detail: `${protocolDisplayLabel(item.protocol)} · ${item.online ? '当前在线' : '暂无在线数据'}`, time: item.lastSeen }))
].slice(0, 5);
return <Card className="v2-record-card v2-events-card" bodyStyle={{ padding: 0 }}>
<WorkspacePanelHeader
title="最近事件"
description="质量提醒与来源状态"
meta={`${events.length} 条`}
actions={<Link to={`/alerts?vin=${encodeURIComponent(detail.vin)}`}><Button theme="borderless" type="tertiary" size="small">查看全部</Button></Link>}
/>
<List
className="v2-event-list"
dataSource={events}
split={false}
emptyContent={<PanelEmpty className="v2-event-empty" compact tone="warning" icon={<IconAlarm />} title="暂无可用事件证据" description="质量提醒或来源变化后显示。" />}
renderItem={(event, index) => <List.Item className={`v2-event-row is-${event.tone}`} key={`${event.title}-${event.time}-${index}`}>
<span className="v2-event-icon">{event.tone === 'success' ? <IconTickCircle /> : <IconAlarm />}</span>
<div><strong>{event.title}</strong><p>{event.detail}</p></div><time title={fmt(event.time)}>{eventTimeLabel(event.time)}</time>
</List.Item>}
/>
</Card>;
}
type TelemetryValuePresentation = {
complex: boolean;
preview: string;
summary: string;
};
function tryParseTelemetryJSON(value: string) {
const trimmed = value.trim();
if (!(trimmed.startsWith('[') && trimmed.endsWith(']')) && !(trimmed.startsWith('{') && trimmed.endsWith('}'))) return undefined;
try { return JSON.parse(trimmed) as unknown; } catch { return undefined; }
}
function telemetryValuePresentation(item: LatestTelemetryValue): TelemetryValuePresentation {
const formatted = formatTelemetryValue(item.value, item.displayValue);
const parsed = typeof item.value === 'string'
? tryParseTelemetryJSON(item.value)
: typeof item.displayValue === 'string'
? tryParseTelemetryJSON(item.displayValue)
: item.value;
if (Array.isArray(parsed)) {
return {
complex: true,
preview: parsed.slice(0, 3).map((value) => String(value)).join('') || '空数组',
summary: `${formatZhNumber(parsed.length)} 个值`
};
}
if (parsed && typeof parsed === 'object') {
const entries = Object.entries(parsed);
return {
complex: true,
preview: entries.slice(0, 2).map(([key]) => key).join('') || '空对象',
summary: `${formatZhNumber(entries.length)} 个字段`
};
}
const detail = String(formatted);
return {
complex: detail.length > 42,
preview: detail.length > 54 ? `${detail.slice(0, 54)}…` : detail,
summary: detail.length > 42 ? `${formatZhNumber(detail.length)} 个字符` : detail
};
}
function telemetryValueDetail(item: LatestTelemetryValue) {
const parsed = typeof item.value === 'string'
? tryParseTelemetryJSON(item.value)
: typeof item.displayValue === 'string'
? tryParseTelemetryJSON(item.displayValue)
: item.value;
if (parsed && typeof parsed === 'object') return JSON.stringify(parsed, null, 2);
return String(formatTelemetryValue(item.value, item.displayValue));
}
function telemetryRowKey(item?: LatestTelemetryValue) {
return [
item?.protocol ?? '',
item?.sourceEndpoint ?? '',
item?.key || `${item?.category ?? ''}:${item?.sourceField ?? ''}`
].join('\u0000');
}
function TelemetryMetricCard({ item, onInspect }: { item: LatestTelemetryValue; onInspect: (item: LatestTelemetryValue) => void }) {
const presentation = telemetryValuePresentation(item);
return <Button
className={`v2-telemetry-metric-card is-quality-${item.quality}`}
theme="borderless"
type="tertiary"
aria-label={`查看 ${item.label} 字段详情`}
onClick={() => onInspect(item)}
>
<span className="v2-telemetry-metric-card-content">
<span className="v2-telemetry-metric-heading"><strong>{item.label}</strong><small title={item.sourceField}>{item.sourceField}</small></span>
<span className={`v2-telemetry-metric-value${presentation.complex ? ' is-complex' : ''}`}><strong>{presentation.summary}</strong>{item.unit ? <small>{item.unit}</small> : null}</span>
<span className="v2-telemetry-metric-meta"><Tag color={telemetryQualityColor(item.quality)} type="light" size="small">{telemetryQualityLabel(item.quality)}</Tag><small>{formatZhNumber(item.freshnessSeconds, 0)}s · {formatTelemetryTime(item.serverTime)}</small><IconChevronRight /></span>
</span>
</Button>;
}
function TelemetryPanel({ data, pending, error }: { data?: LatestTelemetryResponse; pending: boolean; error?: string }) {
const mobileLayout = useMobileLayout();
const [selectedProtocol, setSelectedProtocol] = useState('');
const [selectedCategory, setSelectedCategory] = useState('vehicle');
const [inspectedValue, setInspectedValue] = useState<LatestTelemetryValue>();
const [copiedValue, setCopiedValue] = useState(false);
const inspectedPresentation = useMemo(() => inspectedValue ? telemetryValuePresentation(inspectedValue) : undefined, [inspectedValue]);
const inspectedDetail = useMemo(() => inspectedValue ? telemetryValueDetail(inspectedValue) : '', [inspectedValue]);
const closeInspection = () => {
setInspectedValue(undefined);
setCopiedValue(false);
};
const indexed = useMemo(() => {
const valuesByProtocolCategory = new Map<string, LatestTelemetryResponse['values']>();
const sources = new Map<string, { protocol: string; endpoint?: string }>();
const protocols: string[] = [];
for (const value of data?.values ?? []) {
if (!protocols.includes(value.protocol)) protocols.push(value.protocol);
const categoryKey = `${value.protocol}\u0000${value.category}`;
const values = valuesByProtocolCategory.get(categoryKey);
if (values) values.push(value); else valuesByProtocolCategory.set(categoryKey, [value]);
const sourceKey = `${value.protocol}\u0000${value.sourceEndpoint ?? ''}`;
if (!sources.has(sourceKey)) sources.set(sourceKey, { protocol: value.protocol, endpoint: value.sourceEndpoint });
}
const order = ['GB32960', 'JT808', 'YUTONG_MQTT'];
protocols.sort((left, right) => order.indexOf(left) - order.indexOf(right));
return { valuesByProtocolCategory, protocols, sources: [...sources.values()] };
}, [data]);
const activeProtocol = indexed.protocols.includes(selectedProtocol) ? selectedProtocol : indexed.protocols[0] ?? '';
const categoryLabels = new Map((data?.categories ?? []).map((category) => [category.key, category.label]));
const categories = [...new Set((data?.values ?? []).filter((value) => value.protocol === activeProtocol).map((value) => value.category))]
.map((key) => ({ key, label: categoryLabels.get(key) ?? key, count: indexed.valuesByProtocolCategory.get(`${activeProtocol}\u0000${key}`)?.length ?? 0 }));
const activeCategory = indexed.valuesByProtocolCategory.has(`${activeProtocol}\u0000${selectedCategory}`) ? selectedCategory : categories[0]?.key ?? '';
const visibleMetrics = indexed.valuesByProtocolCategory.get(`${activeProtocol}\u0000${activeCategory}`) ?? [];
const state = pending
? <PanelLoading className="v2-telemetry-loading" compact title="正在读取最新遥测" description="按协议整理最新标量证据。" />
: error
? <div className="v2-telemetry-state is-error" role="alert"><strong>最新遥测不可用</strong><span>{error}</span></div>
: visibleMetrics.length === 0
? <PanelEmpty className="v2-telemetry-empty" compact icon={<IconBox />} title="暂无可展示字段" description={`该协议最近 ${data?.scannedFrames ?? 0} 帧没有可展示的标量遥测。`} />
: <div className="v2-telemetry-metric-grid" role="list" aria-label={`${categoryLabels.get(activeCategory) ?? '当前分类'}遥测字段`}>
{visibleMetrics.map((item) => <span role="listitem" key={telemetryRowKey(item)}><TelemetryMetricCard item={item} onInspect={setInspectedValue} /></span>)}
</div>;
return <Card className="v2-record-card v2-telemetry-card" bodyStyle={{ padding: 0 }}>
<WorkspacePanelHeader
title="实时遥测"
description="按协议查看值、质量与来源"
meta={`${data?.values.length ?? 0} 项`}
/>
<div className="v2-telemetry-tabs">
<SegmentedTabs className="v2-telemetry-protocols" variant="filled" ariaLabel="数据协议" value={activeProtocol} onChange={(protocol) => { setSelectedProtocol(protocol); setSelectedCategory('vehicle'); }} items={indexed.protocols.map((protocol) => ({ key: protocol, label: protocolDisplayLabel(protocol), count: (data?.values ?? []).filter((value) => value.protocol === protocol).length }))} />
<SegmentedTabs className="v2-telemetry-categories" ariaLabel="字段分类" value={activeCategory} onChange={setSelectedCategory} items={categories} />
</div>
{state}
<footer><b>来源</b>{indexed.sources.map((source) => <span key={`${source.protocol}-${source.endpoint ?? ''}`} title={source.endpoint}>{protocolDisplayLabel(source.protocol)}</span>)}<small>扫描 {data?.scannedFrames ?? 0} · 上海时间 {formatTelemetryTime(data?.asOf)}</small></footer>
<WorkspaceSideSheet
className="v2-telemetry-evidence-sidesheet"
variant="detail"
visible={!!inspectedValue}
ariaLabel="遥测字段详情"
placement={mobileLayout ? 'bottom' : 'right'}
width={mobileLayout ? undefined : 520}
height={mobileLayout ? 'min(88dvh, 780px)' : undefined}
title={inspectedValue?.label}
description={inspectedValue?.sourceField}
badge={inspectedValue ? telemetryQualityLabel(inspectedValue.quality) : undefined}
badgeColor={inspectedValue ? telemetryQualityColor(inspectedValue.quality) as WorkspaceSideSheetBadgeColor : 'grey'}
summaryItems={inspectedValue ? [
{
label: '协议来源',
value: <ProtocolTag className="v2-telemetry-detail-protocol" protocol={inspectedValue.protocol} compact />,
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 ? <div className="v2-telemetry-value-detail">
<Descriptions
className="v2-telemetry-value-meta"
size="small"
row
data={[
{ key: '数据类型', value: telemetryValueTypeLabel(inspectedValue.valueType) },
{ key: '单位', value: inspectedValue.unit || '—' },
{ key: '设备时间', value: formatTelemetryTime(inspectedValue.deviceTime) },
{ key: '接收时间', value: formatTelemetryTime(inspectedValue.serverTime) },
{ key: '质量说明', value: inspectedValue.qualityReason || '未提供质量说明' }
]}
/>
<section>
<header><div><strong>完整数据</strong><small>{inspectedPresentation?.summary}</small></div><Button
theme="light"
icon={<IconCopy />}
onClick={() => {
void navigator.clipboard?.writeText(inspectedDetail);
setCopiedValue(true);
}}
>{copiedValue ? '已复制' : '复制完整值'}</Button></header>
<pre>{inspectedDetail}</pre>
</section>
</div> : null}
</WorkspaceSideSheet>
</Card>;
}
function CurrentVehicleAddress({ vehicle, fallback }: { vehicle?: VehicleRealtimeRow; fallback?: string }) {
const coordinate = vehicle && vehicle.locationAvailable !== false && isValidAMapCoordinate(vehicle.longitude, vehicle.latitude)
? { longitude: Number(vehicle.longitude.toFixed(4)), latitude: Number(vehicle.latitude.toFixed(4)) }
: undefined;
const coordinateKey = coordinate ? `${coordinate.longitude.toFixed(4)},${coordinate.latitude.toFixed(4)}` : '';
const [requestedKey, setRequestedKey] = useState('');
const requested = requestedKey ? requestedKey.split(',').map(Number) : [];
const address = useQuery({
queryKey: ['vehicle-detail-address', requestedKey],
enabled: requested.length === 2,
queryFn: ({ signal }) => api.reverseGeocode(new URLSearchParams({
longitude: requested[0].toFixed(4),
latitude: requested[1].toFixed(4)
}), signal),
staleTime: 6 * 60 * 60_000,
gcTime: QUERY_MEMORY.highVolumeGcTime,
refetchOnWindowFocus: false,
retry: 1
});
const moved = !!(coordinateKey && requestedKey && coordinateKey !== requestedKey);
return <div className="v2-current-location">
<span><IconMapPin /><small>当前位置</small><strong>{coordinate ? `${vehicle!.longitude.toFixed(6)}, ${vehicle!.latitude.toFixed(6)}` : '—'}</strong></span>
<span className="v2-current-address"><small>地理位置</small>{!coordinate
? <strong>暂无有效实时坐标</strong>
: !requestedKey
? <Button className="v2-current-address-action" theme="light" type="primary" size="small" aria-label="解析当前位置" icon={<IconMapPin />} onClick={() => setRequestedKey(coordinateKey)}>解析当前位置</Button>
: address.isFetching && !address.data
? <strong>地址解析中…</strong>
: address.isError
? <Button className="v2-current-address-action is-error" theme="light" type="danger" size="small" aria-label="地址解析失败,重试" onClick={() => void address.refetch()}>解析失败,重试</Button>
: <strong title={address.data?.formattedAddress}>{address.data?.formattedAddress || fallback || '暂无地址结果'}</strong>}
{moved ? <Button className="v2-current-address-action" theme="borderless" type="primary" size="small" aria-label="车辆已移动,更新地址" icon={<IconMapPin />} onClick={() => setRequestedKey(coordinateKey)}>车辆已移动 · 更新地址</Button> : null}
</span>
</div>;
}
const vehicleSectionIDs = {
location: 'vehicle-location-panel',
events: 'vehicle-events-panel',
telemetry: 'vehicle-telemetry-panel',
business: 'vehicle-business-panel',
archive: 'vehicle-archive-panel'
} as const;
type VehicleSection = keyof typeof vehicleSectionIDs;
function VehicleRecordNavigation({ onOpenSources }: { onOpenSources: () => void }) {
const jumpToSection = (section: VehicleSection) => {
const target = document.getElementById(vehicleSectionIDs[section]);
if (!target) return;
target.scrollIntoView({ behavior: 'smooth', block: 'start' });
target.focus({ preventScroll: true });
};
const navigationItems = [
['location', '实时位置', <IconMapPin />],
['events', '最近事件', <IconAlarm />],
['telemetry', '实时遥测', <IconClock />],
['business', '业务关联', <IconUserGroup />],
['archive', '车辆主档', <IconBox />]
] as const;
return <Card className="v2-record-card v2-vehicle-record-nav" bodyStyle={{ padding: 0 }}>
<WorkspacePanelHeader
variant="compact"
title="车辆视图"
description="位置、事件、遥测"
actions={<nav className="v2-vehicle-section-nav" aria-label="单车详情导航">
<VehicleSourceEvidenceButton onClick={onOpenSources} />
{navigationItems.map(([key, label, icon]) => <Button key={key} size="small" theme="borderless" type="tertiary" icon={icon} aria-label={`跳转到${label}`} onClick={() => jumpToSection(key)}>{label}</Button>)}
</nav>}
/>
</Card>;
}
function VehicleRecord({ detail, liveRealtime, telemetry, telemetryPending, telemetryError, monitorReturn, directoryReturn, onUpdated }: { detail: VehicleDetail; liveRealtime?: VehicleRealtimeRow; telemetry?: LatestTelemetryResponse; telemetryPending: boolean; telemetryError?: string; monitorReturn: string; directoryReturn: string; onUpdated: () => void }) {
const { session } = usePlatformSession();
const navigate = useNavigate();
const mobileLayout = useMobileLayout();
const recordRootRef = useRef<HTMLDivElement>(null);
const [sourceEvidenceOpen, setSourceEvidenceOpen] = useState(false);
const realtime = liveRealtime ?? detail.realtimeSummary;
const identity = detail.identity;
const hasLocation = !!(realtime && realtime.locationAvailable !== false && isValidAMapCoordinate(realtime.longitude, realtime.latitude));
const mapVehicles = hasLocation && realtime ? [realtime] : [];
const switchVehiclePath = directoryReturn || (monitorReturn ? withMonitorReturn('/vehicles', monitorReturn) : '/vehicles');
const liveMetrics: WorkspaceQueueMetricRailItem[] = [
{
label: '速度',
value: <>{availableMetric(realtime?.speedKmh, realtime?.speedAvailable)}<em className={LIVE_UNIT_CLASS}>km/h</em></>,
note: '实时车速',
tone: 'primary',
emphasis: 'primary'
},
{
label: 'SOC',
value: <>{availableMetric(realtime?.socPercent, realtime?.socAvailable)}<em className={LIVE_UNIT_CLASS}>%</em></>,
note: '剩余电量',
tone: 'success',
emphasis: 'secondary'
},
{
label: '总里程',
value: <>{availableMetric(realtime?.totalMileageKm, realtime?.mileageAvailable)}<em className={LIVE_UNIT_CLASS}>km</em></>,
note: <span className="v2-vehicle-live-action-note">推荐口径<IconChevronRight /></span>,
emphasis: 'secondary',
ariaLabel: '查看总里程全部来源',
onClick: () => setSourceEvidenceOpen(true)
},
{
label: '当日里程',
value: <>{availableMetric(realtime?.todayMileageKm, realtime?.todayMileageAvailable)}<em className={LIVE_UNIT_CLASS}>km</em></>,
note: <span className="v2-vehicle-live-action-note">今日累计<IconChevronRight /></span>,
tone: 'primary',
emphasis: 'primary',
ariaLabel: '查看当日里程全部来源',
onClick: () => setSourceEvidenceOpen(true)
},
{
label: '来源状态',
value: <>{realtime?.onlineSourceCount ?? 0}<em className={LIVE_UNIT_CLASS}>/ {detail.sourceStatus.length}</em></>,
note: '在线来源 / 全部',
tone: (realtime?.onlineSourceCount ?? 0) > 0 ? 'success' : 'neutral',
emphasis: 'secondary'
}
];
useLayoutEffect(() => {
const root = recordRootRef.current;
if (!root) return;
const scrollOwner = root.closest<HTMLElement>('.v2-content') ?? document.querySelector<HTMLElement>('.v2-content');
let active = true;
let resetFrame = 0;
const timers: number[] = [];
const revealIdentity = () => {
if (active) revealVehicleIdentity(root);
};
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, 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);
// 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 () => {
window.cancelAnimationFrame(frame);
stopEntryGuard();
};
}, [detail.vin]);
return <div ref={recordRootRef} className={`v2-vehicle-record-page v2-vehicle-record-v3${mobileLayout ? ' is-mobile-layout' : ''}`} tabIndex={-1} aria-label={`${fmt(identity?.plate || realtime?.plate)} 车辆档案`}>
<MonitorReturnBar />
<Card className="v2-identity-band v2-record-card v2-live-card v2-live-overview v2-vehicle-command-card" bodyStyle={{ padding: 0 }}>
<WorkspaceCommandBar
className="v2-vehicle-command-header v2-vehicle-record-command"
ariaLabel="单车任务栏"
eyebrow="车辆数字档案"
icon={<IconBox />}
title={<span className="v2-identity-primary">
<span className="v2-plate">{fmt(identity?.plate || realtime?.plate)}</span>
<Tag className={`v2-online-label ${realtime?.online ? 'is-online' : ''}`} color={realtime?.online ? 'green' : 'grey'} size="small">{realtime?.online ? '在线' : '离线'}</Tag>
</span>}
description={<span className="v2-identity-vin"><small>VIN</small><b>{detail.vin}</b><Button theme="borderless" aria-label="复制 VIN" title="复制 VIN" icon={<IconCopy />} onClick={() => navigator.clipboard?.writeText(detail.vin)} /></span>}
meta={<span className="v2-identity-meta">
<span className="v2-identity-model"><small>车辆品牌 / 车型</small><strong title={[detail.profile?.brandName, detail.profile?.modelName].filter(Boolean).join(' / ') || fmt(identity?.oem || realtime?.oem)}>{[detail.profile?.brandName, detail.profile?.modelName].filter(Boolean).join(' / ') || fmt(identity?.oem || realtime?.oem)}</strong></span>
<span className="v2-identity-source-context"><small>可用数据来源</small><span className="v2-identity-sources">{detail.sources.length ? detail.sources.map((source) => <ProtocolTag key={source} protocol={source} compact />) : <Tag color="grey" type="light" size="small">暂无来源</Tag>}</span></span>
</span>}
actions={<Suspense fallback={null}><VehicleActions vin={detail.vin} switchTo={switchVehiclePath} directoryReturn={directoryReturn} monitorReturn={monitorReturn} tracks={hasMenu(session, 'tracks')} history={hasMenu(session, 'history')} statistics={hasMenu(session, 'statistics')} alerts={hasMenu(session, 'alerts')} mobile={mobileLayout} onSelect={navigate} /></Suspense>}
/>
<section className="v2-vehicle-live-section" aria-labelledby="vehicle-live-heading">
<WorkspacePanelHeader
title={<span id="vehicle-live-heading">最新上报</span>}
description="关键指标与来源"
meta={<span className="v2-live-report-meta"><i className={realtime?.online ? 'is-online' : ''} />{realtime?.online ? '实时在线' : '当前离线'} · <IconClock /><PlatformTime value={realtime?.lastSeen || identity?.lastSeen} /></span>}
/>
<WorkspaceMetricRail
variant="queue"
className="v2-vehicle-live-metric-rail"
ariaLabel="车辆实时指标"
items={liveMetrics}
context={<span className="v2-vehicle-live-source-context">
<small>推荐来源</small>
<strong><ProtocolTag protocol={realtime?.primaryProtocol} className="v2-live-primary-protocol" compact /></strong>
<em title={realtime?.locationSource}>{protocolSourceLabel(realtime?.locationSource) || '当前最优来源'}</em>
</span>}
/>
<CurrentVehicleAddress vehicle={realtime} fallback={identity?.locationText} />
</section>
</Card>
<VehicleSourceEvidencePanel vin={detail.vin} hideTrigger open={sourceEvidenceOpen} onOpenChange={setSourceEvidenceOpen} />
<VehicleRecordNavigation onOpenSources={() => setSourceEvidenceOpen(true)} />
<div className="v2-record-grid">
<div id={vehicleSectionIDs.location} tabIndex={-1} className="v2-record-section-anchor v2-record-location-anchor">
<Card className="v2-single-map-card" bodyStyle={{ padding: 0 }}>
<WorkspacePanelHeader
title="实时位置"
description="平滑移动"
meta={realtime?.online ? '实时跟随' : '保留最后位置'}
/>
<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><span className="v2-map-update-time">更新时间:<PlatformTime value={realtime?.lastSeen} /></span></footer>
</Card>
</div>
<div id={vehicleSectionIDs.events} tabIndex={-1} className="v2-record-section-anchor"><Events detail={detail} /></div>
<div id={vehicleSectionIDs.telemetry} tabIndex={-1} className="v2-record-section-anchor"><TelemetryPanel data={telemetry} pending={telemetryPending} error={telemetryError} /></div>
<div id={vehicleSectionIDs.business} tabIndex={-1} className="v2-record-section-anchor"><Suspense fallback={<Card className="v2-record-card v2-business-relation-card" bodyStyle={{ padding: 0 }}><PanelLoading compact title="正在读取业务关联" description="客户、合同与运营责任就绪后会自动显示。" /></Card>}><VehicleBusinessRelationCard relation={detail.businessRelation} /></Suspense></div>
<div id={vehicleSectionIDs.archive} tabIndex={-1} className="v2-record-section-anchor"><Suspense fallback={<Card className="v2-record-card v2-archive-card" bodyStyle={{ padding: 0 }}><PanelLoading compact title="正在读取车辆主档" description="主档与维护权限就绪后会自动显示。" /></Card>}><VehicleArchiveCard detail={detail} editable={canAdminister(session)} onUpdated={onUpdated} /></Suspense></div>
</div>
</div>;
}
export default function VehiclePage() {
const { vin } = useParams();
const [searchParams] = useSearchParams();
const monitorReturn = monitorReturnFromParams(searchParams);
const directoryReturn = vehicleDirectoryReturnFromParams(searchParams);
const query = useQuery({ queryKey: ['vehicle-detail', vin], enabled: !!vin, queryFn: ({ signal }) => api.vehicleDetail(new URLSearchParams({ keyword: vin!, limit: '20' }), signal), gcTime: QUERY_MEMORY.summaryGcTime });
const resolvedVin = query.data?.lookupResolved ? query.data.vin : '';
const realtime = useQuery({
queryKey: ['vehicle-detail-realtime', resolvedVin],
queryFn: ({ signal }) => api.vehicleRealtime(new URLSearchParams({ keywords: resolvedVin, limit: '1', offset: '0' }), signal),
enabled: !!resolvedVin,
staleTime: 5_000,
gcTime: QUERY_MEMORY.highVolumeGcTime,
refetchInterval: resolvedVin ? SINGLE_VEHICLE_REFRESH_MS : false,
...LIVE_QUERY_POLICY
});
const telemetry = useQuery({ queryKey: ['vehicle-latest-telemetry', vin], enabled: !!vin, queryFn: ({ signal }) => api.latestTelemetry(vin!, signal), staleTime: 10_000, gcTime: QUERY_MEMORY.summaryGcTime, refetchInterval: 20_000, ...LIVE_QUERY_POLICY });
useEffect(() => {
resetWorkspaceScroll();
let trailingFrame = 0;
const frame = window.requestAnimationFrame(() => {
resetWorkspaceScroll();
trailingFrame = window.requestAnimationFrame(() => resetWorkspaceScroll());
});
const shortTimer = window.setTimeout(resetWorkspaceScroll, 120);
const settledTimer = window.setTimeout(resetWorkspaceScroll, 360);
return () => {
window.cancelAnimationFrame(frame);
window.cancelAnimationFrame(trailingFrame);
window.clearTimeout(shortTimer);
window.clearTimeout(settledTimer);
};
}, [vin, resolvedVin]);
if (!vin) return <Suspense fallback={<PageLoading />}><VehicleSearch /></Suspense>;
if (query.isPending) return <PageLoading />;
if (query.isError) return <div className="v2-page-error"><InlineError message={query.error instanceof Error ? query.error.message : '车辆档案加载失败'} onRetry={() => query.refetch()} /></div>;
if (!query.data.lookupResolved) return <Card className="v2-not-found" bodyStyle={{ padding: 0 }}><PanelEmpty
className="v2-vehicle-not-found-state"
tone="primary"
icon={<IconSearch />}
title="未找到车辆"
description={`没有匹配“${vin}”的车牌、VIN 或终端记录。`}
action={<Link to={directoryReturn || (monitorReturn ? withMonitorReturn('/vehicles', monitorReturn) : '/vehicles')}><Button theme="solid" icon={<IconSearch />} aria-label="重新查询车辆">重新查询</Button></Link>}
/></Card>;
return <VehicleRecord detail={query.data} liveRealtime={realtime.data?.items[0]} telemetry={telemetry.data} telemetryPending={telemetry.isPending} telemetryError={telemetry.isError ? (telemetry.error instanceof Error ? telemetry.error.message : '请求失败') : undefined} monitorReturn={monitorReturn} directoryReturn={directoryReturn} onUpdated={() => { void query.refetch(); void realtime.refetch(); void telemetry.refetch(); }} />;
}