diff --git a/vehicle-data-platform/apps/api/internal/platform/handler_test.go b/vehicle-data-platform/apps/api/internal/platform/handler_test.go index 72c2018f..bbf01b88 100644 --- a/vehicle-data-platform/apps/api/internal/platform/handler_test.go +++ b/vehicle-data-platform/apps/api/internal/platform/handler_test.go @@ -1113,6 +1113,9 @@ func TestHandlerSourceReadiness(t *testing.T) { if body.Data.PlatformRelease != "platform-source-test" { t.Fatalf("source readiness should expose runtime release, got %+v", body.Data) } + if body.Data.TotalVehicles != body.Data.BoundVehicles+body.Data.IdentityRequiredVehicles { + t.Fatalf("source readiness vehicle identity population should reconcile, got %+v", body.Data) + } if len(body.Data.Sources) < len(canonicalVehicleProtocols) { t.Fatalf("source readiness should expose canonical protocol slots, got %+v", body.Data.Sources) } diff --git a/vehicle-data-platform/apps/api/internal/platform/model.go b/vehicle-data-platform/apps/api/internal/platform/model.go index 379a1277..ff6cad55 100644 --- a/vehicle-data-platform/apps/api/internal/platform/model.go +++ b/vehicle-data-platform/apps/api/internal/platform/model.go @@ -942,13 +942,15 @@ type MissingSourceStat struct { } type SourceReadinessPlan struct { - TotalVehicles int `json:"totalVehicles"` - OnlineVehicles int `json:"onlineVehicles"` - KafkaLag *int `json:"kafkaLag"` - ActiveConnections *int `json:"activeConnections"` - RedisOnlineKeys *int `json:"redisOnlineKeys"` - PlatformRelease string `json:"platformRelease"` - Sources []SourceReadinessRow `json:"sources"` + TotalVehicles int `json:"totalVehicles"` + BoundVehicles int `json:"boundVehicles"` + IdentityRequiredVehicles int `json:"identityRequiredVehicles"` + OnlineVehicles int `json:"onlineVehicles"` + KafkaLag *int `json:"kafkaLag"` + ActiveConnections *int `json:"activeConnections"` + RedisOnlineKeys *int `json:"redisOnlineKeys"` + PlatformRelease string `json:"platformRelease"` + Sources []SourceReadinessRow `json:"sources"` } type SourceReadinessRow struct { diff --git a/vehicle-data-platform/apps/api/internal/platform/service.go b/vehicle-data-platform/apps/api/internal/platform/service.go index 85630aad..03f66f30 100644 --- a/vehicle-data-platform/apps/api/internal/platform/service.go +++ b/vehicle-data-platform/apps/api/internal/platform/service.go @@ -262,13 +262,15 @@ func buildSourceReadinessPlan(summary VehicleServiceSummary, health OpsHealth, q rows = append(rows, row) } return SourceReadinessPlan{ - TotalVehicles: summary.TotalVehicles, - OnlineVehicles: summary.OnlineVehicles, - KafkaLag: health.KafkaLag, - ActiveConnections: health.ActiveConnections, - RedisOnlineKeys: health.RedisOnlineKeys, - PlatformRelease: health.Runtime.PlatformRelease, - Sources: rows, + TotalVehicles: summary.TotalVehicles, + BoundVehicles: summary.BoundVehicles, + IdentityRequiredVehicles: summary.IdentityRequiredVehicles, + OnlineVehicles: summary.OnlineVehicles, + KafkaLag: health.KafkaLag, + ActiveConnections: health.ActiveConnections, + RedisOnlineKeys: health.RedisOnlineKeys, + PlatformRelease: health.Runtime.PlatformRelease, + Sources: rows, } } diff --git a/vehicle-data-platform/apps/web/src/api/client.test.ts b/vehicle-data-platform/apps/web/src/api/client.test.ts index 50480681..7b8d3fe2 100644 --- a/vehicle-data-platform/apps/web/src/api/client.test.ts +++ b/vehicle-data-platform/apps/web/src/api/client.test.ts @@ -91,6 +91,20 @@ test('monitor viewport requests forward AbortSignal so obsolete pans can be canc expect(fetchMock).toHaveBeenCalledWith('/api/v2/monitor/map?zoom=13&bounds=113%2C22%2C114%2C23', { signal: controller.signal }); }); +test('high-volume history, track, and mileage requests forward AbortSignal', async () => { + const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue({ ok: true, json: async () => ({ data: {} }) } as Response); + const controller = new AbortController(); + const params = new URLSearchParams({ keyword: 'VIN001' }); + + await api.trackPlayback(params, controller.signal); + await api.historyData(params, controller.signal); + await api.historySeries(params, controller.signal); + await api.dailyMileage(params, controller.signal); + await api.mileageStatistics(params, controller.signal); + + for (const [, init] of fetchMock.mock.calls) expect(init).toEqual({ signal: controller.signal }); +}); + test('rawFramesQuery posts structured JSON instead of URL query strings', async () => { const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue({ ok: true, diff --git a/vehicle-data-platform/apps/web/src/api/client.ts b/vehicle-data-platform/apps/web/src/api/client.ts index 95404fef..929ae277 100644 --- a/vehicle-data-platform/apps/web/src/api/client.ts +++ b/vehicle-data-platform/apps/web/src/api/client.ts @@ -127,11 +127,11 @@ export const api = { session: () => request('/api/v2/session'), monitorSummary: (params = new URLSearchParams(), signal?: AbortSignal) => request(`/api/v2/monitor/summary?${params.toString()}`, signal ? { signal } : undefined), monitorMap: (params = new URLSearchParams(), signal?: AbortSignal) => request(`/api/v2/monitor/map?${params.toString()}`, signal ? { signal } : undefined), - trackPlayback: (params = new URLSearchParams()) => request(`/api/v2/tracks?${params.toString()}`), + trackPlayback: (params = new URLSearchParams(), signal?: AbortSignal) => request(`/api/v2/tracks?${params.toString()}`, signal ? { signal } : undefined), metricCatalog: () => request('/api/v2/metrics'), historyMetricCatalog: () => request('/api/v2/history/metrics'), - historyData: (params = new URLSearchParams()) => request(`/api/v2/history/query?${params.toString()}`), - historySeries: (params = new URLSearchParams()) => request(`/api/v2/history/series?${params.toString()}`), + historyData: (params = new URLSearchParams(), signal?: AbortSignal) => request(`/api/v2/history/query?${params.toString()}`, signal ? { signal } : undefined), + historySeries: (params = new URLSearchParams(), signal?: AbortSignal) => request(`/api/v2/history/series?${params.toString()}`, signal ? { signal } : undefined), historyExports: () => request('/api/v2/exports'), createHistoryExport: (query: HistoryExportRequest) => request('/api/v2/exports', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(query) @@ -171,9 +171,9 @@ export const api = { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ ids }) }), dashboardSummary: () => request('/api/dashboard/summary'), - vehicles: (params = new URLSearchParams()) => request>(`/api/vehicles?${params.toString()}`), + vehicles: (params = new URLSearchParams(), signal?: AbortSignal) => request>(`/api/vehicles?${params.toString()}`, signal ? { signal } : undefined), vehicleResolve: (params = new URLSearchParams()) => request(`/api/vehicles/resolve?${params.toString()}`), - vehicleCoverage: (params = new URLSearchParams()) => request>(`/api/vehicles/coverage?${params.toString()}`), + vehicleCoverage: (params = new URLSearchParams(), signal?: AbortSignal) => request>(`/api/vehicles/coverage?${params.toString()}`, signal ? { signal } : undefined), vehicleCoverageSummary: (params = new URLSearchParams()) => request(`/api/vehicles/coverage/summary?${params.toString()}`), vehicleDetail: (params = new URLSearchParams()) => request(`/api/vehicle-service?${params.toString()}`), vehicleProfile: (vin: string) => request(`/api/v2/vehicles/${encodeURIComponent(vin)}/profile`), @@ -201,8 +201,8 @@ export const api = { body: JSON.stringify(query) }), mileageSummary: (params = new URLSearchParams()) => request(`/api/mileage/summary?${params.toString()}`), - dailyMileage: (params = new URLSearchParams()) => request>(`/api/mileage/daily?${params.toString()}`), - mileageStatistics: (params = new URLSearchParams()) => request(`/api/v2/statistics/mileage?${params.toString()}`), + dailyMileage: (params = new URLSearchParams(), signal?: AbortSignal) => request>(`/api/mileage/daily?${params.toString()}`, signal ? { signal } : undefined), + mileageStatistics: (params = new URLSearchParams(), signal?: AbortSignal) => request(`/api/v2/statistics/mileage?${params.toString()}`, signal ? { signal } : undefined), onlineStatisticsSummary: (params = new URLSearchParams()) => request(`/api/statistics/online-summary?${params.toString()}`), onlineVehicleStatuses: (params = new URLSearchParams()) => request>(`/api/statistics/online-vehicles?${params.toString()}`), qualitySummary: (params = new URLSearchParams()) => request(`/api/quality/summary?${params.toString()}`), diff --git a/vehicle-data-platform/apps/web/src/api/types.ts b/vehicle-data-platform/apps/web/src/api/types.ts index c26fe04d..49f51ba5 100644 --- a/vehicle-data-platform/apps/web/src/api/types.ts +++ b/vehicle-data-platform/apps/web/src/api/types.ts @@ -709,6 +709,8 @@ export interface OpsHealth { export interface SourceReadinessPlan { totalVehicles: number; + boundVehicles: number; + identityRequiredVehicles: number; onlineVehicles: number; kafkaLag: number | null; activeConnections: number | null; diff --git a/vehicle-data-platform/apps/web/src/v2/pages/HistoryPage.tsx b/vehicle-data-platform/apps/web/src/v2/pages/HistoryPage.tsx index 07144cc0..a18c37e7 100644 --- a/vehicle-data-platform/apps/web/src/v2/pages/HistoryPage.tsx +++ b/vehicle-data-platform/apps/web/src/v2/pages/HistoryPage.tsx @@ -6,6 +6,7 @@ import { api } from '../../api/client'; import type { HistoryDataRow, HistoryExportRequest, HistoryMetricDefinition, HistorySeriesResponse } from '../../api/types'; import { buildHistorySeriesPanels, formatExportFileSize, formatHistoryValue, formatSeriesGrain, parseHistoryKeywords } from '../domain/history'; import { InlineError } from '../shared/AsyncState'; +import { QUERY_MEMORY } from '../queryPolicy'; function HistoryTrend({ response, category, loading, error }: { response?: HistorySeriesResponse; category: string; loading: boolean; error?: string }) { const panels = useMemo(() => buildHistorySeriesPanels(response), [response]); @@ -79,8 +80,8 @@ export default function HistoryPage() { return next; }, [criteria, keywords]); const catalogQuery = useQuery({ queryKey: ['history-metric-catalog'], queryFn: api.historyMetricCatalog, staleTime: 30 * 60_000 }); - const dataQuery = useQuery({ queryKey: ['history-data', params.toString()], enabled: keywords.length > 0, queryFn: () => api.historyData(params), placeholderData: (previous) => previous }); - const seriesQuery = useQuery({ queryKey: ['history-series', seriesParams.toString()], enabled: keywords.length > 0 && criteria.category === 'location', queryFn: () => api.historySeries(seriesParams), placeholderData: (previous) => previous }); + const dataQuery = useQuery({ queryKey: ['history-data', params.toString()], enabled: keywords.length > 0, queryFn: ({ signal }) => api.historyData(params, signal), placeholderData: (previous) => previous, gcTime: QUERY_MEMORY.highVolumeGcTime }); + const seriesQuery = useQuery({ queryKey: ['history-series', seriesParams.toString()], enabled: keywords.length > 0 && criteria.category === 'location', queryFn: ({ signal }) => api.historySeries(seriesParams, signal), placeholderData: (previous) => previous, gcTime: QUERY_MEMORY.highVolumeGcTime }); const result = dataQuery.data; const allMetrics = result?.columns ?? catalogQuery.data?.metrics.filter((metric) => metric.category === criteria.category) ?? []; const visibleKeys = visibleByCategory[criteria.category] ?? allMetrics.filter((metric) => metric.defaultVisible).map((metric) => metric.key); diff --git a/vehicle-data-platform/apps/web/src/v2/pages/MonitorPage.test.tsx b/vehicle-data-platform/apps/web/src/v2/pages/MonitorPage.test.tsx index 992ea917..46ce1831 100644 --- a/vehicle-data-platform/apps/web/src/v2/pages/MonitorPage.test.tsx +++ b/vehicle-data-platform/apps/web/src/v2/pages/MonitorPage.test.tsx @@ -17,6 +17,7 @@ const vehicles = [{ }] as VehicleRealtimeRow[]; const monitorMap = { clusters: [], points: [], total: 2 }; const fleetMapRenderSpy = vi.hoisted(() => vi.fn()); +const monitorQueryFlags = vi.hoisted(() => ({ isLoading: false, isFetching: false, isPlaceholderData: false })); vi.mock('../map/FleetMap', () => ({ FleetMap: ({ selectedVin, onSelectVin }: { selectedVin?: string; onSelectVin?: (vin: string) => void }) => { @@ -42,14 +43,19 @@ vi.mock('../hooks/useMonitorData', () => ({ }, useMonitorData: () => ({ summary: { data: { totalVehicles: 2, onlineVehicles: 2, offlineVehicles: 0, drivingVehicles: 1, idleVehicles: 1, frameToday: 10 } }, - vehicles: { data: { items: vehicles, total: 2 }, isError: false, isLoading: false, isFetching: false }, - map: { data: monitorMap }, + vehicles: { data: { items: vehicles, total: 2 }, isError: false, ...monitorQueryFlags }, + map: { data: monitorMap, isPlaceholderData: monitorQueryFlags.isPlaceholderData }, selectedVehicle: { data: { items: [] } } }), useMonitorVehicleCard: () => ({ detail: {}, activeAlerts: {}, address: {} }) })); -afterEach(cleanup); +afterEach(() => { + cleanup(); + monitorQueryFlags.isLoading = false; + monitorQueryFlags.isFetching = false; + monitorQueryFlags.isPlaceholderData = false; +}); test('starts without a selection and supports expand, collapse, reselection, and clear', () => { const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); @@ -63,6 +69,8 @@ test('starts without a selection and supports expand, collapse, reselection, and expect(firstVehicle).not.toHaveClass('is-selected'); expect(screen.queryByRole('button', { name: '取消选择车辆' })).not.toBeInTheDocument(); expect(screen.getByTestId('fleet-map')).toHaveAttribute('data-selected-vin', ''); + expect(screen.getByText('有实时位置')).toBeInTheDocument(); + expect(screen.queryByText('接入车辆')).not.toBeInTheDocument(); fireEvent.click(firstVehicle); expect(workspace).toHaveClass('is-detail-open'); @@ -124,8 +132,7 @@ test('pastes, deduplicates, and submits multiple plates as one batch search', as const input = screen.getByRole('textbox', { name: '搜索车辆' }); fireEvent.paste(input, { clipboardData: { getData: () => '粤a12345\n粤B67890,粤A12345' } }); expect(input).toHaveValue('粤A12345,粤B67890'); - expect(screen.getByText('已识别 2 辆')).toBeInTheDocument(); - expect(screen.getByText('正在批量筛选 2 辆')).toBeInTheDocument(); + expect(screen.getAllByText('已找到 2/2')).toHaveLength(3); fireEvent.click(screen.getByRole('button', { name: /列表/ })); await waitFor(() => { @@ -134,3 +141,24 @@ test('pastes, deduplicates, and submits multiple plates as one batch search', as expect(params?.has('keyword')).toBe(false); }); }); + +test('hides stale fleet rows while a pasted batch is still loading', () => { + monitorQueryFlags.isPlaceholderData = true; + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + render(); + + fireEvent.paste(screen.getByRole('textbox', { name: '搜索车辆' }), { clipboardData: { getData: () => '粤A12345\n粤B67890' } }); + expect(screen.getByText('正在查找 2 辆车')).toBeInTheDocument(); + expect(screen.getByText('查询中')).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: /粤A12345 LTEST000000000001/ })).not.toBeInTheDocument(); +}); + +test('reports pasted plates that have no exact realtime match', () => { + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + render(); + + fireEvent.paste(screen.getByRole('textbox', { name: '搜索车辆' }), { clipboardData: { getData: () => '粤A12345\n粤Z99999' } }); + expect(screen.getByText('已找到 1/2')).toBeInTheDocument(); + expect(screen.getAllByText('已找到 1/2 · 未找到 1 辆')).toHaveLength(2); + expect(screen.getAllByTitle('未找到:粤Z99999')).toHaveLength(2); +}); diff --git a/vehicle-data-platform/apps/web/src/v2/pages/MonitorPage.tsx b/vehicle-data-platform/apps/web/src/v2/pages/MonitorPage.tsx index 91d7e3b8..52c7f0bd 100644 --- a/vehicle-data-platform/apps/web/src/v2/pages/MonitorPage.tsx +++ b/vehicle-data-platform/apps/web/src/v2/pages/MonitorPage.tsx @@ -12,6 +12,7 @@ import { MAX_MONITOR_SEARCH_TERMS, MONITOR_REFRESH, monitorQueryParams, parseMon const protocols = ['', 'GB32960', 'JT808', 'YUTONG_MQTT']; const statuses = ['', 'online', 'offline', 'driving', 'idle']; +const EMPTY_VEHICLES: VehicleRealtimeRow[] = []; type AddressCoordinate = { longitude: number; latitude: number; key: string }; @@ -194,6 +195,30 @@ export default function MonitorPage() { if (status === 'driving' || status === 'idle') return data.filter((vehicle) => vehicleStatus(vehicle) === status); return data; }, [status, vehicles.data?.items]); + const listRows = realtimeListQuery.data?.items ?? EMPTY_VEHICLES; + const activeBatchRows = mode === 'map' ? rows : listRows; + const activeBatchQuery = mode === 'map' ? vehicles : realtimeListQuery; + const batchSearchPending = searchTerms.length > 1 && ( + keyword !== deferredKeyword || activeBatchQuery.isLoading || activeBatchQuery.isPlaceholderData + ); + const batchMatch = useMemo(() => { + const identities = new Set(); + for (const vehicle of activeBatchRows) { + identities.add(vehicle.vin.toLocaleUpperCase()); + if (vehicle.plate) identities.add(vehicle.plate.toLocaleUpperCase()); + } + const missing = searchTerms.filter((term) => !identities.has(term)); + return { matched: searchTerms.length - missing.length, missing }; + }, [activeBatchRows, searchTerms]); + const visibleRows = batchSearchPending ? [] : rows; + const batchStatus = batchSearchPending + ? `正在查找 ${searchTerms.length} 辆` + : batchMatch.missing.length + ? `已找到 ${batchMatch.matched}/${searchTerms.length} · 未找到 ${batchMatch.missing.length} 辆` + : `已找到 ${batchMatch.matched}/${searchTerms.length}`; + const batchStatusTitle = batchMatch.missing.length + ? `未找到:${batchMatch.missing.join('、')}` + : searchTerms.join('、'); const selected = selectedVin ? rows.find((vehicle) => vehicle.vin === selectedVin) ?? selectedVehicle.data?.items[0] : undefined; @@ -232,7 +257,7 @@ export default function MonitorPage() { }} placeholder="车牌 / VIN;可批量粘贴车牌" /> - {searchTerms.length > 1 ? 已识别 {searchTerms.length} 辆 : null} + {searchTerms.length > 1 ? {batchSearchPending ? `已识别 ${searchTerms.length} 辆` : `已找到 ${batchMatch.matched}/${searchTerms.length}`} : null}