572 lines
34 KiB
TypeScript
572 lines
34 KiB
TypeScript
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(); }} />;
|
||
}
|