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">
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user