feat(platform): expose multi-source vehicle evidence

This commit is contained in:
lingniu
2026-07-16 16:35:04 +08:00
parent 196cfa018f
commit 17c4591040
21 changed files with 1129 additions and 11 deletions

View File

@@ -0,0 +1,58 @@
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
import { afterEach, expect, test, vi } from 'vitest';
import { api } from '../../api/client';
import type { VehicleSourceEvidence } from '../../api/types';
import { VehicleSourceEvidencePanel } from './VehicleSourceEvidencePanel';
const evidence: VehicleSourceEvidence = {
vin: 'VIN-001',
plate: '粤A12345',
mileageDate: '2026-07-16',
recommendedLocationProtocol: 'JT808',
recommendedLocationLabel: 'G7',
locationConflict: true,
conflictDistanceM: 328,
locationSources: [
{
protocol: 'JT808', sourceLabel: 'G7', terminalLabel: '终端 133****5425', sourceKind: 'PLATFORM',
selectedWithinProtocol: true, recommended: true, enabled: true, priority: 20, online: true,
qualityStatus: 'OK', qualityReason: '', longitude: 113.26, latitude: 23.13, speedKmh: 20,
totalMileageKm: 1000, eventTime: '2026-07-16 10:00:00', receivedAt: '2026-07-16 10:00:01'
},
{
protocol: 'JT808', sourceLabel: '北斗平台', terminalLabel: '终端 139****1208', sourceKind: 'PLATFORM',
selectedWithinProtocol: false, recommended: false, enabled: true, priority: 30, online: true,
qualityStatus: 'OK', qualityReason: '', longitude: 113.27, latitude: 23.14, speedKmh: 18,
totalMileageKm: 998, eventTime: '2026-07-16 09:59:55', receivedAt: '2026-07-16 09:59:57'
}
],
mileageSources: [{
protocol: 'JT808', sourceLabel: 'G7', terminalLabel: '终端 133****5425', sourceKind: 'PLATFORM',
selectedWithinProtocol: true, recommended: true, enabled: true, priority: 20, qualityStatus: 'OK',
qualityReason: '', firstTotalMileageKm: 990, latestTotalMileageKm: 1000, dailyMileageKm: 10,
sampleCount: 100, firstEventTime: '2026-07-16 00:00:00', latestEventTime: '2026-07-16 10:00:00'
}],
comparison: { locationMaxDistanceM: 328, totalMileageDeltaKm: 2, dailyMileageDeltaKm: 0, reportTimeDeltaSeconds: 6 },
asOf: '2026-07-16T10:00:02+08:00'
};
afterEach(() => {
cleanup();
vi.restoreAllMocks();
});
test('loads all source evidence only after the user expands it', async () => {
const sourceEvidence = vi.spyOn(api, 'vehicleSourceEvidence').mockResolvedValue(evidence);
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
render(<QueryClientProvider client={client}><VehicleSourceEvidencePanel vin="VIN-001" /></QueryClientProvider>);
expect(sourceEvidence).not.toHaveBeenCalled();
fireEvent.click(screen.getByRole('button', { name: '查看全部来源' }));
await waitFor(() => expect(sourceEvidence).toHaveBeenCalledTimes(1));
expect(await screen.findByText('北斗平台')).toBeInTheDocument();
expect(screen.getAllByText('当前推荐').length).toBeGreaterThan(0);
expect(screen.queryByText('13307795425')).not.toBeInTheDocument();
client.clear();
});

View File

@@ -0,0 +1,128 @@
import { useQuery } from '@tanstack/react-query';
import { useMemo, useState } from 'react';
import { api } from '../../api/client';
import type { VehicleLocationSourceEvidence, VehicleMileageSourceEvidence } from '../../api/types';
import { QUERY_MEMORY } from '../queryPolicy';
function today() {
const now = new Date();
const offset = now.getTimezoneOffset() * 60_000;
return new Date(now.getTime() - offset).toISOString().slice(0, 10);
}
function number(value?: number, digits = 1) {
return value == null || !Number.isFinite(value) ? '—' : value.toLocaleString('zh-CN', { minimumFractionDigits: digits, maximumFractionDigits: digits });
}
function coordinate(source: VehicleLocationSourceEvidence) {
if (source.longitude == null || source.latitude == null) return '—';
return `${source.longitude.toFixed(6)}, ${source.latitude.toFixed(6)}`;
}
function evidenceTone(source: Pick<VehicleLocationSourceEvidence, 'recommended' | 'enabled' | 'online' | 'qualityStatus'>) {
if (!source.enabled) return 'disabled';
if (source.qualityStatus !== 'OK') return 'warning';
if (source.recommended) return 'recommended';
return source.online ? 'ready' : 'offline';
}
function sourceBadges(source: Pick<VehicleLocationSourceEvidence, 'recommended' | 'selectedWithinProtocol' | 'enabled' | 'online' | 'qualityStatus'>) {
return <>
{source.recommended ? <b className="is-recommended"></b> : null}
{!source.recommended && source.selectedWithinProtocol ? <b></b> : null}
{!source.enabled ? <b className="is-disabled"></b> : null}
{source.enabled && !source.online ? <b className="is-offline">线</b> : null}
{source.qualityStatus !== 'OK' ? <b className="is-warning">{source.qualityStatus}</b> : null}
</>;
}
function LocationSourceCard({ source }: { source: VehicleLocationSourceEvidence }) {
return <article className={`v2-source-evidence-card is-${evidenceTone(source)}`}>
<header><div><strong>{source.sourceLabel || source.protocol}</strong><span>{source.protocol}{source.terminalLabel ? ` · ${source.terminalLabel}` : ''}</span></div><aside>{sourceBadges(source)}</aside></header>
<dl>
<div><dt></dt><dd>{coordinate(source)}</dd></div>
<div><dt></dt><dd>{source.speedKmh == null ? '—' : `${number(source.speedKmh)} km/h`}</dd></div>
<div><dt></dt><dd>{source.totalMileageKm == null ? '—' : `${number(source.totalMileageKm)} km`}</dd></div>
<div><dt>SOC</dt><dd>{source.socPercent == null ? '—' : `${number(source.socPercent)}%`}</dd></div>
<div><dt></dt><dd>{source.eventTime || '—'}</dd></div>
<div><dt></dt><dd>{source.receivedAt || '—'}</dd></div>
</dl>
{source.qualityReason ? <p>{source.qualityReason}</p> : null}
</article>;
}
function MileageSourceCard({ source }: { source: VehicleMileageSourceEvidence }) {
const comparable = { ...source, online: true };
return <article className={`v2-source-evidence-card is-${evidenceTone(comparable)}`}>
<header><div><strong>{source.sourceLabel || source.protocol}</strong><span>{source.protocol}{source.terminalLabel ? ` · ${source.terminalLabel}` : ''}</span></div><aside>{sourceBadges(comparable)}</aside></header>
<dl>
<div><dt></dt><dd>{source.dailyMileageKm == null ? '—' : `${number(source.dailyMileageKm)} km`}</dd></div>
<div><dt></dt><dd>{source.latestTotalMileageKm == null ? '—' : `${number(source.latestTotalMileageKm)} km`}</dd></div>
<div><dt></dt><dd>{source.firstTotalMileageKm == null ? '—' : `${number(source.firstTotalMileageKm)} km`}</dd></div>
<div><dt></dt><dd>{source.sampleCount.toLocaleString('zh-CN')}</dd></div>
<div><dt></dt><dd>{source.firstEventTime || '—'}</dd></div>
<div><dt></dt><dd>{source.latestEventTime || '—'}</dd></div>
</dl>
{source.qualityReason ? <p>{source.qualityReason}</p> : null}
</article>;
}
export function VehicleSourceEvidencePanel({
vin,
compact = false,
open,
onOpenChange
}: {
vin: string;
compact?: boolean;
open?: boolean;
onOpenChange?: (open: boolean) => void;
}) {
const [internalOpen, setInternalOpen] = useState(false);
const expanded = open ?? internalOpen;
const setExpanded = (value: boolean) => {
if (open == null) setInternalOpen(value);
onOpenChange?.(value);
};
const [date, setDate] = useState(today);
const query = useQuery({
queryKey: ['vehicle-source-evidence', vin, date],
queryFn: ({ signal }) => api.vehicleSourceEvidence(vin, date, signal),
enabled: expanded && Boolean(vin),
staleTime: 15_000,
gcTime: QUERY_MEMORY.summaryGcTime,
refetchOnWindowFocus: false
});
const sourceCount = (query.data?.locationSources.length ?? 0) + (query.data?.mileageSources.length ?? 0);
const description = useMemo(() => {
if (!query.data) return '按需读取,不影响车辆列表和地图刷新性能';
if (sourceCount <= 2) return '当前车辆来源较少,仅展示实际存在的证据';
return `已读取 ${query.data.locationSources.length} 个位置来源、${query.data.mileageSources.length} 个里程来源`;
}, [query.data, sourceCount]);
return <section className={`v2-source-evidence${compact ? ' is-compact' : ''}${expanded ? ' is-open' : ''}`}>
<header className="v2-source-evidence-trigger">
<div><strong></strong><span>{description}</span></div>
<button type="button" aria-expanded={expanded} onClick={() => setExpanded(!expanded)}>{expanded ? '收起来源' : '查看全部来源'}</button>
</header>
{expanded ? <div className="v2-source-evidence-body">
<div className="v2-source-evidence-toolbar">
<label><span></span><input type="date" value={date} max={today()} onChange={(event) => setDate(event.target.value)} /></label>
<small></small>
</div>
{query.isPending ? <div className="v2-source-evidence-state"><i /></div> : null}
{query.isError ? <div className="v2-source-evidence-state is-error"><span>{query.error instanceof Error ? query.error.message : '来源证据读取失败'}</span><button type="button" onClick={() => void query.refetch()}></button></div> : null}
{query.data ? <>
<div className="v2-source-evidence-summary">
<div><small></small><strong>{query.data.recommendedLocationLabel || query.data.recommendedLocationProtocol || '—'}</strong></div>
<div><small></small><strong>{number(query.data.comparison.locationMaxDistanceM)}<em>m</em></strong></div>
<div><small></small><strong>{number(query.data.comparison.totalMileageDeltaKm)}<em>km</em></strong></div>
<div><small></small><strong>{number(query.data.comparison.reportTimeDeltaSeconds, 0)}<em>s</em></strong></div>
</div>
{query.data.locationSources.length ? <section className="v2-source-evidence-group"><header><strong></strong><span>{query.data.locationConflict ? `后台检测到位置冲突${query.data.conflictDistanceM == null ? '' : ` · ${number(query.data.conflictDistanceM)} m`}` : '推荐来源与备用来源并列展示'}</span></header><div>{query.data.locationSources.map((source, index) => <LocationSourceCard key={`${source.protocol}-${source.sourceLabel}-${source.terminalLabel}-${index}`} source={source} />)}</div></section> : null}
{query.data.mileageSources.length ? <section className="v2-source-evidence-group"><header><strong>{query.data.mileageDate} </strong><span> {number(query.data.comparison.dailyMileageDeltaKm)} km</span></header><div>{query.data.mileageSources.map((source, index) => <MileageSourceCard key={`${source.protocol}-${source.sourceLabel}-${source.terminalLabel}-${index}`} source={source} />)}</div></section> : null}
{!query.data.locationSources.length && !query.data.mileageSources.length ? <div className="v2-source-evidence-state"></div> : null}
</> : null}
</div> : null}
</section>;
}