Files
lingniu-vehicle-ingest/vehicle-data-platform/apps/web/src/pages/VehicleDetail.tsx

668 lines
31 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 { Banner, Button, Card, Descriptions, Form, Select, Space, Table, Tabs, Tag, Toast, Typography } from '@douyinfe/semi-ui';
import { IconCopy, 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 { SourceStatusTags } from '../components/SourceStatusTags';
import { StatusTag } from '../components/StatusTag';
import { isLikelyVIN } from '../domain/vehicleLookup';
type VehicleQuery = {
keyword: string;
protocol?: string;
};
const sourceCapabilities: Array<{ key: keyof Pick<VehicleSourceStatus, 'hasRealtime' | 'hasHistory' | 'hasRaw' | 'hasMileage'>; label: string }> = [
{ key: 'hasRealtime', label: '实时' },
{ key: 'hasHistory', label: '历史' },
{ key: 'hasRaw', label: 'RAW' },
{ key: 'hasMileage', label: '里程' }
];
const coverageStatusText: Record<string, string> = {
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)}`;
}
function sourceServiceRole(source: VehicleSourceStatus, primaryProtocol?: string) {
if (source.protocol === primaryProtocol) return { label: '主来源', color: 'blue' as const };
if (source.online) return { label: '在线证据', color: 'green' as const };
if (!source.lastSeen && !source.hasRealtime && !source.hasHistory && !source.hasRaw && !source.hasMileage) {
return { label: '暂无来源', color: 'grey' as const };
}
return { label: '离线证据', color: 'grey' as const };
}
type VehicleServiceAction = {
key: string;
label: string;
description: string;
color: 'green' | 'orange' | 'red' | 'blue';
onClick: () => void;
};
async function copyVehicleServiceURL() {
try {
await navigator.clipboard.writeText(`${window.location.origin}${window.location.pathname}${window.location.hash}`);
Toast.success('已复制服务链接');
} catch {
Toast.error('复制服务链接失败');
}
}
export function VehicleDetail({
vin,
protocol,
onOpenRealtime,
onOpenHistory,
onOpenRaw,
onOpenMileage,
onOpenVehicles,
onOpenQuality,
onQueryChange
}: {
vin: string;
protocol?: string;
onOpenRealtime: (vin: string, protocol?: string) => void;
onOpenHistory: (vin: string, protocol?: string) => void;
onOpenRaw: (vin: string, protocol?: string) => void;
onOpenMileage: (vin: string, protocol?: string) => void;
onOpenVehicles?: (filters?: Record<string, string>) => void;
onOpenQuality?: (filters?: Record<string, string>) => void;
onQueryChange?: (keyword: string, protocol?: string) => void;
}) {
const [query, setQuery] = useState<VehicleQuery>({ keyword: vin, protocol });
const [detail, setDetail] = useState<VehicleDetailData | null>(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 consistencyTitle = consistency?.title ?? '-';
const consistencyDetail = consistency?.detail ?? '-';
const missingProtocols = consistency?.missingProtocols ?? [];
const missingProtocolText = missingProtocols.length > 0 ? (
<Space spacing={4}>
{missingProtocols.map((item) => (
<Button
key={item}
size="small"
theme="light"
type="warning"
onClick={() => onOpenVehicles?.({ serviceStatus: 'degraded', missingProtocol: item })}
>
{item}
</Button>
))}
</Space>
) : '-';
const evidenceSourceCount = overview?.sourceCount ?? consistencySourceCount;
const evidenceOnlineSourceCount = overview?.onlineSourceCount ?? consistencyOnlineSourceCount;
const evidenceLocatedSourceCount = consistencyLocatedSourceCount;
const evidenceMileageDelta = consistencyMileageDelta;
const evidenceTimeDeltaSeconds = consistencyTimeDeltaSeconds;
const activeProtocol = query.protocol?.trim() ?? '';
const scopeText = activeProtocol ? `单一来源:${activeProtocol}` : '全部来源聚合';
const archivePlate = resolution?.plate || summary?.plate || identity?.plate || latest?.plate || '';
const archivePhone = resolution?.phone || summary?.phone || identity?.phone || '';
const archiveOEM = resolution?.oem || summary?.oem || identity?.oem || '';
const archiveFields = [hasResolvedVIN && displayVIN !== '-', archivePlate, archivePhone, archiveOEM];
const archiveCompleteness = `${archiveFields.filter(Boolean).length}/${archiveFields.length}`;
const archiveMissingLabels = [
{ value: hasResolvedVIN && displayVIN !== '-' ? displayVIN : '', label: '缺VIN' },
{ value: archivePlate, label: '缺车牌' },
{ value: archivePhone, label: '缺手机号' },
{ value: archiveOEM, label: '缺OEM' }
]
.filter((item) => !String(item.value ?? '').trim())
.map((item) => item.label);
const archiveSourceCount = Math.max(protocols.length, sourceCount);
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 qualityFiltersForCurrentVehicle = () => ({
keyword: resolvedVIN,
...(activeProtocol ? { protocol: activeProtocol } : {})
});
const qualityIssueAction = (count: number) => {
if (count <= 0) {
return <Tag color="green"></Tag>;
}
return (
<Button
size="small"
theme="light"
type="warning"
onClick={() => onOpenQuality?.(qualityFiltersForCurrentVehicle())}
>
{count}
</Button>
);
};
const serviceActions = useMemo<VehicleServiceAction[]>(() => {
const actions: VehicleServiceAction[] = [];
if (!hasResolvedVIN) {
actions.push({
key: 'identity',
label: '维护身份绑定',
description: `关键词 ${displayLookupKey} 暂未解析到 VIN先补齐绑定后再看跨来源服务。`,
color: 'orange',
onClick: () => onOpenVehicles?.({ bindingStatus: 'unbound' })
});
}
for (const item of missingProtocols) {
actions.push({
key: `missing-${item}`,
label: `补齐 ${item} 来源`,
description: `${item} 当前没有形成有效来源证据,会影响这辆车的统一服务可信度。`,
color: 'orange',
onClick: () => onOpenVehicles?.({ serviceStatus: 'degraded', missingProtocol: item })
});
}
for (const source of detail?.sourceStatus ?? []) {
if (source.online) continue;
actions.push({
key: `offline-${source.protocol}`,
label: `排查 ${source.protocol} 离线`,
description: `${source.protocol} 最后上报时间:${source.lastSeen || '无上报'}`,
color: 'orange',
onClick: () => switchSource(source.protocol)
});
}
if (qualityCount > 0) {
actions.push({
key: 'quality',
label: `处理质量问题 ${qualityCount}`,
description: '质量问题会影响车辆服务的定位、里程和实时状态可信度。',
color: 'orange',
onClick: () => onOpenQuality?.(qualityFiltersForCurrentVehicle())
});
}
if (actions.length === 0 && hasResolvedVIN) {
actions.push({
key: 'healthy',
label: '查看实时服务',
description: '当前没有明确处置项,可直接查看该车统一实时状态。',
color: 'green',
onClick: () => onOpenRealtime(resolvedVIN, activeProtocol)
});
}
return actions;
}, [activeProtocol, detail?.sourceStatus, displayLookupKey, hasResolvedVIN, missingProtocols, onOpenQuality, onOpenRealtime, onOpenVehicles, qualityCount, resolvedVIN]);
const qualityTable = (
<Table
loading={loading}
pagination={false}
rowKey={(row?: QualityIssueRow) => `${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) => <Tag color={row.severity === 'error' ? 'red' : 'orange'}>{row.severity}</Tag> },
{ title: '最后时间', dataIndex: 'lastSeen', width: 190 },
{ title: '说明', dataIndex: 'detail' }
]}
/>
);
return (
<div className="vp-page">
<PageHeader title="车辆服务" description="以 VIN 为主对象聚合身份、实时、历史、RAW 和里程,协议仅作为数据来源" />
<Card bordered>
<Form key={formKey} initValues={query} layout="horizontal" onSubmit={(values) => {
const nextQuery = { keyword: String(values.keyword ?? ''), protocol: String(values.protocol ?? '') };
setQuery(nextQuery);
onQueryChange?.(nextQuery.keyword, nextQuery.protocol);
load(nextQuery);
}}>
<Form.Input field="keyword" label="车辆关键词" placeholder="VIN / 车牌 / 手机号" style={{ width: 280 }} />
<Form.Select field="protocol" label="数据来源" placeholder="全部来源" style={{ width: 180 }}>
<Select.Option value="GB32960">GB32960</Select.Option>
<Select.Option value="JT808">JT808</Select.Option>
<Select.Option value="YUTONG_MQTT">YUTONG_MQTT</Select.Option>
</Form.Select>
<Space>
<Button icon={<IconSearch />} htmlType="submit" theme="solid" type="primary"></Button>
<Button icon={<IconRefresh />} onClick={() => load(query)} loading={loading}></Button>
<Button icon={<IconCopy />} onClick={copyVehicleServiceURL}></Button>
<Button disabled={!hasResolvedVIN} onClick={() => onOpenRealtime(resolvedVIN, activeProtocol)}></Button>
<Button disabled={!hasResolvedVIN} onClick={() => onOpenHistory(resolvedVIN, activeProtocol)}></Button>
<Button disabled={!hasResolvedVIN} onClick={() => onOpenRaw(resolvedVIN, activeProtocol)}> RAW</Button>
<Button disabled={!hasResolvedVIN} onClick={() => onOpenMileage(resolvedVIN, activeProtocol)}></Button>
</Space>
</Form>
</Card>
<div className="vp-scope-bar">
<span className="vp-scope-label"></span>
<Tag color={activeProtocol ? 'blue' : 'green'}>{scopeText}</Tag>
<Typography.Text type="tertiary">
{activeProtocol ? '下方实时、历史、RAW 和里程仅展示该来源返回的数据。' : '下方数据按车辆聚合展示全部可用来源。'}
</Typography.Text>
</div>
{serviceStatus ? (
<div className={`vp-service-status vp-service-status-${serviceStatus.severity}`}>
<span className="vp-scope-label"></span>
<Tag color={serviceStatus.severity === 'ok' ? 'green' : serviceStatus.severity === 'error' ? 'red' : 'orange'}>
{serviceStatus.title}
</Tag>
<Typography.Text type="tertiary">{serviceStatus.detail}</Typography.Text>
</div>
) : null}
{overview ? (
<Card bordered title="车辆服务概览" style={{ marginTop: 16 }}>
<Descriptions
row
data={[
{ key: '服务车辆', value: [overview.plate, overview.vin].filter(Boolean).join(' / ') || '-' },
{ key: '在线来源', value: `${overview.onlineSourceCount}/${overview.sourceCount}` },
{ key: '覆盖状态', value: <Tag color={overview.coverageStatus === 'online' ? 'green' : overview.coverageStatus === 'partial' ? 'orange' : 'grey'}>{coverageStatusText[overview.coverageStatus] ?? overview.coverageStatus}</Tag> },
{ key: '主来源', value: overview.primaryProtocol || '-' },
{ key: '最后上报', value: overview.lastSeen || '-' },
{ key: '数据规模', value: `历史 ${overview.historyCount} / RAW ${overview.rawCount} / 里程 ${overview.mileageCount}` },
{ key: '实时来源', value: overview.realtimeCount },
{ key: '质量问题', value: qualityIssueAction(overview.qualityIssueCount) }
]}
/>
</Card>
) : null}
{overview || consistency ? (
<Card bordered title="车辆服务证据链" style={{ marginTop: 16 }}>
<div className="vp-evidence-grid">
{[
{ label: '来源证据', value: evidenceSourceCount > 0 ? `${evidenceOnlineSourceCount}/${evidenceSourceCount} 来源在线` : '-' },
{ label: '定位证据', value: evidenceLocatedSourceCount > 0 ? `${evidenceLocatedSourceCount} 个来源有位置` : '暂无位置' },
{ label: '里程差异', value: formatCompactNumber(evidenceMileageDelta, ' km') },
{ label: '时间差异', value: formatSeconds(evidenceTimeDeltaSeconds) },
{ label: '主来源', value: overview?.primaryProtocol || summary?.primaryProtocol || '-' },
{ label: '服务结论', value: consistencyTitle }
].map((item) => (
<div key={item.label} className="vp-evidence-item">
<div className="vp-evidence-label">{item.label}</div>
<div className="vp-evidence-value">{item.value}</div>
</div>
))}
</div>
<Typography.Text type="secondary">{consistencyDetail}</Typography.Text>
</Card>
) : null}
<Card bordered title="服务处置建议" style={{ marginTop: 16 }}>
<div className="vp-action-grid">
{serviceActions.map((item) => (
<div key={item.key} className="vp-action-item">
<div>
<Tag color={item.color}>{item.label}</Tag>
<Typography.Text type="secondary" style={{ display: 'block', marginTop: 8 }}>{item.description}</Typography.Text>
</div>
<Button size="small" theme="light" type={item.color === 'red' ? 'danger' : item.color === 'green' ? 'primary' : 'warning'} onClick={item.onClick}>
{item.label}
</Button>
</div>
))}
</div>
</Card>
<Card bordered title="车辆档案" style={{ marginTop: 16 }}>
<div className="vp-vehicle-summary">
<Descriptions
row
data={[
{ key: '车辆主键', value: displayVIN },
{ key: '查询关键词', value: displayLookupKey },
{ key: 'VIN', value: displayVIN },
{ key: '解析状态', value: <Tag color={hasResolvedVIN ? 'green' : 'orange'}>{hasResolvedVIN ? '已解析 VIN' : '未解析到 VIN'}</Tag> },
{ key: '车牌', value: archivePlate || '-' },
{ key: '手机号', value: archivePhone || '-' },
{ key: 'OEM', value: archiveOEM || '-' },
{
key: '档案完整度',
value: (
<Space spacing={4} wrap>
<Tag color={archiveCompleteness === '4/4' ? 'green' : 'orange'}>{archiveCompleteness}</Tag>
{archiveMissingLabels.map((label) => (
<Tag key={label} color="orange">{label}</Tag>
))}
</Space>
)
},
{ key: '归并来源数', value: archiveSourceCount },
{ key: '在线', value: <StatusTag status={online ? 'ok' : 'offline'} /> },
{
key: '数据来源',
value: protocols.length > 0 ? (
<SourceStatusTags sourceStatus={detail?.sourceStatus} protocols={protocols} lastSeen={lastSeen === '-' ? '' : lastSeen} />
) : '-'
},
{ key: '在线来源', value: summary ? `${summary.onlineSourceCount}/${summary.sourceCount}` : '-' },
{ key: '最后位置时间', value: lastSeen },
{ key: '最新 RAW 时间', value: latestRaw?.serverTime ?? '-' },
{ key: '质量问题', value: <Tag color={qualityCount > 0 ? 'orange' : 'green'}>{qualityCount > 0 ? `${qualityCount}` : '无'}</Tag> }
]}
/>
</div>
{!hasResolvedVIN ? (
<Banner
type="warning"
bordered
title="身份绑定待处理"
description={`车辆关键词 ${displayLookupKey} 暂未解析到 VIN请维护车辆身份绑定后再查看历史、里程和跨来源数据。`}
style={{ marginTop: 12 }}
/>
) : null}
</Card>
{hasResolvedVIN ? (
<>
<Card bordered title="数据来源覆盖" style={{ marginTop: 16 }}>
{detail?.sourceStatus?.length ? (
<>
<div className="vp-source-toolbar">
<Space>
<Tag color={activeProtocol ? 'grey' : 'blue'}>{activeProtocol ? `当前 ${activeProtocol}` : '当前 全部来源'}</Tag>
<SourceStatusTags sourceStatus={detail.sourceStatus} protocols={protocols} lastSeen={lastSeen === '-' ? '' : lastSeen} />
<Button size="small" disabled={!activeProtocol} onClick={() => switchSource('')}></Button>
</Space>
</div>
<Table
loading={loading}
pagination={false}
rowKey="protocol"
dataSource={detail.sourceStatus}
columns={[
{
title: '来源',
dataIndex: 'protocol',
width: 140,
render: (_: unknown, row: VehicleSourceStatus) => (
<Tag color={row.protocol === summary?.primaryProtocol ? 'blue' : 'grey'}>{row.protocol}</Tag>
)
},
{
title: '车辆服务角色',
width: 130,
render: (_: unknown, row: VehicleSourceStatus) => {
const role = sourceServiceRole(row, summary?.primaryProtocol);
return <Tag color={role.color}>{role.label}</Tag>;
}
},
{ title: '在线', width: 100, render: (_: unknown, row: VehicleSourceStatus) => <StatusTag status={row.online ? 'ok' : 'offline'} /> },
{ title: '最后时间', dataIndex: 'lastSeen', width: 190, render: (value?: string) => value || '无上报' },
...sourceCapabilities.map((item) => ({
title: item.label,
width: 90,
render: (_: unknown, row: VehicleSourceStatus) => <Tag color={row[item.key] ? 'green' : 'grey'}>{row[item.key] ? '有' : '无'}</Tag>
})),
{
title: '操作',
width: 330,
render: (_: unknown, row: VehicleSourceStatus) => (
<Space spacing={6} wrap>
<Button
size="small"
theme={activeProtocol === row.protocol ? 'solid' : 'light'}
type={activeProtocol === row.protocol ? 'primary' : 'tertiary'}
disabled={activeProtocol === row.protocol}
onClick={() => switchSource(row.protocol)}
>
{row.protocol}
</Button>
<Button
size="small"
theme="light"
type="tertiary"
disabled={!row.hasHistory}
onClick={() => onOpenHistory(resolvedVIN, row.protocol)}
>
{row.protocol}
</Button>
<Button
size="small"
theme="light"
type="tertiary"
disabled={!row.hasRaw}
onClick={() => onOpenRaw(resolvedVIN, row.protocol)}
>
{row.protocol} RAW
</Button>
<Button
size="small"
theme="light"
type="tertiary"
disabled={!row.hasMileage}
onClick={() => onOpenMileage(resolvedVIN, row.protocol)}
>
{row.protocol}
</Button>
</Space>
)
}
]}
/>
</>
) : (
<Typography.Text type="secondary"></Typography.Text>
)}
</Card>
<Card bordered title="跨来源一致性" style={{ marginTop: 16 }}>
<Descriptions
row
data={[
{ key: '一致性结论', value: consistencyTitle },
{ key: '来源覆盖', value: consistencySourceCount > 0 ? `${consistencySourceCount} 个来源` : '-' },
{ key: '在线来源', value: consistencySourceCount > 0 ? `${consistencyOnlineSourceCount}/${consistencySourceCount} 在线` : '-' },
{ key: '缺失来源', value: missingProtocolText },
{ key: '位置覆盖', value: consistencyLocatedSourceCount > 0 ? `${consistencyLocatedSourceCount} 个来源有位置` : '暂无位置' },
{ key: '里程差异', value: formatCompactNumber(consistencyMileageDelta, ' km') },
{ key: '来源时间差', value: formatSeconds(consistencyTimeDeltaSeconds) },
{ key: '诊断说明', value: consistencyDetail },
{ key: '车辆服务视角', value: activeProtocol ? `当前只看 ${activeProtocol}` : '三类来源合并为同一车辆服务' }
]}
/>
</Card>
</>
) : null}
{!hasResolvedVIN ? (
<Card bordered style={{ marginTop: 16 }}>
<Tabs>
<Tabs.TabPane tab="质量问题" itemKey="quality">
{qualityTable}
</Tabs.TabPane>
</Tabs>
</Card>
) : (
<Card bordered style={{ marginTop: 16 }}>
<Tabs>
<Tabs.TabPane tab="实时状态" itemKey="realtime">
<div className="vp-realtime-strip">
{[
{ 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) => (
<div key={item.label} className="vp-realtime-strip-item">
<div className="vp-realtime-strip-label">{item.label}</div>
<div className="vp-realtime-strip-value">{item.value}</div>
</div>
))}
</div>
<Table
loading={loading}
pagination={false}
rowKey="protocol"
dataSource={detail?.realtime ?? []}
columns={[
{ title: '来源', dataIndex: 'protocol', width: 130 },
{ title: '经度', dataIndex: 'longitude', width: 120 },
{ title: '纬度', dataIndex: 'latitude', width: 120 },
{ title: '速度 km/h', dataIndex: 'speedKmh', width: 120 },
{ title: 'SOC %', dataIndex: 'socPercent', width: 120 },
{ title: '总里程 km', dataIndex: 'totalMileageKm', width: 130 },
{ title: '最后时间', dataIndex: 'lastSeen', width: 190 }
]}
/>
</Tabs.TabPane>
<Tabs.TabPane tab="历史位置" itemKey="history">
<Table
loading={loading}
pagination={false}
rowKey="deviceTime"
dataSource={detail?.history?.items ?? []}
columns={[
{ title: '来源', dataIndex: 'protocol', width: 130 },
{ title: '经度', dataIndex: 'longitude', width: 120 },
{ title: '纬度', dataIndex: 'latitude', width: 120 },
{ title: '速度 km/h', dataIndex: 'speedKmh', width: 120 },
{ title: '总里程 km', dataIndex: 'totalMileageKm', width: 130 },
{ title: '设备时间', dataIndex: 'deviceTime', width: 190 },
{ title: '入库时间', dataIndex: 'serverTime', width: 190 }
]}
/>
</Tabs.TabPane>
<Tabs.TabPane tab="RAW 字段" itemKey="raw">
<Table
loading={loading}
pagination={false}
rowKey="id"
dataSource={detail?.raw?.items ?? []}
columns={[
{ title: '来源', dataIndex: 'protocol', width: 130 },
{ title: '帧类型', dataIndex: 'frameType', width: 190 },
{ title: '大小 B', dataIndex: 'rawSizeBytes', width: 100 },
{ title: '设备时间', dataIndex: 'deviceTime', width: 190 },
{ title: '入库时间', dataIndex: 'serverTime', width: 190 }
]}
/>
<Typography.Text type="tertiary"> RAW </Typography.Text>
<pre className="vp-json">{JSON.stringify(latestRaw?.parsedFields ?? {}, null, 2)}</pre>
</Tabs.TabPane>
<Tabs.TabPane tab="里程" itemKey="mileage">
<Table
loading={loading}
pagination={false}
rowKey="date"
dataSource={detail?.mileage?.items ?? []}
columns={[
{ title: '日期', dataIndex: 'date', width: 130 },
{ title: '来源', dataIndex: 'source', width: 130 },
{ title: '起始里程', dataIndex: 'startMileageKm', width: 130 },
{ title: '结束里程', dataIndex: 'endMileageKm', width: 130 },
{ title: '日里程', dataIndex: 'dailyMileageKm', width: 130 }
]}
/>
</Tabs.TabPane>
<Tabs.TabPane tab="质量问题" itemKey="quality">
{qualityTable}
</Tabs.TabPane>
</Tabs>
</Card>
)}
</div>
);
}