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

@@ -56,6 +56,7 @@ import type {
VehicleIdentityResolution,
VehicleServiceOverview,
VehicleServiceSummary,
VehicleSourceEvidence,
VehicleRow
} from './types';
import { getAccessToken, notifyUnauthorizedSession } from '../v2/auth/session';
@@ -242,6 +243,12 @@ export const api = {
vehicleDetail: (params = new URLSearchParams(), signal?: AbortSignal) => request<VehicleDetail>(`/api/vehicle-service?${params.toString()}`, withSignal(undefined, signal)),
vehicleProfile: (vin: string) => request<VehicleProfile>(`/api/v2/vehicles/${encodeURIComponent(vin)}/profile`),
latestTelemetry: (vin: string, signal?: AbortSignal) => request<LatestTelemetryResponse>(`/api/v2/vehicles/${encodeURIComponent(vin)}/telemetry/latest`, withSignal(undefined, signal)),
vehicleSourceEvidence: (vin: string, date?: string, signal?: AbortSignal) => {
const params = new URLSearchParams();
if (date) params.set('date', date);
const suffix = params.toString() ? `?${params.toString()}` : '';
return request<VehicleSourceEvidence>(`/api/v2/vehicles/${encodeURIComponent(vin)}/source-evidence${suffix}`, withSignal(undefined, signal));
},
updateVehicleProfile: (vin: string, input: VehicleProfileInput) => request<VehicleProfile>(`/api/v2/vehicles/${encodeURIComponent(vin)}/profile`, {
method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(input)
}),

View File

@@ -502,6 +502,67 @@ export interface VehicleSourceConsistency {
detail: string;
}
export interface VehicleSourceEvidence {
vin: string;
plate: string;
mileageDate: string;
recommendedLocationProtocol: string;
recommendedLocationLabel: string;
locationConflict: boolean;
conflictDistanceM?: number;
locationSources: VehicleLocationSourceEvidence[];
mileageSources: VehicleMileageSourceEvidence[];
comparison: VehicleSourceEvidenceComparison;
asOf: string;
}
export interface VehicleLocationSourceEvidence {
protocol: string;
sourceLabel: string;
terminalLabel: string;
sourceKind: string;
selectedWithinProtocol: boolean;
recommended: boolean;
enabled: boolean;
priority: number;
online: boolean;
qualityStatus: string;
qualityReason: string;
longitude?: number;
latitude?: number;
speedKmh?: number;
totalMileageKm?: number;
socPercent?: number;
eventTime: string;
receivedAt: string;
}
export interface VehicleMileageSourceEvidence {
protocol: string;
sourceLabel: string;
terminalLabel: string;
sourceKind: string;
selectedWithinProtocol: boolean;
recommended: boolean;
enabled: boolean;
priority: number;
qualityStatus: string;
qualityReason: string;
firstTotalMileageKm?: number;
latestTotalMileageKm?: number;
dailyMileageKm?: number;
sampleCount: number;
firstEventTime: string;
latestEventTime: string;
}
export interface VehicleSourceEvidenceComparison {
locationMaxDistanceM: number;
totalMileageDeltaKm: number;
dailyMileageDeltaKm: number;
reportTimeDeltaSeconds: number;
}
export interface VehicleServiceOverview {
vin: string;
plate: string;

View File

@@ -6,6 +6,7 @@ import { api } from '../../api/client';
import type { Page, VehicleRealtimeRow } from '../../api/types';
import { FleetMap } from '../map/FleetMap';
import { EmptyState, InlineError } from '../shared/AsyncState';
import { VehicleSourceEvidencePanel } from '../shared/VehicleSourceEvidencePanel';
import { formatNumber, relativeFreshness, statusLabel, vehicleStatus } from '../domain/monitor';
import { MAX_MONITOR_SEARCH_TERMS, MONITOR_REFRESH, monitorFilterScope, monitorQueryParams, parseMonitorSearchTerms, useMonitorData, useMonitorVehicleCard, type MonitorViewport } from '../hooks/useMonitorData';
import { LIVE_QUERY_POLICY, QUERY_MEMORY, retainPreviousPageWithinScope } from '../queryPolicy';
@@ -184,6 +185,7 @@ function VehicleDetailCard({
onCollapse: () => void;
onClear: () => void;
}) {
const [sourceEvidenceOpen, setSourceEvidenceOpen] = useState(false);
const card = useMonitorVehicleCard(vehicle.vin, vehicle, true);
const detail = card.detail.data;
const activeAlerts = card.activeAlerts.data;
@@ -222,8 +224,8 @@ function VehicleDetailCard({
<div className="v2-metric-grid">
<div><small></small><strong>{hasRealtimeSpeed(vehicle) ? formatNumber(vehicle.speedKmh, 1) : '—'}<em>{hasRealtimeSpeed(vehicle) ? 'km/h' : ''}</em></strong></div>
<div><small>SOC</small><strong>{hasRealtimeSOC(vehicle) ? formatNumber(vehicle.socPercent, 1) : '—'}<em>{hasRealtimeSOC(vehicle) ? '%' : ''}</em></strong></div>
<div><small></small><strong>{hasRealtimeMileage(vehicle) ? formatNumber(vehicle.totalMileageKm, 1) : '—'}<em>{hasRealtimeMileage(vehicle) ? 'km' : ''}</em></strong></div>
<div><small></small><strong>{dailyMileage == null ? '—' : formatNumber(dailyMileage, 1)}<em>{dailyMileage == null ? '' : 'km'}</em></strong></div>
<div><small></small><button type="button" className="v2-metric-source-link" title="展开全部里程来源" onClick={() => setSourceEvidenceOpen(true)}>{hasRealtimeMileage(vehicle) ? formatNumber(vehicle.totalMileageKm, 1) : '—'}<em>{hasRealtimeMileage(vehicle) ? 'km' : ''}</em></button></div>
<div><small></small><button type="button" className="v2-metric-source-link" title="展开全部里程来源" onClick={() => setSourceEvidenceOpen(true)}>{dailyMileage == null ? '—' : formatNumber(dailyMileage, 1)}<em>{dailyMileage == null ? '' : 'km'}</em></button></div>
<div><small></small><strong>{statusLabel(status)}</strong></div>
<div><small></small><strong>{formatNumber(activeAlerts?.total ?? 0)}<em></em></strong></div>
</div>
@@ -233,11 +235,12 @@ function VehicleDetailCard({
<dl className="v2-detail-list">
<div><dt></dt><dd>{vehicle.lastSeen || '暂无'}</dd></div>
<div><dt></dt><dd>{relativeFreshness(vehicle.lastSeen)}</dd></div>
<div><dt></dt><dd>{hasRealtimeLocation(vehicle) ? `${vehicle.longitude.toFixed(6)}, ${vehicle.latitude.toFixed(6)}` : '—'}</dd></div>
<div><dt></dt><dd><button type="button" className="v2-detail-source-link" title="展开全部位置来源" onClick={() => setSourceEvidenceOpen(true)}>{hasRealtimeLocation(vehicle) ? `${vehicle.longitude.toFixed(6)}, ${vehicle.latitude.toFixed(6)}` : '—'}</button></dd></div>
<div><dt></dt><dd>{hasRealtimeLocation(vehicle) ? address?.formattedAddress || '位置解析中' : '暂无实时位置'}</dd></div>
<div><dt></dt><dd>{latestAlert ? `${latestAlert.ruleName} · ${latestAlert.severity}` : '无当前业务告警'}</dd></div>
</dl>
</section>
<VehicleSourceEvidencePanel vin={vehicle.vin} compact open={sourceEvidenceOpen} onOpenChange={setSourceEvidenceOpen} />
</aside>
);
}

View File

@@ -1,5 +1,5 @@
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { cleanup, render, waitFor } from '@testing-library/react';
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
import { afterEach, expect, test, vi } from 'vitest';
import { MemoryRouter, Route, Routes } from 'react-router-dom';
import { api } from '../../api/client';
@@ -52,6 +52,13 @@ test('polls lightweight single-vehicle realtime data and passes its report inter
vi.spyOn(api, 'vehicleDetail').mockResolvedValue(detail);
const realtimeSpy = vi.spyOn(api, 'vehicleRealtime').mockResolvedValue({ items: [movedRealtime], total: 1, limit: 1, offset: 0 });
vi.spyOn(api, 'latestTelemetry').mockResolvedValue({ values: [], categories: [], scannedFrames: 0 } as never);
const sourceEvidence = vi.spyOn(api, 'vehicleSourceEvidence').mockResolvedValue({
vin: initialRealtime.vin, plate: initialRealtime.plate, mileageDate: '2026-07-16',
recommendedLocationProtocol: 'JT808', recommendedLocationLabel: 'JT808', locationConflict: false,
locationSources: [], mileageSources: [],
comparison: { locationMaxDistanceM: 0, totalMileageDeltaKm: 0, dailyMileageDeltaKm: 0, reportTimeDeltaSeconds: 0 },
asOf: '2026-07-16T10:00:00+08:00'
});
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
render(<QueryClientProvider client={client}><MemoryRouter initialEntries={[`/vehicles/${initialRealtime.vin}`]} future={ROUTER_FUTURE}><Routes><Route path="/vehicles/:vin" element={<VehiclePage />} /></Routes></MemoryRouter></QueryClientProvider>);
@@ -66,4 +73,7 @@ test('polls lightweight single-vehicle realtime data and passes its report inter
await waitFor(() => expect(fleetMapVehicles).toHaveBeenLastCalledWith([
expect.objectContaining({ longitude: 113.28, latitude: 23.15, reportIntervalMs: 30_000 })
]));
expect(sourceEvidence).not.toHaveBeenCalled();
fireEvent.click(screen.getByTitle('展开全部位置来源'));
await waitFor(() => expect(sourceEvidence).toHaveBeenCalledTimes(1));
});

View File

@@ -15,6 +15,7 @@ import { formatZhNumber } from '../domain/formatters';
import { parseVehicleProfileSyncCSV, vehicleProfileSyncCSVHeader } from '../domain/profileSync';
import { FleetMap } from '../map/FleetMap';
import { InlineError, PageLoading } from '../shared/AsyncState';
import { VehicleSourceEvidencePanel } from '../shared/VehicleSourceEvidencePanel';
function fmt(value?: string) { return value?.trim() || '—'; }
function metric(value: number | undefined, fallback = '—') { return typeof value === 'number' && Number.isFinite(value) ? formatZhNumber(value, 1) : fallback; }
@@ -173,6 +174,7 @@ function TelemetryPanel({ data, pending, error }: { data?: LatestTelemetryRespon
function VehicleRecord({ detail, liveRealtime, telemetry, telemetryPending, telemetryError, onUpdated }: { detail: VehicleDetail; liveRealtime?: VehicleRealtimeRow; telemetry?: LatestTelemetryResponse; telemetryPending: boolean; telemetryError?: string; onUpdated: () => void }) {
const { session } = usePlatformSession();
const [sourceEvidenceOpen, setSourceEvidenceOpen] = useState(false);
const realtime = liveRealtime ?? detail.realtimeSummary;
const identity = detail.identity;
const mapVehicles = realtime ? [realtime] : [];
@@ -185,11 +187,12 @@ function VehicleRecord({ detail, liveRealtime, telemetry, telemetryPending, tele
</section>
<div className="v2-record-grid">
<section className="v2-single-map-card"><FleetMap vehicles={mapVehicles} selectedVin={detail.vin} onSelect={() => undefined} /><footer><span><IconMapPin />{realtime ? `实时坐标 ${realtime.longitude.toFixed(6)}, ${realtime.latitude.toFixed(6)}` : '暂无有效实时坐标'}{identity?.locationText ? ` · 档案区域 ${identity.locationText}` : ''}</span><time>{fmt(realtime?.lastSeen)}</time></footer></section>
<section className="v2-single-map-card"><FleetMap vehicles={mapVehicles} selectedVin={detail.vin} onSelect={() => undefined} /><footer><button type="button" className="v2-map-source-link" title="展开全部位置来源" onClick={() => setSourceEvidenceOpen(true)}><IconMapPin />{realtime ? `实时坐标 ${realtime.longitude.toFixed(6)}, ${realtime.latitude.toFixed(6)}` : '暂无有效实时坐标'}{identity?.locationText ? ` · 档案区域 ${identity.locationText}` : ''}</button><time>{fmt(realtime?.lastSeen)}</time></footer></section>
<Archive detail={detail} editable={canAdminister(session)} onUpdated={onUpdated} />
<section className="v2-record-card v2-live-card"><header><strong></strong><span><IconClock />{timeOnly(realtime?.lastSeen)}</span></header><div className="v2-live-grid">
<div><small></small><strong>{metric(realtime?.speedKmh)}<em>km/h</em></strong></div><div><small>SOC</small><strong>{metric(realtime?.socPercent)}<em>%</em></strong></div><div><small></small><strong>{metric(realtime?.totalMileageKm)}<em>km</em></strong></div><div><small></small><strong>{metric(lastMileage?.dailyMileageKm)}<em>km</em></strong></div><div><small>线</small><strong>{realtime?.onlineSourceCount ?? 0}<em></em></strong></div><div><small></small><strong>{detail.sourceStatus.length}<em></em></strong></div>
<div><small></small><strong>{metric(realtime?.speedKmh)}<em>km/h</em></strong></div><div><small>SOC</small><strong>{metric(realtime?.socPercent)}<em>%</em></strong></div><div><small></small><button type="button" className="v2-live-source-link" title="展开全部里程来源" onClick={() => setSourceEvidenceOpen(true)}>{metric(realtime?.totalMileageKm)}<em>km</em></button></div><div><small></small><button type="button" className="v2-live-source-link" title="展开全部里程来源" onClick={() => setSourceEvidenceOpen(true)}>{metric(lastMileage?.dailyMileageKm)}<em>km</em></button></div><div><small>线</small><strong>{realtime?.onlineSourceCount ?? 0}<em></em></strong></div><div><small></small><strong>{detail.sourceStatus.length}<em></em></strong></div>
</div></section>
<VehicleSourceEvidencePanel vin={detail.vin} open={sourceEvidenceOpen} onOpenChange={setSourceEvidenceOpen} />
<TelemetryPanel data={telemetry} pending={telemetryPending} error={telemetryError} />
<Events detail={detail} />
</div>

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>;
}

View File

@@ -282,6 +282,64 @@ button, a { -webkit-tap-highlight-color: transparent; }
.v2-metric-grid small { display: block; color: var(--v2-muted); font-size: 8px; }
.v2-metric-grid strong { display: block; margin-top: 4px; overflow: hidden; font-size: 15px; text-overflow: ellipsis; white-space: nowrap; }
.v2-metric-grid em { margin-left: 2px; color: var(--v2-muted); font-size: 7px; font-style: normal; font-weight: 500; }
.v2-metric-source-link, .v2-live-source-link { display: block; width: 100%; margin-top: 4px; overflow: hidden; border: 0; background: transparent; padding: 0; color: inherit; cursor: pointer; text-align: left; text-overflow: ellipsis; white-space: nowrap; font-size: 15px; font-weight: 700; }
.v2-metric-source-link:hover, .v2-live-source-link:hover, .v2-detail-source-link:hover, .v2-map-source-link:hover { color: var(--v2-blue); }
.v2-detail-source-link { max-width: 100%; overflow: hidden; border: 0; background: transparent; padding: 0; color: #3d4a5e; cursor: pointer; text-align: left; text-overflow: ellipsis; white-space: nowrap; font: inherit; }
.v2-source-evidence { min-width: 0; border: 1px solid #dce5ef; border-radius: 10px; background: #fff; box-shadow: 0 4px 18px rgba(23,43,77,.045); }
.v2-record-grid > .v2-source-evidence { grid-column: 1 / -1; }
.v2-source-evidence-trigger { display: flex; min-height: 54px; align-items: center; justify-content: space-between; gap: 12px; padding: 10px 14px; }
.v2-source-evidence-trigger > div { min-width: 0; }
.v2-source-evidence-trigger strong { display: block; color: #25344a; font-size: 12px; }
.v2-source-evidence-trigger span { display: block; margin-top: 3px; overflow: hidden; color: #79869a; font-size: 9px; text-overflow: ellipsis; white-space: nowrap; }
.v2-source-evidence-trigger button { flex: 0 0 auto; height: 30px; border: 1px solid #bfd4f6; border-radius: 7px; background: #f3f7ff; padding: 0 11px; color: var(--v2-blue); cursor: pointer; font-size: 9px; font-weight: 700; }
.v2-source-evidence-body { border-top: 1px solid var(--v2-border); padding: 12px 14px 14px; }
.v2-source-evidence-toolbar { display: flex; align-items: center; justify-content: space-between; gap: 16px; margin-bottom: 10px; }
.v2-source-evidence-toolbar label { display: flex; align-items: center; gap: 8px; color: #59677b; font-size: 9px; font-weight: 700; }
.v2-source-evidence-toolbar input { height: 30px; border: 1px solid #dce4ef; border-radius: 6px; background: #fff; padding: 0 8px; color: #344257; font-size: 9px; }
.v2-source-evidence-toolbar small { color: #8b96a8; font-size: 8px; }
.v2-source-evidence-summary { display: grid; grid-template-columns: repeat(4,minmax(0,1fr)); overflow: hidden; border: 1px solid #dfe6ef; border-radius: 8px; background: #f8fafc; }
.v2-source-evidence-summary > div { min-width: 0; padding: 10px 12px; }
.v2-source-evidence-summary > div + div { border-left: 1px solid #e2e8f0; }
.v2-source-evidence-summary small { display: block; color: #7b8799; font-size: 8px; }
.v2-source-evidence-summary strong { display: block; margin-top: 4px; overflow: hidden; color: #26364c; font-size: 14px; text-overflow: ellipsis; white-space: nowrap; font-variant-numeric: tabular-nums; }
.v2-source-evidence-summary em { margin-left: 3px; color: #8793a5; font-size: 8px; font-style: normal; font-weight: 500; }
.v2-source-evidence-group { margin-top: 12px; padding: 0; border: 0; }
.v2-source-evidence-group > header { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; margin-bottom: 7px; }
.v2-source-evidence-group > header strong { color: #314157; font-size: 11px; }
.v2-source-evidence-group > header span { color: #8995a7; font-size: 8px; }
.v2-source-evidence-group > div { display: grid; grid-template-columns: repeat(auto-fit,minmax(245px,1fr)); gap: 8px; }
.v2-source-evidence-card { min-width: 0; overflow: hidden; border: 1px solid #dfe6ef; border-radius: 8px; background: #fff; }
.v2-source-evidence-card.is-recommended { border-color: #8eb8f5; box-shadow: inset 3px 0 #3b82f6; }
.v2-source-evidence-card.is-warning { border-color: #f0c16f; box-shadow: inset 3px 0 #eaa126; }
.v2-source-evidence-card.is-disabled, .v2-source-evidence-card.is-offline { background: #fafbfc; opacity: .82; }
.v2-source-evidence-card > header { display: flex; min-height: 48px; align-items: center; justify-content: space-between; gap: 8px; border-bottom: 1px solid #edf1f5; padding: 8px 10px; }
.v2-source-evidence-card > header > div { min-width: 0; }
.v2-source-evidence-card > header strong { display: block; overflow: hidden; color: #2c3b50; font-size: 10px; text-overflow: ellipsis; white-space: nowrap; }
.v2-source-evidence-card > header span { display: block; margin-top: 3px; overflow: hidden; color: #7e8a9c; font-size: 8px; text-overflow: ellipsis; white-space: nowrap; }
.v2-source-evidence-card > header aside { display: flex; flex: 0 0 auto; flex-wrap: wrap; justify-content: flex-end; gap: 4px; }
.v2-source-evidence-card > header b { border-radius: 10px; background: #edf2f7; padding: 2px 6px; color: #64748b; font-size: 7px; }
.v2-source-evidence-card > header b.is-recommended { background: #e8f1ff; color: #1769d2; }
.v2-source-evidence-card > header b.is-disabled, .v2-source-evidence-card > header b.is-offline { background: #eef0f3; color: #7b8491; }
.v2-source-evidence-card > header b.is-warning { background: #fff4dc; color: #a46500; }
.v2-source-evidence-card dl { display: grid; grid-template-columns: 1fr 1fr; margin: 0; padding: 5px 10px 8px; }
.v2-source-evidence-card dl > div { min-width: 0; padding: 5px 3px; }
.v2-source-evidence-card dt { color: #8b96a7; font-size: 7px; }
.v2-source-evidence-card dd { margin: 3px 0 0; overflow: hidden; color: #445268; font-size: 9px; text-overflow: ellipsis; white-space: nowrap; font-variant-numeric: tabular-nums; }
.v2-source-evidence-card > p { margin: 0 10px 9px; border-radius: 5px; background: #fff7e8; padding: 6px 8px; color: #9a650b; font-size: 7px; line-height: 1.5; }
.v2-source-evidence-state { display: flex; min-height: 80px; align-items: center; justify-content: center; gap: 8px; color: #718096; font-size: 9px; }
.v2-source-evidence-state i { width: 16px; height: 16px; border: 2px solid #cbd8ea; border-top-color: var(--v2-blue); border-radius: 50%; animation: v2-spin .8s linear infinite; }
.v2-source-evidence-state.is-error { color: #b42318; }
.v2-source-evidence-state button { height: 28px; border: 1px solid #edb5b0; border-radius: 6px; background: #fff; padding: 0 9px; color: #a61b13; cursor: pointer; font-size: 8px; }
.v2-source-evidence.is-compact { margin-top: 10px; border-right: 0; border-left: 0; border-radius: 0; box-shadow: none; }
.v2-source-evidence.is-compact .v2-source-evidence-trigger { padding: 9px 0; }
.v2-source-evidence.is-compact .v2-source-evidence-body { padding: 10px 0 2px; }
.v2-source-evidence.is-compact .v2-source-evidence-toolbar { align-items: flex-start; flex-direction: column; gap: 7px; }
.v2-source-evidence.is-compact .v2-source-evidence-summary { grid-template-columns: repeat(2,minmax(0,1fr)); }
.v2-source-evidence.is-compact .v2-source-evidence-summary > div:nth-child(3) { border-left: 0; }
.v2-source-evidence.is-compact .v2-source-evidence-summary > div:nth-child(n+3) { border-top: 1px solid #e2e8f0; }
.v2-source-evidence.is-compact .v2-source-evidence-group > div { grid-template-columns: 1fr; }
.v2-vehicle-detail .v2-source-evidence-group { padding: 0; border-top: 0; }
.v2-event-strip { display: flex; min-height: 48px; align-items: center; gap: 22px; border: 1px solid #dfe6ef; border-radius: 8px; background: #fff; padding: 0 16px; box-shadow: 0 3px 12px rgba(21,32,51,.035); color: #6d7a8d; font-size: 10px; }
.v2-event-strip strong { color: var(--v2-text); font-size: 11px; }
@@ -381,6 +439,7 @@ button, a { -webkit-tap-highlight-color: transparent; }
.v2-single-map-card .v2-map-legend { display: none; }
.v2-single-map-card footer { display: flex; min-height: 38px; align-items: center; justify-content: space-between; gap: 16px; padding: 0 14px; color: #64748b; font-size: 9px; }
.v2-single-map-card footer span { display: inline-flex; min-width: 0; align-items: center; gap: 7px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.v2-map-source-link { display: inline-flex; min-width: 0; align-items: center; gap: 7px; overflow: hidden; border: 0; background: transparent; padding: 0; color: #64748b; cursor: pointer; text-align: left; text-overflow: ellipsis; white-space: nowrap; font: inherit; }
.v2-single-map-card footer time { white-space: nowrap; font-variant-numeric: tabular-nums; }
.v2-archive-card { grid-column: 2; grid-row: 1; }
.v2-record-list { margin: 0; padding: 8px 14px 4px; }
@@ -400,6 +459,7 @@ button, a { -webkit-tap-highlight-color: transparent; }
.v2-live-grid > div:nth-child(n+4) { border-top: 1px solid var(--v2-border); }
.v2-live-grid small { display: block; color: var(--v2-muted); font-size: 9px; }
.v2-live-grid strong { display: block; margin-top: 5px; overflow: hidden; font-size: 17px; text-overflow: ellipsis; white-space: nowrap; font-variant-numeric: tabular-nums; }
.v2-live-source-link { margin-top: 5px; font-size: 17px; font-variant-numeric: tabular-nums; }
.v2-live-grid em { margin-left: 3px; color: var(--v2-muted); font-size: 8px; font-style: normal; font-weight: 500; }
.v2-telemetry-card { grid-column: 1; grid-row: 2 / span 2; }
.v2-telemetry-card nav { display: flex; height: 42px; align-items: stretch; border-bottom: 1px solid var(--v2-border); padding: 0 8px; overflow-x: auto; }
@@ -2041,4 +2101,20 @@ button, a { -webkit-tap-highlight-color: transparent; }
.v2-mileage-table .is-total { width: 104px; min-width: 104px; padding-right: 10px; }
.v2-mileage-table .is-daily { font-size: 10px; }
.v2-mileage-table .is-period { font-size: 11px; }
.v2-source-evidence-trigger { min-height: 58px; padding: 10px 11px; }
.v2-source-evidence-trigger strong { font-size: 13px; }
.v2-source-evidence-trigger span { max-width: 220px; font-size: 10px; }
.v2-source-evidence-trigger button { height: 34px; font-size: 10px; }
.v2-source-evidence-body { padding: 10px; }
.v2-source-evidence-toolbar { align-items: flex-start; flex-direction: column; gap: 7px; }
.v2-source-evidence-toolbar label { font-size: 10px; }
.v2-source-evidence-toolbar input { height: 34px; font-size: 11px; }
.v2-source-evidence-summary { grid-template-columns: repeat(2,minmax(0,1fr)); }
.v2-source-evidence-summary > div:nth-child(3) { border-left: 0; }
.v2-source-evidence-summary > div:nth-child(n+3) { border-top: 1px solid #e2e8f0; }
.v2-source-evidence-summary strong { font-size: 15px; }
.v2-source-evidence-group > div { display: flex; overflow-x: auto; scroll-snap-type: x proximity; scrollbar-width: thin; }
.v2-source-evidence-card { min-width: min(82vw,300px); flex: 0 0 min(82vw,300px); scroll-snap-align: start; }
.v2-source-evidence-card > header strong { font-size: 11px; }
.v2-source-evidence-card dd { font-size: 10px; }
}