feat(platform): expose multi-source vehicle evidence
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user