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">

View File

@@ -1548,6 +1548,69 @@ button.vp-realtime-command-item:focus-visible {
gap: 16px;
}
.vp-source-fusion-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 12px;
}
.vp-source-fusion-item {
min-height: 250px;
padding: 14px;
border: 1px solid var(--vp-border);
border-radius: var(--vp-radius);
background: #fbfcff;
display: grid;
gap: 12px;
align-content: space-between;
}
.vp-source-fusion-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 10px;
}
.vp-source-fusion-contribution {
min-height: 52px;
display: flex;
flex-wrap: wrap;
align-content: flex-start;
gap: 6px;
}
.vp-source-fusion-facts {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px;
}
.vp-source-fusion-fact {
min-height: 64px;
padding: 9px 10px;
border: 1px solid var(--vp-border);
border-radius: 6px;
background: #fff;
display: grid;
gap: 5px;
align-content: start;
}
.vp-source-fusion-fact span {
color: var(--vp-text-muted);
font-size: 12px;
line-height: 18px;
}
.vp-source-fusion-fact strong {
color: var(--vp-text);
font-size: 13px;
line-height: 19px;
font-weight: 600;
word-break: break-word;
}
.vp-vehicle-detail-map {
height: 300px;
}
@@ -1647,6 +1710,8 @@ button.vp-realtime-command-item:focus-visible {
.vp-bi-publish-board,
.vp-bi-publish-grid,
.vp-trajectory-anomaly-grid,
.vp-source-fusion-grid,
.vp-source-fusion-facts,
.vp-vehicle-map-layout,
.vp-playback-layout,
.vp-playback-timeline {

View File

@@ -10100,6 +10100,11 @@ test('filters vehicle list by service status', async () => {
test('switches vehicle detail to a source from the source matrix', async () => {
window.history.replaceState(null, '', '/#/detail?keyword=VIN001');
const writeText = vi.fn(() => Promise.resolve());
Object.defineProperty(navigator, 'clipboard', {
configurable: true,
value: { writeText }
});
const fetchMock = vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => {
const path = String(input);
if (path.includes('/api/vehicle-service')) {
@@ -10194,10 +10199,20 @@ test('switches vehicle detail to a source from the source matrix', async () => {
expect(screen.getAllByText('GB32960 在线').length).toBeGreaterThanOrEqual(1);
expect(screen.getAllByText('JT808 在线').length).toBeGreaterThanOrEqual(1);
expect(screen.getAllByText('YUTONG_MQTT 未接入').length).toBeGreaterThanOrEqual(1);
expect(screen.getByText('多源归并矩阵')).toBeInTheDocument();
expect(screen.getByText('主实时来源')).toBeInTheDocument();
expect(screen.getAllByText('定位证据').length).toBeGreaterThanOrEqual(1);
expect(screen.getAllByText('统计口径').length).toBeGreaterThanOrEqual(1);
expect(screen.getByText('无实时字段')).toBeInTheDocument();
expect(screen.getByText('无 RAW 证据')).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: '复制归并矩阵' }));
expect(writeText).toHaveBeenCalledWith(expect.stringContaining('【单车多源归并矩阵】'));
expect(writeText).toHaveBeenCalledWith(expect.stringContaining('1. GB32960 / 主来源'));
expect(writeText).toHaveBeenCalledWith(expect.stringContaining('贡献主实时来源、在线心跳、实时字段、定位证据、轨迹回放、RAW证据、统计口径'));
expect(screen.getByText('车辆服务角色')).toBeInTheDocument();
expect(screen.getByText('主来源')).toBeInTheDocument();
expect(screen.getByText('在线证据')).toBeInTheDocument();
expect(screen.getByText('暂无来源')).toBeInTheDocument();
expect(screen.getAllByText('主来源').length).toBeGreaterThanOrEqual(1);
expect(screen.getAllByText('在线证据').length).toBeGreaterThanOrEqual(1);
expect(screen.getAllByText('暂无来源').length).toBeGreaterThanOrEqual(1);
expect(screen.getByText('最新位置')).toBeInTheDocument();
expect(screen.getAllByText('113.2, 23.1').length).toBeGreaterThan(0);
expect(screen.getAllByText(/100\s*km/).length).toBeGreaterThan(0);