feat(platform): add vehicle source fusion matrix

This commit is contained in:
lingniu
2026-07-04 21:48:51 +08:00
parent f0f7bc546e
commit e6d639572d
3 changed files with 211 additions and 3 deletions

View File

@@ -66,6 +66,31 @@ function sourceServiceRole(source: VehicleSourceStatus, primaryProtocol?: string
return { label: '离线证据', color: 'grey' as const };
}
function sourceFusionRow(source: VehicleSourceStatus, realtime: RealtimeLocationRow | undefined, primaryProtocol?: string): SourceFusionRow {
const hasLocation = !!realtime && isFiniteNumber(realtime.longitude) && isFiniteNumber(realtime.latitude) && realtime.longitude !== 0 && realtime.latitude !== 0;
const hasMileage = isFiniteNumber(realtime?.totalMileageKm) || source.hasMileage;
const contribution = [
source.protocol === primaryProtocol ? '主实时来源' : '',
source.online ? '在线心跳' : '',
source.hasRealtime || realtime ? '实时字段' : '',
hasLocation ? '定位证据' : '',
source.hasHistory ? '轨迹回放' : '',
source.hasRaw ? 'RAW证据' : '',
hasMileage ? '统计口径' : ''
].filter(Boolean);
return {
source,
realtime,
role: sourceServiceRole(source, primaryProtocol),
freshness: source.online ? `在线 / ${source.lastSeen || realtime?.lastSeen || '-'}` : source.lastSeen ? `离线 / ${source.lastSeen}` : '未接入',
realtimeText: source.hasRealtime || realtime ? `速度 ${formatCompactNumber(realtime?.speedKmh, ' km/h')} / SOC ${formatCompactNumber(realtime?.socPercent, '%')}` : '无实时字段',
locationText: hasLocation ? `${realtime?.longitude}, ${realtime?.latitude}` : '无有效坐标',
rawText: source.hasRaw ? '可查 RAW' : '无 RAW 证据',
mileageText: hasMileage ? formatCompactNumber(realtime?.totalMileageKm, ' km') : '无里程证据',
contribution: contribution.length > 0 ? contribution : ['待补来源']
};
}
type VehicleServiceAction = {
key: string;
label: string;
@@ -74,6 +99,18 @@ type VehicleServiceAction = {
onClick: () => void;
};
type SourceFusionRow = {
source: VehicleSourceStatus;
realtime?: RealtimeLocationRow;
role: { label: string; color: 'green' | 'orange' | 'red' | 'blue' | 'grey' };
freshness: string;
realtimeText: string;
locationText: string;
rawText: string;
mileageText: string;
contribution: string[];
};
type VehicleReportRow = {
section: string;
item: string;
@@ -243,6 +280,9 @@ export function VehicleDetail({
}
return next;
}, [realtimeRows]);
const sourceFusionRows = useMemo(() => (
(detail?.sourceStatus ?? []).map((source) => sourceFusionRow(source, latestRealtimeByProtocol.get(source.protocol), summary?.primaryProtocol))
), [detail?.sourceStatus, latestRealtimeByProtocol, summary?.primaryProtocol]);
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
@@ -385,6 +425,38 @@ export function VehicleDetail({
];
copyText(lines.join('\n'), '诊断摘要');
};
const copySourceFusionReport = () => {
if (!detail || sourceFusionRows.length === 0) {
Toast.warning('当前没有可复制的多源归并矩阵');
return;
}
const vehicleName = [archivePlate, displayVIN].filter((item) => item && item !== '-').join(' / ') || displayLookupKey;
const lines = [
'【单车多源归并矩阵】',
`车辆:${vehicleName}`,
`当前范围:${scopeText}`,
`主来源:${overview?.primaryProtocol || summary?.primaryProtocol || '-'}`,
`来源覆盖:${evidenceSourceCount > 0 ? `${evidenceOnlineSourceCount}/${evidenceSourceCount} 在线` : '-'}`,
`一致性:${consistencyTitle}`,
'',
...sourceFusionRows.map((row, index) => [
`${index + 1}. ${row.source.protocol} / ${row.role.label}`,
` 新鲜度:${row.freshness}`,
` 贡献:${row.contribution.join('、')}`,
` 实时:${row.realtimeText}`,
` 定位:${row.locationText}`,
` RAW${row.rawText}`,
` 里程:${row.mileageText}`
].join('\n')),
'',
`车辆服务:${vehicleServiceURL(vehicleServiceHash('detail'))}`,
`实时监控:${vehicleServiceURL(vehicleServiceHash('realtime'))}`,
`轨迹回放:${vehicleServiceURL(vehicleServiceHash('history'))}`,
`RAW证据${vehicleServiceURL(vehicleServiceHash('history-query', { tab: 'raw', includeFields: 'true' }))}`,
`里程统计:${vehicleServiceURL(vehicleServiceHash('mileage'))}`
];
copyText(lines.join('\n'), '多源归并矩阵');
};
const qualityIssueAction = (count: number) => {
if (count <= 0) {
return <Tag color="green"></Tag>;
@@ -818,6 +890,62 @@ export function VehicleDetail({
</Card>
) : null}
{hasResolvedVIN && sourceFusionRows.length > 0 ? (
<Card
bordered
title={<Space><span></span><Button size="small" aria-label="复制归并矩阵" icon={<IconCopy />} onClick={copySourceFusionReport}></Button></Space>}
style={{ marginTop: 16 }}
>
<div className="vp-source-fusion-grid">
{sourceFusionRows.map((row) => (
<div key={row.source.protocol} className="vp-source-fusion-item">
<div className="vp-source-fusion-head">
<Space spacing={6} wrap>
<Tag color={row.role.color}>{row.source.protocol}</Tag>
<Tag color={row.source.online ? 'green' : row.source.lastSeen ? 'orange' : 'grey'}>{row.freshness}</Tag>
</Space>
<Tag color={row.role.color}>{row.role.label}</Tag>
</div>
<div className="vp-source-fusion-contribution">
{row.contribution.map((item) => (
<Tag key={item} color={item === '待补来源' ? 'orange' : 'blue'}>{item}</Tag>
))}
</div>
<div className="vp-source-fusion-facts">
{[
{ label: '实时', value: row.realtimeText },
{ label: '定位', value: row.locationText },
{ label: 'RAW', value: row.rawText },
{ label: '里程', value: row.mileageText }
].map((item) => (
<div key={item.label} className="vp-source-fusion-fact">
<span>{item.label}</span>
<strong>{item.value}</strong>
</div>
))}
</div>
<Space wrap>
<Button
size="small"
theme={activeProtocol === row.source.protocol ? 'solid' : 'light'}
type={activeProtocol === row.source.protocol ? 'primary' : 'tertiary'}
disabled={activeProtocol === row.source.protocol}
aria-label={`归并矩阵仅看 ${row.source.protocol}`}
onClick={() => switchSource(row.source.protocol)}
>
</Button>
<Button size="small" theme="light" disabled={!row.source.hasRealtime} onClick={() => onOpenRealtime(resolvedVIN, row.source.protocol)}></Button>
<Button size="small" theme="light" disabled={!row.source.hasHistory} onClick={() => onOpenHistory(resolvedVIN, row.source.protocol)}></Button>
<Button size="small" theme="light" disabled={!row.source.hasRaw} onClick={() => onOpenRaw(resolvedVIN, row.source.protocol)}>RAW</Button>
<Button size="small" theme="light" disabled={!row.source.hasMileage} onClick={() => onOpenMileage(resolvedVIN, row.source.protocol)}></Button>
</Space>
</div>
))}
</div>
</Card>
) : null}
{hasResolvedVIN && vehicleMapPoints.length > 0 ? (
<Card bordered title="车辆位置态势" style={{ marginTop: 16 }}>
<div className="vp-vehicle-map-layout">