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

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