feat(platform): add vehicle source fusion matrix
This commit is contained in:
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user