import { Banner, Button, Card, Descriptions, Form, Select, Space, Table, Tabs, Tag, Toast, Typography } from '@douyinfe/semi-ui'; import { IconRefresh, IconSearch } from '@douyinfe/semi-icons'; import { useEffect, useMemo, useRef, useState } from 'react'; import { api } from '../api/client'; import type { QualityIssueRow, VehicleDetail as VehicleDetailData, VehicleSourceStatus } from '../api/types'; import { PageHeader } from '../components/PageHeader'; import { StatusTag } from '../components/StatusTag'; import { isLikelyVIN } from '../domain/vehicleLookup'; type VehicleQuery = { keyword: string; protocol?: string; }; const sourceCapabilities: Array<{ key: keyof Pick; label: string }> = [ { key: 'hasRealtime', label: '实时' }, { key: 'hasHistory', label: '历史' }, { key: 'hasRaw', label: 'RAW' }, { key: 'hasMileage', label: '里程' } ]; const coverageStatusText: Record = { online: '全部在线', partial: '部分在线', offline: '全部离线', no_data: '暂无来源' }; function isFiniteNumber(value: unknown): value is number { return typeof value === 'number' && Number.isFinite(value); } function formatCompactNumber(value?: number, suffix = '') { if (!isFiniteNumber(value)) return '-'; return `${value.toLocaleString(undefined, { maximumFractionDigits: 1 })}${suffix}`; } function parseTimeMs(value?: string) { if (!value) return undefined; const ms = Date.parse(value); return Number.isFinite(ms) ? ms : undefined; } function formatSeconds(value?: number) { if (!isFiniteNumber(value)) return '-'; return `${Math.round(value)} 秒`; } export function VehicleDetail({ vin, protocol, onOpenHistory, onOpenMileage, onQueryChange }: { vin: string; protocol?: string; onOpenHistory: (vin: string, protocol?: string) => void; onOpenMileage: (vin: string, protocol?: string) => void; onQueryChange?: (keyword: string, protocol?: string) => void; }) { const [query, setQuery] = useState({ keyword: vin, protocol }); const [detail, setDetail] = useState(null); const [loading, setLoading] = useState(false); const requestSeq = useRef(0); const load = (nextQuery = query) => { const keyword = nextQuery.keyword.trim(); if (!keyword) { Toast.warning('请输入 VIN / 车牌 / 手机号'); return; } setLoading(true); const params = new URLSearchParams({ keyword }); if (nextQuery.protocol?.trim()) { params.set('protocol', nextQuery.protocol.trim()); } const seq = requestSeq.current + 1; requestSeq.current = seq; api.vehicleDetail(params) .then((nextDetail) => { if (requestSeq.current === seq) { setDetail(nextDetail); } }) .catch((error: Error) => { if (requestSeq.current === seq) { Toast.error(error.message); } }) .finally(() => { if (requestSeq.current === seq) { setLoading(false); } }); }; useEffect(() => { const nextQuery = { keyword: vin, protocol }; setQuery(nextQuery); load(nextQuery); }, [vin, protocol]); const identity = detail?.identity; const summary = detail?.realtimeSummary; const latest = detail?.realtime?.[0]; const resolution = detail?.resolution; const overview = detail?.serviceOverview; const resolvedVIN = resolution?.vin || detail?.vin || summary?.vin || identity?.vin || latest?.vin || query.keyword; const hasResolvedVIN = resolution?.resolved ?? detail?.lookupResolved ?? isLikelyVIN(resolvedVIN); const displayVIN = hasResolvedVIN ? resolvedVIN : '-'; const displayLookupKey = resolution?.lookupKey || detail?.lookupKey || query.keyword; const protocols = useMemo(() => resolution?.protocols?.length ? resolution.protocols : detail?.sources ?? [], [detail?.sources, resolution?.protocols]); const latestRaw = detail?.raw?.items?.[0]; const qualityCount = detail?.quality?.total ?? 0; const online = resolution?.online || summary?.online || identity?.online || false; const lastSeen = resolution?.lastSeen || summary?.lastSeen || latest?.lastSeen || '-'; const serviceStatus = detail?.serviceStatus; const realtimeRows = detail?.realtime ?? []; const realtimeSources = new Set(realtimeRows.map((row) => row.protocol).filter(Boolean)); const sourceCount = Math.max(detail?.sourceStatus?.length ?? 0, realtimeSources.size, summary?.sourceCount ?? 0, protocols.length); const onlineSourceCount = detail?.sourceStatus?.length ? detail.sourceStatus.filter((source) => source.online).length : summary?.onlineSourceCount ?? 0; const locatedSourceCount = realtimeRows.filter((row) => isFiniteNumber(row.longitude) && isFiniteNumber(row.latitude)).length; const mileageValues = realtimeRows.map((row) => row.totalMileageKm).filter(isFiniteNumber); const mileageDelta = mileageValues.length > 1 ? Math.max(...mileageValues) - Math.min(...mileageValues) : undefined; const timeValues = [ ...realtimeRows.map((row) => parseTimeMs(row.lastSeen)), ...(detail?.sourceStatus ?? []).map((source) => parseTimeMs(source.lastSeen)) ].filter(isFiniteNumber); const sourceTimeDeltaSeconds = timeValues.length > 1 ? (Math.max(...timeValues) - Math.min(...timeValues)) / 1000 : undefined; const consistency = detail?.sourceConsistency; const consistencySourceCount = consistency?.sourceCount ?? sourceCount; const consistencyOnlineSourceCount = consistency?.onlineSourceCount ?? onlineSourceCount; const consistencyLocatedSourceCount = consistency?.locatedSourceCount ?? locatedSourceCount; const consistencyMileageDelta = consistency?.mileageDeltaKm ?? mileageDelta; const consistencyTimeDeltaSeconds = consistency?.sourceTimeDeltaSeconds ?? sourceTimeDeltaSeconds; const activeProtocol = query.protocol?.trim() ?? ''; const scopeText = activeProtocol ? `单一来源:${activeProtocol}` : '全部来源聚合'; const formKey = `${query.keyword}-${query.protocol ?? ''}`; const switchSource = (nextProtocol = '') => { const nextQuery = { keyword: resolvedVIN || query.keyword, protocol: nextProtocol }; setQuery(nextQuery); onQueryChange?.(nextQuery.keyword, nextQuery.protocol); load(nextQuery); }; const qualityTable = ( `${row?.protocol ?? ''}-${row?.issueType ?? ''}-${row?.lastSeen ?? ''}`} dataSource={detail?.quality?.items ?? []} columns={[ { title: '来源', dataIndex: 'protocol', width: 130 }, { title: '问题', dataIndex: 'issueType', width: 150 }, { title: '级别', width: 110, render: (_: unknown, row: QualityIssueRow) => {row.severity} }, { title: '最后时间', dataIndex: 'lastSeen', width: 190 }, { title: '说明', dataIndex: 'detail' } ]} /> ); return (
{ const nextQuery = { keyword: String(values.keyword ?? ''), protocol: String(values.protocol ?? '') }; setQuery(nextQuery); onQueryChange?.(nextQuery.keyword, nextQuery.protocol); load(nextQuery); }}> GB32960 JT808 YUTONG_MQTT
当前查看范围 {scopeText} {activeProtocol ? '下方实时、历史、RAW 和里程仅展示该来源返回的数据。' : '下方数据按车辆聚合展示全部可用来源。'}
{serviceStatus ? (
车辆服务状态 {serviceStatus.title} {serviceStatus.detail}
) : null} {overview ? ( {coverageStatusText[overview.coverageStatus] ?? overview.coverageStatus} }, { key: '主来源', value: overview.primaryProtocol || '-' }, { key: '最后上报', value: overview.lastSeen || '-' }, { key: '数据规模', value: `历史 ${overview.historyCount} / RAW ${overview.rawCount} / 里程 ${overview.mileageCount}` }, { key: '实时来源', value: overview.realtimeCount }, { key: '质量问题', value: 0 ? 'orange' : 'green'}>{overview.qualityIssueCount} } ]} /> ) : null}
{hasResolvedVIN ? '已解析 VIN' : '未解析到 VIN'} }, { key: '车牌', value: resolution?.plate || summary?.plate || identity?.plate || latest?.plate || '-' }, { key: '手机号', value: resolution?.phone || summary?.phone || identity?.phone || '-' }, { key: 'OEM', value: resolution?.oem || summary?.oem || identity?.oem || '-' }, { key: '在线', value: }, { key: '数据来源', value: protocols.length > 0 ? {protocols.map((item) => {item})} : '-' }, { key: '在线来源', value: summary ? `${summary.onlineSourceCount}/${summary.sourceCount}` : '-' }, { key: '最后位置时间', value: lastSeen }, { key: '最新 RAW 时间', value: latestRaw?.serverTime ?? '-' }, { key: '质量问题', value: 0 ? 'orange' : 'green'}>{qualityCount > 0 ? `${qualityCount} 项` : '无'} } ]} />
{!hasResolvedVIN ? ( ) : null}
{hasResolvedVIN ? ( <> {detail?.sourceStatus?.length ? ( <>
{activeProtocol ? `当前 ${activeProtocol}` : '当前 全部来源'}
{detail.sourceStatus.map((source) => (
{source.protocol} {source.lastSeen || '无上报'}
{sourceCapabilities.map((item) => ( {item.label}{source[item.key] ? '有' : '无'} ))}
))}
) : ( 暂未查询到该车辆的数据来源覆盖。 )}
0 ? `${consistencySourceCount} 个来源` : '-' }, { key: '在线来源', value: consistencySourceCount > 0 ? `${consistencyOnlineSourceCount}/${consistencySourceCount} 在线` : '-' }, { key: '位置覆盖', value: consistencyLocatedSourceCount > 0 ? `${consistencyLocatedSourceCount} 个来源有位置` : '暂无位置' }, { key: '里程差异', value: formatCompactNumber(consistencyMileageDelta, ' km') }, { key: '来源时间差', value: formatSeconds(consistencyTimeDeltaSeconds) }, { key: '车辆服务视角', value: activeProtocol ? `当前只看 ${activeProtocol}` : '三类来源合并为同一车辆服务' } ]} />
}, { title: '最后时间', dataIndex: 'lastSeen', width: 190 }, { title: '实时', width: 90, render: (_: unknown, row: VehicleSourceStatus) => {row.hasRealtime ? '有' : '无'} }, { title: '历史', width: 90, render: (_: unknown, row: VehicleSourceStatus) => {row.hasHistory ? '有' : '无'} }, { title: 'RAW', width: 90, render: (_: unknown, row: VehicleSourceStatus) => {row.hasRaw ? '有' : '无'} }, { title: '里程', width: 90, render: (_: unknown, row: VehicleSourceStatus) => {row.hasMileage ? '有' : '无'} } ]} /> ) : null} {!hasResolvedVIN ? ( {qualityTable} ) : (
{[ { label: '最新来源', value: summary?.primaryProtocol || '-' }, { label: '速度 km/h', value: summary ? summary.speedKmh : '-' }, { label: 'SOC %', value: summary ? summary.socPercent : '-' }, { label: '总里程 km', value: summary ? summary.totalMileageKm : '-' }, { label: '经纬度', value: summary ? `${summary.longitude}, ${summary.latitude}` : '-' }, { label: '最后时间', value: lastSeen } ].map((item) => (
{item.label}
{item.value}
))}
最新 RAW 解析字段
{JSON.stringify(latestRaw?.parsedFields ?? {}, null, 2)}
{qualityTable} )} ); }