Files
lingniu-vehicle-ingest/vehicle-data-platform/apps/web/src/v2/pages/VehiclePage.tsx
2026-07-18 10:00:15 +08:00

548 lines
37 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 { useMutation, useQuery } from '@tanstack/react-query';
import {
IconAlarm, IconBox, IconCalendar, IconClock, IconCopy,
IconChevronRight, IconMapPin, IconSearch, IconTickCircle
} from '@douyinfe/semi-icons';
import { Button, Card, Descriptions, Empty, Input, List, Select, SideSheet, Table, Tag } from '@douyinfe/semi-ui';
import { FormEvent, 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, telemetryQualityLabel } from '../domain/telemetry';
import { formatZhNumber } from '../domain/formatters';
import { isValidAMapCoordinate } from '../../integrations/amap';
import { FleetMap } from '../map/FleetMap';
import { InlineError, PageLoading } from '../shared/AsyncState';
import { MonitorReturnBar } from '../shared/MonitorReturnBar';
import { SegmentedTabs } from '../shared/SegmentedTabs';
import { VehicleSourceEvidencePanel } from '../shared/VehicleSourceEvidencePanel';
import { WorkspacePanelHeader } from '../shared/WorkspacePanelHeader';
import { monitorReturnFromParams, withMonitorReturn } from '../routing/monitorContext';
import { useMobileLayout } from '../hooks/useMobileLayout';
import { useSideSheetA11y } from '../hooks/useSideSheetA11y';
function fmt(value?: string) { return value?.trim() || '—'; }
function metric(value: number | undefined, fallback = '—') { return typeof value === 'number' && Number.isFinite(value) ? formatZhNumber(value, 1) : fallback; }
function availableMetric(value: number | undefined, available?: boolean) { return available === false ? '—' : metric(value); }
function issueTone(issue: QualityIssueRow) { return issue.severity === 'error' ? 'error' : 'warning'; }
function durationHours(seconds?: number | null) { return seconds == null ? '—' : `${formatZhNumber(seconds / 3600, 1)} 小时`; }
function localDateTime(value?: string) { return value ? value.slice(0, 16) : ''; }
const operationStatusLabels = { unknown: '待维护', active: '运营中', inactive: '停运', maintenance: '维保中', retired: '已退役' } as const;
const SINGLE_VEHICLE_REFRESH_MS = 10_000;
const VehicleSearch = lazy(() => import('./VehicleSearchWorkspace'));
function resetWorkspaceScroll() {
const targets = [
document.scrollingElement,
document.documentElement,
document.body,
document.querySelector<HTMLElement>('.v2-shell'),
document.querySelector<HTMLElement>('.v2-main'),
document.querySelector<HTMLElement>('.v2-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();
if (!root) return;
root.scrollTop = 0;
root.scrollLeft = 0;
root.scrollIntoView?.({ behavior: 'auto', block: 'start', inline: 'nearest' });
}
function eventTimeLabel(value?: string) {
if (!value) return '—';
const normalized = value.replace('T', ' ');
return normalized.length >= 16 ? normalized.slice(5, 16) : normalized;
}
function Archive({ detail, editable, onUpdated }: { detail: VehicleDetail; editable: boolean; onUpdated: () => void }) {
const profile = detail.profile;
const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState({ brandName: '', modelName: '', vehicleType: '', companyName: '', operationStatus: 'unknown', accessProvider: '', firstAccessAt: '', runtimeHours: '' });
const save = useMutation({
mutationFn: () => api.updateVehicleProfile(detail.vin, {
brandName: draft.brandName.trim(), modelName: draft.modelName.trim(), vehicleType: draft.vehicleType.trim(), companyName: draft.companyName.trim(),
operationStatus: draft.operationStatus as NonNullable<typeof profile>['operationStatus'], accessProvider: draft.accessProvider.trim(), firstAccessAt: draft.firstAccessAt,
runtimeSeconds: draft.runtimeHours.trim() === '' ? null : Math.round(Number(draft.runtimeHours) * 3600), version: profile?.version ?? 0
}),
onSuccess: () => { setEditing(false); onUpdated(); }
});
const startEditing = () => {
setDraft({ brandName: profile?.brandName ?? '', modelName: profile?.modelName ?? '', vehicleType: profile?.vehicleType ?? '', companyName: profile?.companyName ?? '', operationStatus: profile?.operationStatus ?? 'unknown', accessProvider: profile?.accessProvider ?? '', firstAccessAt: localDateTime(profile?.firstAccessAt), runtimeHours: profile?.runtimeSeconds == null ? '' : String(profile.runtimeSeconds / 3600) });
save.reset(); setEditing(true);
};
const submit = (event: FormEvent) => { event.preventDefault(); if (draft.runtimeHours === '' || Number.isFinite(Number(draft.runtimeHours))) save.mutate(); };
return <Card className="v2-record-card v2-archive-card" bodyStyle={{ padding: 0 }}>
<WorkspacePanelHeader
title="车辆主档"
description="身份、车型与运营属性"
meta={`完整度 ${profile?.completeness ?? 0}%`}
actions={editable && !editing ? <Button theme="borderless" size="small" onClick={startEditing}></Button> : null}
/>
{editing ? <form className="v2-profile-form" onSubmit={submit}>
<label><span></span><Input maxLength={128} value={draft.brandName} onChange={(value) => setDraft({ ...draft, brandName: value })} /></label>
<label><span></span><Input maxLength={128} value={draft.modelName} onChange={(value) => setDraft({ ...draft, modelName: value })} /></label>
<label><span></span><Input maxLength={64} value={draft.vehicleType} onChange={(value) => setDraft({ ...draft, vehicleType: value })} /></label>
<label><span></span><Input maxLength={128} value={draft.companyName} onChange={(value) => setDraft({ ...draft, companyName: value })} /></label>
<label><span></span><Select value={draft.operationStatus} onChange={(value) => setDraft({ ...draft, operationStatus: String(value) })} optionList={Object.entries(operationStatusLabels).map(([value, label]) => ({ value, label }))} /></label>
<label><span></span><Input maxLength={128} value={draft.accessProvider} onChange={(value) => setDraft({ ...draft, accessProvider: value })} /></label>
<label><span></span><Input aria-label="首次接入" type="datetime-local" value={draft.firstAccessAt} onChange={(value) => setDraft({ ...draft, firstAccessAt: value })} /></label>
<label><span></span><Input type="number" min="0" step="0.1" value={draft.runtimeHours} onChange={(value) => setDraft({ ...draft, runtimeHours: value })} /></label>
{save.isError ? <p>{save.error.message}</p> : null}<footer><Button theme="light" onClick={() => setEditing(false)}></Button><Button theme="solid" htmlType="submit" loading={save.isPending}></Button></footer>
</form> : <><Descriptions className="v2-record-descriptions" align="left" size="small" data={[
{ key: '车辆品牌', value: fmt(profile?.brandName) },
{ key: '车型 / 类型', value: [profile?.modelName, profile?.vehicleType].filter(Boolean).join(' / ') || '—' },
{ key: '所属企业', value: fmt(profile?.companyName) },
{ key: '运营状态', value: <Tag color={profile?.operationStatus === 'active' ? 'green' : profile?.operationStatus === 'maintenance' ? 'orange' : 'grey'} type="light" size="small">{operationStatusLabels[profile?.operationStatus ?? 'unknown']}</Tag> },
{ key: '接入服务商', value: fmt(profile?.accessProvider) },
{ key: '首次接入', value: fmt(profile?.firstAccessAt) },
{ key: '累计运行', value: durationHours(profile?.runtimeSeconds) }
]} /><p className="v2-record-note"> {profile?.sourceSystem || '未配置'}{profile?.updatedAt ? ` · v${profile.version} · ${profile.updatedBy} 更新` : ''}</p></>}
</Card>;
}
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: `${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={<Empty className="v2-event-empty" 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>;
}
function TelemetryFieldCell({ item }: { item: LatestTelemetryValue }) {
return <div className="v2-telemetry-field"><strong title={item.description}>{item.label}</strong><small title={item.sourceField}>{item.sourceField}</small></div>;
}
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 TelemetryValueCell({ item, onInspect }: { item: LatestTelemetryValue; onInspect?: (item: LatestTelemetryValue) => void }) {
const presentation = telemetryValuePresentation(item);
if (presentation.complex) {
return <div className="v2-telemetry-value is-complex"><Button
className="v2-telemetry-value-trigger"
theme="borderless"
type="tertiary"
aria-label={`查看 ${item.label} 完整数据`}
onClick={() => onInspect?.(item)}
>
<span><strong>{presentation.summary}</strong><small>{presentation.preview}</small></span><IconChevronRight />
</Button></div>;
}
return <div className="v2-telemetry-value"><strong>{presentation.summary}</strong>{item.unit ? <span>{item.unit}</span> : null}</div>;
}
function TelemetryQualityCell({ item }: { item: LatestTelemetryValue }) {
const color = item.quality === 'good' ? 'green' : item.quality === 'stale' ? 'orange' : 'red';
return <div className="v2-telemetry-quality"><Tag color={color} type="light" size="small">{telemetryQualityLabel(item.quality)}</Tag><small title={item.qualityReason}>{item.qualityReason || '未提供质量说明'} · {formatZhNumber(item.freshnessSeconds, 0)}s</small></div>;
}
function TelemetryTimeCell({ item }: { item: LatestTelemetryValue }) {
return <div className="v2-telemetry-time"><span><small></small>{formatTelemetryTime(item.deviceTime)}</span><span><small></small>{formatTelemetryTime(item.serverTime)}</span></div>;
}
function TelemetrySourceCell({ item }: { item: LatestTelemetryValue }) {
return <div className="v2-telemetry-source"><Tag color="blue" type="light" size="small">{item.protocol}</Tag><small title={item.sourceEndpoint}>{item.sourceEndpoint || '协议默认来源'}</small></div>;
}
function telemetryRowKey(item?: LatestTelemetryValue) {
return `${item?.protocol ?? ''}-${item?.category ?? ''}-${item?.sourceField ?? ''}-${item?.frameId ?? ''}`;
}
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]);
useSideSheetA11y(Boolean(inspectedValue), '.v2-telemetry-value-sidesheet', 'v2-telemetry-value-detail', '遥测字段完整数据', '关闭遥测字段完整数据');
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 protocolLabel = (protocol: string) => protocol === 'GB32960' ? 'GB/T 32960' : protocol === 'JT808' ? 'JT/T 808' : protocol === 'YUTONG_MQTT' ? '宇通 MQTT' : protocol;
const columns = [
{ title: '字段 / 协议映射', dataIndex: 'label', width: 260, render: (_: unknown, item: LatestTelemetryValue) => <TelemetryFieldCell item={item} /> },
{ title: '当前值', dataIndex: 'value', width: 170, render: (_: unknown, item: LatestTelemetryValue) => <TelemetryValueCell item={item} onInspect={setInspectedValue} /> },
{ title: '质量 / 新鲜度', dataIndex: 'quality', width: 170, render: (_: unknown, item: LatestTelemetryValue) => <TelemetryQualityCell item={item} /> },
{ title: '设备 / 接收时间', dataIndex: 'deviceTime', width: 230, render: (_: unknown, item: LatestTelemetryValue) => <TelemetryTimeCell item={item} /> },
{ title: '数据来源', dataIndex: 'protocol', width: 180, render: (_: unknown, item: LatestTelemetryValue) => <TelemetrySourceCell item={item} /> }
];
const state = pending
? <div className="v2-telemetry-state" role="status"><strong></strong><span></span></div>
: error
? <div className="v2-telemetry-state is-error" role="alert"><strong></strong><span>{error}</span></div>
: visibleMetrics.length === 0
? <Empty className="v2-telemetry-empty" title="暂无可展示字段" description={`该协议最近 ${data?.scannedFrames ?? 0} 帧没有可展示的标量遥测。`} />
: mobileLayout
? <List className="v2-telemetry-mobile-list" dataSource={visibleMetrics} split={false} renderItem={(item) => <List.Item className="v2-telemetry-mobile-item" key={telemetryRowKey(item)}>
<header><TelemetryFieldCell item={item} /><TelemetryValueCell item={item} onInspect={setInspectedValue} /></header>
<div><TelemetryQualityCell item={item} /><TelemetryTimeCell item={item} /></div>
<TelemetrySourceCell item={item} />
</List.Item>} />
: <div className="v2-telemetry-table-wrap"><Table
className="v2-telemetry-table"
columns={columns}
dataSource={visibleMetrics}
rowKey={telemetryRowKey}
pagination={false}
scroll={{ x: 1010 }}
empty={null}
/></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: protocolLabel(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}>{source.protocol}</span>)}<small> {data?.scannedFrames ?? 0} · {formatTelemetryTime(data?.asOf)}</small></footer>
<SideSheet
className="v2-telemetry-value-sidesheet"
visible={Boolean(inspectedValue)}
width={520}
title={inspectedValue ? <div className="v2-telemetry-value-title"><strong>{inspectedValue.label}</strong><span>{inspectedValue.sourceField}</span></div> : null}
onCancel={() => { setInspectedValue(undefined); setCopiedValue(false); }}
footer={<div className="v2-telemetry-value-footer"><span></span><Button theme="solid" onClick={() => { setInspectedValue(undefined); setCopiedValue(false); }}></Button></div>}
>
{inspectedValue ? <div className="v2-telemetry-value-detail">
<Descriptions
className="v2-telemetry-value-meta"
size="small"
row
data={[
{ key: '协议', value: inspectedValue.protocol },
{ key: '数据类型', value: inspectedValue.valueType || '—' },
{ key: '单位', value: inspectedValue.unit || '—' },
{ key: '质量', value: telemetryQualityLabel(inspectedValue.quality) },
{ key: '设备时间', value: formatTelemetryTime(inspectedValue.deviceTime) },
{ key: '接收时间', value: formatTelemetryTime(inspectedValue.serverTime) }
]}
/>
<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>
<footer><span></span><strong>{inspectedValue.sourceEndpoint || '协议默认来源'}</strong></footer>
</div> : null}
</SideSheet>
</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 = Boolean(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',
archive: 'vehicle-archive-panel'
} as const;
type VehicleSection = keyof typeof vehicleSectionIDs;
function VehicleRecordNavigation() {
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 />],
['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="单车详情导航">
{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, onUpdated }: { detail: VehicleDetail; liveRealtime?: VehicleRealtimeRow; telemetry?: LatestTelemetryResponse; telemetryPending: boolean; telemetryError?: string; monitorReturn: 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 = Boolean(realtime && realtime.locationAvailable !== false && isValidAMapCoordinate(realtime.longitude, realtime.latitude));
const mapVehicles = hasLocation && realtime ? [realtime] : [];
const actions = [
{ key: 'switch', label: '切换车辆', icon: <IconSearch />, to: '/vehicles', type: 'primary' as const },
...(hasMenu(session, 'tracks') ? [{ key: 'tracks', label: '轨迹回放', icon: <IconMapPin />, to: withMonitorReturn(`/tracks?vin=${encodeURIComponent(detail.vin)}`, monitorReturn), type: 'tertiary' as const }] : []),
...(hasMenu(session, 'history') ? [{ key: 'history', label: '历史数据', icon: <IconCalendar />, to: withMonitorReturn(`/history?vin=${encodeURIComponent(detail.vin)}`, monitorReturn), type: 'tertiary' as const }] : []),
...(hasMenu(session, 'statistics') ? [{ key: 'statistics', label: '里程查询', icon: <IconClock />, to: withMonitorReturn(`/statistics?vins=${encodeURIComponent(detail.vin)}`, monitorReturn), type: 'tertiary' as const }] : []),
...(hasMenu(session, 'alerts') ? [{ key: 'alerts', label: '告警事件', icon: <IconAlarm />, to: `/alerts?vin=${encodeURIComponent(detail.vin)}`, type: 'tertiary' as const }] : [])
];
useLayoutEffect(() => {
const root = recordRootRef.current;
if (!root) return;
let cancelled = false;
const timers: number[] = [];
const revealIdentity = () => {
if (!cancelled) revealVehicleIdentity(root);
};
const cancelEntryReset = () => {
cancelled = true;
for (const timer of timers) window.clearTimeout(timer);
};
const interactionEvents = ['wheel', 'touchstart', 'pointerdown', 'keydown'] as const;
for (const eventName of interactionEvents) window.addEventListener(eventName, cancelEntryReset, { capture: true, passive: true });
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));
return () => {
cancelled = true;
window.cancelAnimationFrame(frame);
for (const timer of timers) window.clearTimeout(timer);
for (const eventName of interactionEvents) window.removeEventListener(eventName, cancelEntryReset, true);
};
}, [detail.vin]);
return <div ref={recordRootRef} className={`v2-vehicle-record-page${mobileLayout ? ' is-mobile-layout' : ''}`}>
<MonitorReturnBar />
<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-identity-primary">
<span className="v2-plate"><IconBox />{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 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>
</div>
<div className="v2-identity-meta">
<div><small> / </small><strong>{[detail.profile?.brandName, detail.profile?.modelName].filter(Boolean).join(' / ') || fmt(identity?.oem || realtime?.oem)}</strong></div>
<div><small></small><div className="v2-identity-sources">{detail.sources.length ? detail.sources.map((source) => <Tag key={source} color="blue" type="light" size="small">{source}</Tag>) : <Tag color="grey" type="light" size="small"></Tag>}</div></div>
</div>
<div className="v2-identity-actions" role="group" aria-label="车辆快捷操作">
{actions.map((action) => <Button key={action.key} theme="light" type={action.type} icon={action.icon} aria-label={action.label} onClick={() => navigate(action.to)}>{action.label}</Button>)}
</div>
</div>
<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 />{fmt(realtime?.lastSeen || identity?.lastSeen)}</span>}
/>
<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>SOC</small><strong>{availableMetric(realtime?.socPercent, realtime?.socAvailable)}<em>%</em></strong><span></span></div>
<div role="listitem"><small></small><Button theme="borderless" type="tertiary" className="v2-live-source-link" title="展开全部里程来源" aria-label="查看总里程全部来源" onClick={() => setSourceEvidenceOpen(true)}><span>{availableMetric(realtime?.totalMileageKm, realtime?.mileageAvailable)}<em>km</em></span><IconChevronRight /></Button><span></span></div>
<div role="listitem"><small></small><Button theme="borderless" type="tertiary" className="v2-live-source-link" title="展开全部里程来源" aria-label="查看当日里程全部来源" onClick={() => setSourceEvidenceOpen(true)}><span>{availableMetric(realtime?.todayMileageKm, realtime?.todayMileageAvailable)}<em>km</em></span><IconChevronRight /></Button><span></span></div>
<div role="listitem"><small></small><strong className="is-text">{fmt(realtime?.primaryProtocol)}</strong><span title={realtime?.locationSource}>{realtime?.locationSource || '当前最优来源'}</span></div>
<div role="listitem"><small></small><strong>{realtime?.onlineSourceCount ?? 0}<em> / {detail.sourceStatus.length}</em></strong><span>线 / </span></div>
</div>
<CurrentVehicleAddress vehicle={realtime} fallback={identity?.locationText} />
</section>
</Card>
<VehicleSourceEvidencePanel vin={detail.vin} open={sourceEvidenceOpen} onOpenChange={setSourceEvidenceOpen} />
<VehicleRecordNavigation />
<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><time>{fmt(realtime?.lastSeen)}</time></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.archive} tabIndex={-1} className="v2-record-section-anchor"><Archive detail={detail} editable={canAdminister(session)} onUpdated={onUpdated} /></div>
</div>
</div>;
}
export default function VehiclePage() {
const { vin } = useParams();
const [searchParams] = useSearchParams();
const monitorReturn = monitorReturnFromParams(searchParams);
const query = useQuery({ queryKey: ['vehicle-detail', vin], enabled: Boolean(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: Boolean(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: Boolean(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 }}><Empty image={<IconSearch size="extra-large" />} title="未找到车辆" description={`没有匹配“${vin}”的车牌、VIN 或终端记录。`}><Link to="/vehicles"><Button theme="solid" icon={<IconSearch />}></Button></Link></Empty></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} onUpdated={() => { void query.refetch(); void realtime.refetch(); void telemetry.refetch(); }} />;
}