fix(web): preserve route state and cancel stale reads
This commit is contained in:
@@ -105,6 +105,34 @@ test('high-volume history, track, and mileage requests forward AbortSignal', asy
|
||||
for (const [, init] of fetchMock.mock.calls) expect(init).toEqual({ signal: controller.signal });
|
||||
});
|
||||
|
||||
test('route-scoped platform reads forward AbortSignal on navigation cleanup', async () => {
|
||||
const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue({ ok: true, json: async () => ({ data: {} }) } as Response);
|
||||
const controller = new AbortController();
|
||||
const signal = controller.signal;
|
||||
|
||||
await api.session(signal);
|
||||
await api.metricCatalog(signal);
|
||||
await api.historyMetricCatalog(signal);
|
||||
await api.historyExports(signal);
|
||||
await api.accessSummary({}, signal);
|
||||
await api.accessVehicles({ limit: 50, offset: 0 }, signal);
|
||||
await api.accessUnresolvedIdentities({ limit: 20, offset: 0 }, signal);
|
||||
await api.accessThresholds(signal);
|
||||
await api.alertSummaryV2({}, signal);
|
||||
await api.alertEventsV2({ limit: 20, offset: 0 }, signal);
|
||||
await api.alertEventV2('event-1', signal);
|
||||
await api.alertRulesV2(signal);
|
||||
await api.alertNotificationsV2(new URLSearchParams({ limit: '100' }), signal);
|
||||
await api.vehicleDetail(new URLSearchParams({ keyword: 'VIN001' }), signal);
|
||||
await api.latestTelemetry('VIN001', signal);
|
||||
await api.reverseGeocode(new URLSearchParams({ longitude: '113', latitude: '23' }), signal);
|
||||
await api.opsHealth(signal);
|
||||
await api.sourceReadiness(signal);
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(18);
|
||||
for (const [, init] of fetchMock.mock.calls) expect(init?.signal).toBe(signal);
|
||||
});
|
||||
|
||||
test('rawFramesQuery posts structured JSON instead of URL query strings', async () => {
|
||||
const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue({
|
||||
ok: true,
|
||||
|
||||
@@ -101,6 +101,10 @@ function withAuthorization(headers: HeadersInit | undefined, token: string) {
|
||||
return authorized;
|
||||
}
|
||||
|
||||
function withSignal(init: RequestInit | undefined, signal?: AbortSignal) {
|
||||
return signal ? { ...init, signal } : init;
|
||||
}
|
||||
|
||||
async function responseErrorMessage(response: Response) {
|
||||
try {
|
||||
const envelope = (await response.json()) as ApiErrorEnvelope;
|
||||
@@ -124,49 +128,49 @@ function withTraceID(message: string, traceID?: string) {
|
||||
}
|
||||
|
||||
export const api = {
|
||||
session: () => request<SessionInfo>('/api/v2/session'),
|
||||
session: (signal?: AbortSignal) => request<SessionInfo>('/api/v2/session', withSignal(undefined, signal)),
|
||||
monitorSummary: (params = new URLSearchParams(), signal?: AbortSignal) => request<MonitorSummary>(`/api/v2/monitor/summary?${params.toString()}`, signal ? { signal } : undefined),
|
||||
monitorMap: (params = new URLSearchParams(), signal?: AbortSignal) => request<MonitorMapResponse>(`/api/v2/monitor/map?${params.toString()}`, signal ? { signal } : undefined),
|
||||
trackPlayback: (params = new URLSearchParams(), signal?: AbortSignal) => request<TrackPlaybackResponse>(`/api/v2/tracks?${params.toString()}`, signal ? { signal } : undefined),
|
||||
metricCatalog: () => request<MetricCatalog>('/api/v2/metrics'),
|
||||
historyMetricCatalog: () => request<HistoryMetricCatalog>('/api/v2/history/metrics'),
|
||||
metricCatalog: (signal?: AbortSignal) => request<MetricCatalog>('/api/v2/metrics', withSignal(undefined, signal)),
|
||||
historyMetricCatalog: (signal?: AbortSignal) => request<HistoryMetricCatalog>('/api/v2/history/metrics', withSignal(undefined, signal)),
|
||||
historyData: (params = new URLSearchParams(), signal?: AbortSignal) => request<HistoryDataResponse>(`/api/v2/history/query?${params.toString()}`, signal ? { signal } : undefined),
|
||||
historySeries: (params = new URLSearchParams(), signal?: AbortSignal) => request<HistorySeriesResponse>(`/api/v2/history/series?${params.toString()}`, signal ? { signal } : undefined),
|
||||
historyExports: () => request<HistoryExportJob[]>('/api/v2/exports'),
|
||||
historyExports: (signal?: AbortSignal) => request<HistoryExportJob[]>('/api/v2/exports', withSignal(undefined, signal)),
|
||||
createHistoryExport: (query: HistoryExportRequest) => request<HistoryExportJob>('/api/v2/exports', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(query)
|
||||
}),
|
||||
accessSummary: (query: AccessQuery) => request<AccessSummary>('/api/v2/access/summary', {
|
||||
accessSummary: (query: AccessQuery, signal?: AbortSignal) => request<AccessSummary>('/api/v2/access/summary', withSignal({
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(query)
|
||||
}),
|
||||
accessVehicles: (query: AccessQuery) => request<Page<AccessVehicleRow>>('/api/v2/access/vehicles', {
|
||||
}, signal)),
|
||||
accessVehicles: (query: AccessQuery, signal?: AbortSignal) => request<Page<AccessVehicleRow>>('/api/v2/access/vehicles', withSignal({
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(query)
|
||||
}),
|
||||
accessUnresolvedIdentities: (query: AccessUnresolvedIdentityQuery) => request<Page<AccessUnresolvedIdentity>>('/api/v2/access/unresolved-identities', {
|
||||
}, signal)),
|
||||
accessUnresolvedIdentities: (query: AccessUnresolvedIdentityQuery, signal?: AbortSignal) => request<Page<AccessUnresolvedIdentity>>('/api/v2/access/unresolved-identities', withSignal({
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(query)
|
||||
}),
|
||||
accessThresholds: () => request<AccessThresholdConfig>('/api/v2/access/thresholds'),
|
||||
}, signal)),
|
||||
accessThresholds: (signal?: AbortSignal) => request<AccessThresholdConfig>('/api/v2/access/thresholds', withSignal(undefined, signal)),
|
||||
updateAccessThresholds: (update: AccessThresholdUpdate) => request<AccessThresholdConfig>('/api/v2/access/thresholds', {
|
||||
method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(update)
|
||||
}),
|
||||
alertSummaryV2: (query: AlertQuery) => request<AlertSummary>('/api/v2/alerts/summary', {
|
||||
alertSummaryV2: (query: AlertQuery, signal?: AbortSignal) => request<AlertSummary>('/api/v2/alerts/summary', withSignal({
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(query)
|
||||
}),
|
||||
alertEventsV2: (query: AlertQuery) => request<Page<AlertEvent>>('/api/v2/alerts/events', {
|
||||
}, signal)),
|
||||
alertEventsV2: (query: AlertQuery, signal?: AbortSignal) => request<Page<AlertEvent>>('/api/v2/alerts/events', withSignal({
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(query)
|
||||
}),
|
||||
alertEventV2: (id: string) => request<AlertEvent>(`/api/v2/alerts/events/${encodeURIComponent(id)}`),
|
||||
}, signal)),
|
||||
alertEventV2: (id: string, signal?: AbortSignal) => request<AlertEvent>(`/api/v2/alerts/events/${encodeURIComponent(id)}`, withSignal(undefined, signal)),
|
||||
actOnAlertV2: (id: string, action: Pick<AlertAction, 'action' | 'note'> & { version: number; actor?: string }) => request<AlertEvent>(`/api/v2/alerts/events/${encodeURIComponent(id)}/actions`, {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(action)
|
||||
}),
|
||||
alertRulesV2: () => request<AlertRule[]>('/api/v2/alerts/rules'),
|
||||
alertRulesV2: (signal?: AbortSignal) => request<AlertRule[]>('/api/v2/alerts/rules', withSignal(undefined, signal)),
|
||||
saveAlertRuleV2: (input: AlertRuleInput) => request<AlertRule>(input.version > 0 ? `/api/v2/alerts/rules/${encodeURIComponent(input.id)}` : '/api/v2/alerts/rules', {
|
||||
method: input.version > 0 ? 'PUT' : 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(input)
|
||||
}),
|
||||
setAlertRuleEnabledV2: (id: string, update: { version: number; enabled: boolean; actor?: string }) => request<AlertRule>(`/api/v2/alerts/rules/${encodeURIComponent(id)}/enabled`, {
|
||||
method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(update)
|
||||
}),
|
||||
alertNotificationsV2: (params = new URLSearchParams()) => request<Page<AlertNotification>>(`/api/v2/alerts/notifications?${params.toString()}`),
|
||||
alertNotificationsV2: (params = new URLSearchParams(), signal?: AbortSignal) => request<Page<AlertNotification>>(`/api/v2/alerts/notifications?${params.toString()}`, withSignal(undefined, signal)),
|
||||
readAlertNotificationsV2: (ids: number[]) => request<{ updated: number }>('/api/v2/alerts/notifications/read', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ ids })
|
||||
}),
|
||||
@@ -175,9 +179,9 @@ export const api = {
|
||||
vehicleResolve: (params = new URLSearchParams()) => request<VehicleIdentityResolution>(`/api/vehicles/resolve?${params.toString()}`),
|
||||
vehicleCoverage: (params = new URLSearchParams(), signal?: AbortSignal) => request<Page<VehicleCoverageRow>>(`/api/vehicles/coverage?${params.toString()}`, signal ? { signal } : undefined),
|
||||
vehicleCoverageSummary: (params = new URLSearchParams()) => request<VehicleCoverageSummary>(`/api/vehicles/coverage/summary?${params.toString()}`),
|
||||
vehicleDetail: (params = new URLSearchParams()) => request<VehicleDetail>(`/api/vehicle-service?${params.toString()}`),
|
||||
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) => request<LatestTelemetryResponse>(`/api/v2/vehicles/${encodeURIComponent(vin)}/telemetry/latest`),
|
||||
latestTelemetry: (vin: string, signal?: AbortSignal) => request<LatestTelemetryResponse>(`/api/v2/vehicles/${encodeURIComponent(vin)}/telemetry/latest`, 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)
|
||||
}),
|
||||
@@ -211,7 +215,7 @@ export const api = {
|
||||
alertEventSummary: (params = new URLSearchParams()) => request<QualitySummary>(`/api/alert-events/summary?${params.toString()}`),
|
||||
alertEvents: (params = new URLSearchParams()) => request<Page<QualityIssueRow>>(`/api/alert-events?${params.toString()}`),
|
||||
alertEventNotificationPlan: (params = new URLSearchParams()) => request<QualityNotificationPlan>(`/api/alert-events/notification-plan?${params.toString()}`),
|
||||
reverseGeocode: (params = new URLSearchParams()) => request<MapReverseGeocode>(`/api/map/reverse-geocode?${params.toString()}`),
|
||||
opsHealth: () => request<OpsHealth>('/api/ops/health'),
|
||||
sourceReadiness: () => request<SourceReadinessPlan>('/api/ops/source-readiness')
|
||||
reverseGeocode: (params = new URLSearchParams(), signal?: AbortSignal) => request<MapReverseGeocode>(`/api/map/reverse-geocode?${params.toString()}`, withSignal(undefined, signal)),
|
||||
opsHealth: (signal?: AbortSignal) => request<OpsHealth>('/api/ops/health', withSignal(undefined, signal)),
|
||||
sourceReadiness: (signal?: AbortSignal) => request<SourceReadinessPlan>('/api/ops/source-readiness', withSignal(undefined, signal))
|
||||
};
|
||||
|
||||
@@ -4,6 +4,7 @@ import { AppShell } from './layout/AppShell';
|
||||
import { AuthGate } from './auth/AuthGate';
|
||||
import { RoutePage } from './routing/RouteBoundary';
|
||||
import { RoutePages } from './routing/routeModules';
|
||||
import { ROUTER_FUTURE } from './routing/routerConfig';
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
@@ -19,7 +20,7 @@ const queryClient = new QueryClient({
|
||||
export function AppV2() {
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<AuthGate><BrowserRouter future={{ v7_startTransition: true, v7_relativeSplatPath: true }}>
|
||||
<AuthGate><BrowserRouter future={ROUTER_FUTURE}>
|
||||
<Routes>
|
||||
<Route element={<AppShell />}>
|
||||
<Route index element={<Navigate to="/monitor" replace />} />
|
||||
|
||||
@@ -22,7 +22,7 @@ export function AuthGate({ children }: { children: ReactNode }) {
|
||||
const [attempted, setAttempted] = useState(() => Boolean(getAccessToken()));
|
||||
const session = useQuery({
|
||||
queryKey: ['platform-session', tokenVersion],
|
||||
queryFn: api.session,
|
||||
queryFn: ({ signal }) => api.session(signal),
|
||||
retry: false,
|
||||
staleTime: Infinity
|
||||
});
|
||||
|
||||
@@ -104,23 +104,23 @@ export function useMonitorVehicleCard(vin: string, vehicle?: VehicleRealtimeRow,
|
||||
|
||||
const detail = useQuery({
|
||||
queryKey: ['monitor', 'vehicle-card', 'detail', vin],
|
||||
queryFn: () => api.vehicleDetail(new URLSearchParams({ keyword: vin })),
|
||||
queryFn: ({ signal }) => api.vehicleDetail(new URLSearchParams({ keyword: vin }), signal),
|
||||
enabled,
|
||||
staleTime: 30_000
|
||||
});
|
||||
const activeAlerts = useQuery({
|
||||
queryKey: ['monitor', 'vehicle-card', 'active-alerts', vin],
|
||||
queryFn: () => api.alertEventsV2({ keyword: vin, status: 'active', limit: 20, offset: 0 }),
|
||||
queryFn: ({ signal }) => api.alertEventsV2({ keyword: vin, status: 'active', limit: 20, offset: 0 }, signal),
|
||||
enabled,
|
||||
staleTime: 10_000,
|
||||
refetchInterval: enabled && activelyTracked ? MONITOR_REFRESH.alerts : false
|
||||
});
|
||||
const address = useQuery({
|
||||
queryKey: ['monitor', 'vehicle-card', 'address', longitude, latitude],
|
||||
queryFn: () => api.reverseGeocode(new URLSearchParams({
|
||||
queryFn: ({ signal }) => api.reverseGeocode(new URLSearchParams({
|
||||
longitude: longitude!.toFixed(6),
|
||||
latitude: latitude!.toFixed(6)
|
||||
})),
|
||||
}), signal),
|
||||
enabled: enabled && hasCoordinate,
|
||||
staleTime: 60 * 60_000
|
||||
});
|
||||
|
||||
@@ -8,6 +8,7 @@ import { accessRowsToCSV, formatAccessTime, formatSeconds, updateProtocolThresho
|
||||
import { InlineError } from '../shared/AsyncState';
|
||||
import { usePlatformSession } from '../auth/AuthGate';
|
||||
import { canAdminister } from '../auth/session';
|
||||
import { QUERY_MEMORY } from '../queryPolicy';
|
||||
|
||||
const PROTOCOLS = ['GB32960', 'JT808', 'YUTONG_MQTT'] as const;
|
||||
const EMPTY_FILTERS = { keyword: '', protocol: '', oem: '', connectionState: '', onlineState: '', model: '', provider: '', firstSeenFrom: '', firstSeenTo: '', latestSeenFrom: '', latestSeenTo: '', delayState: '' };
|
||||
@@ -99,10 +100,10 @@ export default function AccessPage() {
|
||||
const [offset, setOffset] = useState(0); const [limit, setLimit] = useState(50); const [selectedVIN, setSelectedVIN] = useState('');
|
||||
const [thresholdDraft, setThresholdDraft] = useState<AccessThresholdUpdate>(); const queryClient = useQueryClient();
|
||||
const baseQuery = useMemo(() => Object.fromEntries(Object.entries(criteria).filter(([, value]) => value)) as AccessQuery, [criteria]);
|
||||
const summaryQuery = useQuery({ queryKey: ['access-summary'], queryFn: () => api.accessSummary({}), staleTime: 15_000 });
|
||||
const vehiclesQuery = useQuery({ queryKey: ['access-vehicles', baseQuery, limit, offset], queryFn: () => api.accessVehicles({ ...baseQuery, limit, offset }), placeholderData: (previous) => previous });
|
||||
const unresolvedQuery = useQuery({ queryKey: ['access-unresolved-identities', criteria.keyword, criteria.protocol], queryFn: () => api.accessUnresolvedIdentities({ keyword: criteria.keyword || undefined, protocol: criteria.protocol || undefined, limit: 20, offset: 0 }), staleTime: 15_000 });
|
||||
const thresholdQuery = useQuery({ queryKey: ['access-thresholds'], queryFn: api.accessThresholds, staleTime: 60_000 });
|
||||
const summaryQuery = useQuery({ queryKey: ['access-summary'], queryFn: ({ signal }) => api.accessSummary({}, signal), staleTime: 15_000, gcTime: QUERY_MEMORY.summaryGcTime });
|
||||
const vehiclesQuery = useQuery({ queryKey: ['access-vehicles', baseQuery, limit, offset], queryFn: ({ signal }) => api.accessVehicles({ ...baseQuery, limit, offset }, signal), placeholderData: (previous) => previous, gcTime: QUERY_MEMORY.highVolumeGcTime });
|
||||
const unresolvedQuery = useQuery({ queryKey: ['access-unresolved-identities', criteria.keyword, criteria.protocol], queryFn: ({ signal }) => api.accessUnresolvedIdentities({ keyword: criteria.keyword || undefined, protocol: criteria.protocol || undefined, limit: 20, offset: 0 }, signal), staleTime: 15_000, gcTime: QUERY_MEMORY.summaryGcTime });
|
||||
const thresholdQuery = useQuery({ queryKey: ['access-thresholds'], queryFn: ({ signal }) => api.accessThresholds(signal), staleTime: 60_000, gcTime: QUERY_MEMORY.summaryGcTime });
|
||||
useEffect(() => { if (thresholdQuery.data && !thresholdDraft) setThresholdDraft({ version: thresholdQuery.data.version, defaultThresholdSec: thresholdQuery.data.defaultThresholdSec, delayThresholdSec: thresholdQuery.data.delayThresholdSec, longOfflineSec: thresholdQuery.data.longOfflineSec, protocols: thresholdQuery.data.protocols }); }, [thresholdDraft, thresholdQuery.data]);
|
||||
const updateThreshold = useMutation({ mutationFn: api.updateAccessThresholds, onSuccess: async (config) => { setThresholdDraft({ version: config.version, defaultThresholdSec: config.defaultThresholdSec, delayThresholdSec: config.delayThresholdSec, longOfflineSec: config.longOfflineSec, protocols: config.protocols }); await Promise.all([queryClient.invalidateQueries({ queryKey: ['access-summary'] }), queryClient.invalidateQueries({ queryKey: ['access-vehicles'] })]); } });
|
||||
const rows = vehiclesQuery.data?.items ?? []; const selected = rows.find((row) => row.vin === selectedVIN);
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { cleanup, fireEvent, render, screen } from '@testing-library/react';
|
||||
import { afterEach, expect, test, vi } from 'vitest';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import AlertsPage from './AlertsPage';
|
||||
import { ROUTER_FUTURE } from '../routing/routerConfig';
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
alertSummaryV2: vi.fn(), alertEventsV2: vi.fn(), alertEventV2: vi.fn(), actOnAlertV2: vi.fn(),
|
||||
alertRulesV2: vi.fn(), metricCatalog: vi.fn(), alertNotificationsV2: vi.fn(), readAlertNotificationsV2: vi.fn(),
|
||||
saveAlertRuleV2: vi.fn(), setAlertRuleEnabledV2: vi.fn()
|
||||
}));
|
||||
vi.mock('../../api/client', () => ({ api: mocks }));
|
||||
vi.mock('../auth/AuthGate', () => ({ usePlatformSession: () => ({ session: { role: 'admin' } }) }));
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
Object.values(mocks).forEach((mock) => mock.mockReset());
|
||||
});
|
||||
|
||||
test('preserves an unsubmitted event filter draft across alert tab URL changes', async () => {
|
||||
mocks.alertSummaryV2.mockResolvedValue({ active: 0, unprocessed: 0, processing: 0, recovered: 0, closed: 0, ignored: 0, unreadNotifications: 0, asOf: '' });
|
||||
mocks.alertEventsV2.mockResolvedValue({ items: [], total: 0, limit: 20, offset: 0 });
|
||||
mocks.alertRulesV2.mockResolvedValue([]);
|
||||
mocks.metricCatalog.mockResolvedValue({ metrics: [], asOf: '' });
|
||||
mocks.alertNotificationsV2.mockResolvedValue({ items: [], total: 0, limit: 100, offset: 0 });
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
render(<QueryClientProvider client={client}><MemoryRouter future={ROUTER_FUTURE} initialEntries={['/alerts']}><AlertsPage /></MemoryRouter></QueryClientProvider>);
|
||||
|
||||
const keyword = await screen.findByPlaceholderText('车牌 / VIN / 规则名称');
|
||||
fireEvent.change(keyword, { target: { value: '保留筛选草稿' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: /站内通知/ }));
|
||||
expect(await screen.findByText('仅站内通道具备真实送达与已读状态')).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole('button', { name: /告警事件/ }));
|
||||
|
||||
expect(await screen.findByPlaceholderText('车牌 / VIN / 规则名称')).toHaveValue('保留筛选草稿');
|
||||
});
|
||||
@@ -8,6 +8,7 @@ import { actionLabels, alertValue, canAct, formatAlertTime, operatorLabels, rule
|
||||
import { InlineError } from '../shared/AsyncState';
|
||||
import { usePlatformSession } from '../auth/AuthGate';
|
||||
import { canAdminister, canOperate } from '../auth/session';
|
||||
import { QUERY_MEMORY } from '../queryPolicy';
|
||||
|
||||
type Tab = 'events' | 'rules' | 'notifications';
|
||||
type Filters = { keyword: string; severity: string; status: string; ruleId: string; protocol: string; dateFrom: string; dateTo: string };
|
||||
@@ -38,15 +39,15 @@ function EventInspector({ event, note, acting, actionError, editable, onNote, on
|
||||
</aside>;
|
||||
}
|
||||
|
||||
function EventWorkspace({ filters, setFilters, rules, unread, editable, onTab }: { filters: Filters; setFilters: (next: Filters) => void; rules: AlertRule[]; unread: number; editable: boolean; onTab: (tab: Tab) => void }) {
|
||||
const [draft, setDraft] = useState(filters); const [offset, setOffset] = useState(0); const [limit, setLimit] = useState(20); const [selectedID, setSelectedID] = useState(''); const [note, setNote] = useState(''); const queryClient = useQueryClient();
|
||||
function EventWorkspace({ filters, draft, setDraft, setFilters, rules, unread, editable, onTab }: { filters: Filters; draft: Filters; setDraft: (next: Filters) => void; setFilters: (next: Filters) => void; rules: AlertRule[]; unread: number; editable: boolean; onTab: (tab: Tab) => void }) {
|
||||
const [offset, setOffset] = useState(0); const [limit, setLimit] = useState(20); const [selectedID, setSelectedID] = useState(''); const [note, setNote] = useState(''); const queryClient = useQueryClient();
|
||||
const query: AlertQuery = useMemo(() => ({ ...Object.fromEntries(Object.entries(filters).filter(([, value]) => value)), limit, offset }), [filters, limit, offset]);
|
||||
const baseQuery = useMemo(() => ({ ...query, limit: undefined, offset: undefined }), [query]);
|
||||
const summary = useQuery({ queryKey: ['alert-summary-v2', baseQuery], queryFn: () => api.alertSummaryV2(baseQuery), staleTime: 8_000 });
|
||||
const events = useQuery({ queryKey: ['alert-events-v2', query], queryFn: () => api.alertEventsV2(query), placeholderData: (previous) => previous, staleTime: 5_000 });
|
||||
const summary = useQuery({ queryKey: ['alert-summary-v2', baseQuery], queryFn: ({ signal }) => api.alertSummaryV2(baseQuery, signal), staleTime: 8_000, gcTime: QUERY_MEMORY.summaryGcTime });
|
||||
const events = useQuery({ queryKey: ['alert-events-v2', query], queryFn: ({ signal }) => api.alertEventsV2(query, signal), placeholderData: (previous) => previous, staleTime: 5_000, gcTime: QUERY_MEMORY.highVolumeGcTime });
|
||||
const rows = events.data?.items ?? [];
|
||||
useEffect(() => { if (rows.length && !rows.some((item) => item.id === selectedID)) setSelectedID(rows[0].id); }, [rows, selectedID]);
|
||||
const detail = useQuery({ queryKey: ['alert-event-v2', selectedID], queryFn: () => api.alertEventV2(selectedID), enabled: Boolean(selectedID), staleTime: 3_000 });
|
||||
const detail = useQuery({ queryKey: ['alert-event-v2', selectedID], queryFn: ({ signal }) => api.alertEventV2(selectedID, signal), enabled: Boolean(selectedID), staleTime: 3_000, gcTime: QUERY_MEMORY.summaryGcTime });
|
||||
const action = useMutation({ mutationFn: ({ name, event }: { name: 'acknowledge' | 'close' | 'ignore'; event: AlertEvent }) => api.actOnAlertV2(event.id, { version: event.version, action: name, note }), onSuccess: async (event) => { setNote(''); queryClient.setQueryData(['alert-event-v2', event.id], event); await Promise.all([queryClient.invalidateQueries({ queryKey: ['alert-events-v2'] }), queryClient.invalidateQueries({ queryKey: ['alert-summary-v2'] }), queryClient.invalidateQueries({ queryKey: ['alert-notifications-v2'] })]); } });
|
||||
const submit = (e: FormEvent) => { e.preventDefault(); setFilters(draft); setOffset(0); };
|
||||
const quickStatus = (status: string) => { const next = { ...filters, status }; setDraft(next); setFilters(next); setOffset(0); };
|
||||
@@ -109,7 +110,7 @@ function RulesWorkspace({ rules, metrics }: { rules: AlertRule[]; metrics: Metri
|
||||
}
|
||||
|
||||
function NotificationsWorkspace({ editable }: { editable: boolean }) {
|
||||
const queryClient = useQueryClient(); const notifications = useQuery({ queryKey: ['alert-notifications-v2', 'all'], queryFn: () => api.alertNotificationsV2(new URLSearchParams({ limit: '100' })), staleTime: 5_000 });
|
||||
const queryClient = useQueryClient(); const notifications = useQuery({ queryKey: ['alert-notifications-v2', 'all'], queryFn: ({ signal }) => api.alertNotificationsV2(new URLSearchParams({ limit: '100' }), signal), staleTime: 5_000, gcTime: QUERY_MEMORY.summaryGcTime });
|
||||
const read = useMutation({ mutationFn: api.readAlertNotificationsV2, onSuccess: async () => { await Promise.all([queryClient.invalidateQueries({ queryKey: ['alert-notifications-v2'] }), queryClient.invalidateQueries({ queryKey: ['alert-summary-v2'] })]); } });
|
||||
return <div className="v2-alert-notifications"><header><div><strong>站内通知</strong><span>仅站内通道具备真实送达与已读状态</span></div>{editable ? <button disabled={!notifications.data?.items.some((item) => !item.read)} onClick={() => read.mutate(notifications.data?.items.filter((item) => !item.read).map((item) => item.id) ?? [])}>全部标为已读</button> : <span className="v2-role-badge">只读</span>}</header>{notifications.isError ? <InlineError message={notifications.error.message} onRetry={() => notifications.refetch()} /> : null}<div>{notifications.data?.items.map((item) => <article className={item.read ? 'is-read' : ''} key={item.id}><i className={`is-${item.severity}`} /><div><strong>{item.title}</strong><p>{item.content}</p><span>{formatAlertTime(item.createdAt)} · {item.read ? '已读' : '未读'}</span></div>{editable && !item.read ? <button onClick={() => read.mutate([item.id])}>标为已读</button> : null}</article>)}</div><footer><b>外部通知通道</b><span>短信(SMS)— 预留 / 未启用</span><span>邮件(Email)— 预留 / 未启用</span><span>企业微信(WeCom)— 预留 / 未启用</span></footer></div>;
|
||||
}
|
||||
@@ -118,9 +119,9 @@ export default function AlertsPage() {
|
||||
const { session } = usePlatformSession(); const operator = canOperate(session); const admin = canAdminister(session);
|
||||
const [params, setParams] = useSearchParams(); const initialTab = (params.get('tab') as Tab) || 'events'; const [tab, setTabState] = useState<Tab>(['events', 'rules', 'notifications'].includes(initialTab) ? initialTab : 'events');
|
||||
const initialFilters: Filters = { ...EMPTY_FILTERS, keyword: params.get('vin') ?? params.get('keyword') ?? '', severity: params.get('severity') ?? '', status: params.get('status') ?? '', ruleId: params.get('ruleId') ?? '', protocol: params.get('protocol') ?? '' };
|
||||
const [filters, setFilterState] = useState(initialFilters); const rules = useQuery({ queryKey: ['alert-rules-v2'], queryFn: api.alertRulesV2, staleTime: 30_000 }); const metrics = useQuery({ queryKey: ['metric-catalog-v2'], queryFn: api.metricCatalog, staleTime: 300_000, enabled: admin }); const notices = useQuery({ queryKey: ['alert-notifications-v2', 'unread'], queryFn: () => api.alertNotificationsV2(new URLSearchParams({ unreadOnly: 'true', limit: '100' })), staleTime: 5_000 });
|
||||
const [filters, setFilterState] = useState(initialFilters); const [eventDraft, setEventDraft] = useState(initialFilters); const rules = useQuery({ queryKey: ['alert-rules-v2'], queryFn: ({ signal }) => api.alertRulesV2(signal), staleTime: 30_000, gcTime: QUERY_MEMORY.summaryGcTime }); const metrics = useQuery({ queryKey: ['metric-catalog-v2'], queryFn: ({ signal }) => api.metricCatalog(signal), staleTime: 300_000, enabled: admin }); const notices = useQuery({ queryKey: ['alert-notifications-v2', 'unread'], queryFn: ({ signal }) => api.alertNotificationsV2(new URLSearchParams({ unreadOnly: 'true', limit: '100' }), signal), staleTime: 5_000, gcTime: QUERY_MEMORY.summaryGcTime });
|
||||
const setTab = (next: Tab) => { setTabState(next); const copy = new URLSearchParams(params); copy.set('tab', next); setParams(copy, { replace: true }); };
|
||||
const setFilters = (next: Filters) => { setFilterState(next); const copy = new URLSearchParams(); if (tab !== 'events') copy.set('tab', tab); Object.entries(next).forEach(([key, value]) => { if (value) copy.set(key, value); }); setParams(copy, { replace: true }); };
|
||||
const activeTab = tab === 'rules' && !admin ? 'events' : tab;
|
||||
return <div className="v2-alert-page"><header className="v2-alert-heading"><div><h2>告警中心</h2><p>统一监控告警事件,快速发现并处置车辆运行异常,保留数据质量与处置证据。</p></div></header><nav className="v2-alert-tabs"><button className={activeTab === 'events' ? 'is-active' : ''} onClick={() => setTab('events')}><IconAlarm />告警事件</button>{admin ? <button className={activeTab === 'rules' ? 'is-active' : ''} onClick={() => setTab('rules')}>规则配置</button> : null}<button className={activeTab === 'notifications' ? 'is-active' : ''} onClick={() => setTab('notifications')}><IconBell />站内通知{(notices.data?.total ?? 0) > 0 ? <b>{notices.data?.total}</b> : null}</button></nav>{rules.isError ? <InlineError message={rules.error.message} onRetry={() => rules.refetch()} /> : null}{activeTab === 'rules' && metrics.isError ? <InlineError message={metrics.error.message} onRetry={() => metrics.refetch()} /> : null}{activeTab === 'events' ? <EventWorkspace filters={filters} setFilters={setFilters} rules={rules.data ?? []} unread={notices.data?.total ?? 0} editable={operator} onTab={setTab} /> : activeTab === 'rules' ? <RulesWorkspace rules={rules.data ?? []} metrics={metrics.data?.metrics ?? []} /> : <NotificationsWorkspace editable={operator} />}</div>;
|
||||
return <div className="v2-alert-page"><header className="v2-alert-heading"><div><h2>告警中心</h2><p>统一监控告警事件,快速发现并处置车辆运行异常,保留数据质量与处置证据。</p></div></header><nav className="v2-alert-tabs"><button className={activeTab === 'events' ? 'is-active' : ''} onClick={() => setTab('events')}><IconAlarm />告警事件</button>{admin ? <button className={activeTab === 'rules' ? 'is-active' : ''} onClick={() => setTab('rules')}>规则配置</button> : null}<button className={activeTab === 'notifications' ? 'is-active' : ''} onClick={() => setTab('notifications')}><IconBell />站内通知{(notices.data?.total ?? 0) > 0 ? <b>{notices.data?.total}</b> : null}</button></nav>{rules.isError ? <InlineError message={rules.error.message} onRetry={() => rules.refetch()} /> : null}{activeTab === 'rules' && metrics.isError ? <InlineError message={metrics.error.message} onRetry={() => metrics.refetch()} /> : null}{activeTab === 'events' ? <EventWorkspace filters={filters} draft={eventDraft} setDraft={setEventDraft} setFilters={setFilters} rules={rules.data ?? []} unread={notices.data?.total ?? 0} editable={operator} onTab={setTab} /> : activeTab === 'rules' ? <RulesWorkspace rules={rules.data ?? []} metrics={metrics.data?.metrics ?? []} /> : <NotificationsWorkspace editable={operator} />}</div>;
|
||||
}
|
||||
|
||||
@@ -42,7 +42,7 @@ function CreateExportButton({ request, disabled }: { request: HistoryExportReque
|
||||
}
|
||||
|
||||
function ExportJobsPanel() {
|
||||
const query = useQuery({ queryKey: ['history-exports'], queryFn: api.historyExports, refetchInterval: (current) => current.state.data?.some((job) => job.status === 'queued' || job.status === 'running') ? 1000 : false });
|
||||
const query = useQuery({ queryKey: ['history-exports'], queryFn: ({ signal }) => api.historyExports(signal), refetchInterval: (current) => current.state.data?.some((job) => job.status === 'queued' || job.status === 'running') ? 1000 : false });
|
||||
const jobs = query.data ?? [];
|
||||
return <section className="v2-export-jobs"><header><strong>导出任务</strong><span>{jobs.length}</span></header><div>{jobs.slice(0, 6).map((job) => <article key={job.id} title={job.evidence}><i className={`is-${job.status}`} /><div><strong>{job.name}</strong><small>{job.status === 'queued' ? '等待单并发执行' : job.status === 'running' ? `${job.processedRows.toLocaleString('zh-CN')} / ${job.totalRows.toLocaleString('zh-CN')} 行 · ${job.progress}%` : job.status === 'completed' ? `${job.rowCount.toLocaleString('zh-CN')} 行 · ${formatExportFileSize(job.fileSizeBytes)} · 已完成` : job.error || '失败'}</small></div>{job.downloadUrl ? <a href={job.downloadUrl}><IconDownload />下载</a> : <em>{job.status === 'running' ? `${job.progress}%` : '—'}</em>}</article>)}{query.isError ? <div className="v2-history-side-empty">导出任务加载失败</div> : !jobs.length ? <div className="v2-history-side-empty">尚未创建导出任务</div> : null}</div></section>;
|
||||
}
|
||||
@@ -79,7 +79,7 @@ export default function HistoryPage() {
|
||||
if (criteria.protocol) next.set('protocol', criteria.protocol);
|
||||
return next;
|
||||
}, [criteria, keywords]);
|
||||
const catalogQuery = useQuery({ queryKey: ['history-metric-catalog'], queryFn: api.historyMetricCatalog, staleTime: 30 * 60_000 });
|
||||
const catalogQuery = useQuery({ queryKey: ['history-metric-catalog'], queryFn: ({ signal }) => api.historyMetricCatalog(signal), staleTime: 30 * 60_000 });
|
||||
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;
|
||||
|
||||
@@ -5,6 +5,7 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import type { VehicleRealtimeRow } from '../../api/types';
|
||||
import { api } from '../../api/client';
|
||||
import MonitorPage from './MonitorPage';
|
||||
import { ROUTER_FUTURE } from '../routing/routerConfig';
|
||||
|
||||
const vehicles = [{
|
||||
vin: 'LTEST000000000001', plate: '粤A12345', protocols: ['JT808'], primaryProtocol: 'JT808',
|
||||
@@ -59,7 +60,7 @@ afterEach(() => {
|
||||
|
||||
test('starts without a selection and supports expand, collapse, reselection, and clear', () => {
|
||||
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
const view = render(<QueryClientProvider client={queryClient}><MemoryRouter><MonitorPage /></MemoryRouter></QueryClientProvider>);
|
||||
const view = render(<QueryClientProvider client={queryClient}><MemoryRouter future={ROUTER_FUTURE}><MonitorPage /></MemoryRouter></QueryClientProvider>);
|
||||
const workspace = view.container.querySelector('.v2-monitor-workspace')!;
|
||||
const firstVehicle = screen.getByRole('button', { name: /粤A12345 LTEST000000000001/ });
|
||||
const secondVehicle = screen.getByRole('button', { name: /粤B67890 LTEST000000000002/ });
|
||||
@@ -106,7 +107,7 @@ test('switches to a lightweight realtime list and resolves addresses only on dem
|
||||
vi.spyOn(api, 'vehicleRealtime').mockResolvedValue({ items: [row], total: 1, limit: 50, offset: 0 });
|
||||
const reverseGeocode = vi.spyOn(api, 'reverseGeocode').mockResolvedValue({ provider: 'AMap', longitude: 113.26, latitude: 23.13, formattedAddress: '广东省广州市天河区测试路' });
|
||||
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
render(<QueryClientProvider client={queryClient}><MemoryRouter><MonitorPage /></MemoryRouter></QueryClientProvider>);
|
||||
render(<QueryClientProvider client={queryClient}><MemoryRouter future={ROUTER_FUTURE}><MonitorPage /></MemoryRouter></QueryClientProvider>);
|
||||
fireEvent.click(screen.getByRole('button', { name: /列表/ }));
|
||||
expect(await screen.findByText('车辆实时列表')).toBeInTheDocument();
|
||||
expect(screen.getByText('速度、里程和坐标实时刷新,文字地址按需解析')).toBeInTheDocument();
|
||||
@@ -127,7 +128,7 @@ test('switches to a lightweight realtime list and resolves addresses only on dem
|
||||
test('pastes, deduplicates, and submits multiple plates as one batch search', async () => {
|
||||
const vehicleRealtime = vi.spyOn(api, 'vehicleRealtime').mockResolvedValue({ items: vehicles, total: 2, limit: 50, offset: 0 });
|
||||
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
render(<QueryClientProvider client={queryClient}><MemoryRouter><MonitorPage /></MemoryRouter></QueryClientProvider>);
|
||||
render(<QueryClientProvider client={queryClient}><MemoryRouter future={ROUTER_FUTURE}><MonitorPage /></MemoryRouter></QueryClientProvider>);
|
||||
|
||||
const input = screen.getByRole('textbox', { name: '搜索车辆' });
|
||||
fireEvent.paste(input, { clipboardData: { getData: () => '粤a12345\n粤B67890,粤A12345' } });
|
||||
@@ -145,7 +146,7 @@ test('pastes, deduplicates, and submits multiple plates as one batch search', as
|
||||
test('hides stale fleet rows while a pasted batch is still loading', () => {
|
||||
monitorQueryFlags.isPlaceholderData = true;
|
||||
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
render(<QueryClientProvider client={queryClient}><MemoryRouter><MonitorPage /></MemoryRouter></QueryClientProvider>);
|
||||
render(<QueryClientProvider client={queryClient}><MemoryRouter future={ROUTER_FUTURE}><MonitorPage /></MemoryRouter></QueryClientProvider>);
|
||||
|
||||
fireEvent.paste(screen.getByRole('textbox', { name: '搜索车辆' }), { clipboardData: { getData: () => '粤A12345\n粤B67890' } });
|
||||
expect(screen.getByText('正在查找 2 辆车')).toBeInTheDocument();
|
||||
@@ -155,7 +156,7 @@ test('hides stale fleet rows while a pasted batch is still loading', () => {
|
||||
|
||||
test('reports pasted plates that have no exact realtime match', () => {
|
||||
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
render(<QueryClientProvider client={queryClient}><MemoryRouter><MonitorPage /></MemoryRouter></QueryClientProvider>);
|
||||
render(<QueryClientProvider client={queryClient}><MemoryRouter future={ROUTER_FUTURE}><MonitorPage /></MemoryRouter></QueryClientProvider>);
|
||||
|
||||
fireEvent.paste(screen.getByRole('textbox', { name: '搜索车辆' }), { clipboardData: { getData: () => '粤A12345\n粤Z99999' } });
|
||||
expect(screen.getByText('已找到 1/2')).toBeInTheDocument();
|
||||
|
||||
@@ -30,10 +30,10 @@ const MonitorAddressCell = memo(function MonitorAddressCell({ vehicle }: { vehic
|
||||
const [requested, setRequested] = useState<AddressCoordinate>();
|
||||
const addressQuery = useQuery({
|
||||
queryKey: ['monitor', 'list-address', requested?.key],
|
||||
queryFn: () => api.reverseGeocode(new URLSearchParams({
|
||||
queryFn: ({ signal }) => api.reverseGeocode(new URLSearchParams({
|
||||
longitude: requested!.longitude.toFixed(4),
|
||||
latitude: requested!.latitude.toFixed(4)
|
||||
})),
|
||||
}), signal),
|
||||
enabled: Boolean(requested),
|
||||
staleTime: 6 * 60 * 60_000,
|
||||
gcTime: 24 * 60 * 60_000,
|
||||
|
||||
@@ -8,8 +8,8 @@ function statusLabel(status: string) {
|
||||
}
|
||||
|
||||
export default function OperationsPage() {
|
||||
const health = useQuery({ queryKey: ['ops-health-v2'], queryFn: api.opsHealth, refetchInterval: 15_000, staleTime: 8_000 });
|
||||
const readiness = useQuery({ queryKey: ['ops-source-readiness-v2'], queryFn: api.sourceReadiness, refetchInterval: 30_000, staleTime: 15_000 });
|
||||
const health = useQuery({ queryKey: ['ops-health-v2'], queryFn: ({ signal }) => api.opsHealth(signal), refetchInterval: 15_000, staleTime: 8_000 });
|
||||
const readiness = useQuery({ queryKey: ['ops-source-readiness-v2'], queryFn: ({ signal }) => api.sourceReadiness(signal), refetchInterval: 30_000, staleTime: 15_000 });
|
||||
const data = health.data; const sources = readiness.data;
|
||||
const refresh = () => Promise.all([health.refetch(), readiness.refetch()]);
|
||||
return <div className="v2-ops-page">
|
||||
|
||||
@@ -3,6 +3,7 @@ import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/re
|
||||
import { afterEach, expect, test, vi } from 'vitest';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import StatisticsPage from './StatisticsPage';
|
||||
import { ROUTER_FUTURE } from '../routing/routerConfig';
|
||||
|
||||
const mocks = vi.hoisted(() => ({ mileageStatistics: vi.fn(), dailyMileage: vi.fn(), vehicles: vi.fn(), vehicleCoverage: vi.fn() }));
|
||||
const exportMocks = vi.hoisted(() => ({ downloadMileageWorkbook: vi.fn() }));
|
||||
@@ -13,7 +14,7 @@ afterEach(() => { cleanup(); window.localStorage.clear(); Object.values(mocks).f
|
||||
|
||||
function renderPage(initialEntry = '/statistics') {
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
return render(<QueryClientProvider client={client}><MemoryRouter initialEntries={[initialEntry]}><StatisticsPage /></MemoryRouter></QueryClientProvider>);
|
||||
return render(<QueryClientProvider client={client}><MemoryRouter future={ROUTER_FUTURE} initialEntries={[initialEntry]}><StatisticsPage /></MemoryRouter></QueryClientProvider>);
|
||||
}
|
||||
|
||||
function prepareData() {
|
||||
|
||||
@@ -4,6 +4,7 @@ import { afterEach, beforeEach, expect, test, vi } from 'vitest';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import type { TrackPlaybackResponse } from '../../api/types';
|
||||
import TrackPage from './TrackPage';
|
||||
import { ROUTER_FUTURE } from '../routing/routerConfig';
|
||||
|
||||
const mocks = vi.hoisted(() => ({ trackPlayback: vi.fn(), reverseGeocode: vi.fn(), vehicles: vi.fn() }));
|
||||
vi.mock('../../api/client', () => ({ api: mocks }));
|
||||
@@ -39,7 +40,7 @@ afterEach(() => { cleanup(); Object.values(mocks).forEach((mock) => mock.mockRes
|
||||
|
||||
test('renders a map-first replay workspace and connects stop, event, and panel interactions', async () => {
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
const view = render(<QueryClientProvider client={client}><MemoryRouter initialEntries={['/tracks?keyword=LTEST000000000001&dateFrom=2026-07-15T00:00&dateTo=2026-07-15T23:59']}><TrackPage /></MemoryRouter></QueryClientProvider>);
|
||||
const view = render(<QueryClientProvider client={client}><MemoryRouter future={ROUTER_FUTURE} initialEntries={['/tracks?keyword=LTEST000000000001&dateFrom=2026-07-15T00:00&dateTo=2026-07-15T23:59']}><TrackPage /></MemoryRouter></QueryClientProvider>);
|
||||
|
||||
expect((await screen.findAllByText('粤A12345')).length).toBeGreaterThan(0);
|
||||
expect(screen.getByTestId('track-map')).toHaveAttribute('data-active-index', '0');
|
||||
|
||||
@@ -208,7 +208,7 @@ export default function TrackPage() {
|
||||
const addressQuery = useQuery({
|
||||
queryKey: ['track-address', addressPoint?.longitude.toFixed(6), addressPoint?.latitude.toFixed(6)],
|
||||
enabled: Boolean(addressPoint), staleTime: 60 * 60 * 1000,
|
||||
queryFn: () => api.reverseGeocode(new URLSearchParams({ longitude: addressPoint!.longitude.toFixed(6), latitude: addressPoint!.latitude.toFixed(6) }))
|
||||
queryFn: ({ signal }) => api.reverseGeocode(new URLSearchParams({ longitude: addressPoint!.longitude.toFixed(6), latitude: addressPoint!.latitude.toFixed(6) }), signal)
|
||||
});
|
||||
|
||||
useEffect(() => { setActiveIndex(0); setPlaying(false); setFollow(true); if (track?.points.length) setRailCollapsed(window.matchMedia('(max-width: 700px)').matches); }, [track?.asOf]);
|
||||
|
||||
@@ -9,6 +9,7 @@ import { api } from '../../api/client';
|
||||
import type { LatestTelemetryResponse, QualityIssueRow, VehicleDetail, VehicleProfileSyncItem, VehicleProfileSyncResult } from '../../api/types';
|
||||
import { usePlatformSession } from '../auth/AuthGate';
|
||||
import { canAdminister } from '../auth/session';
|
||||
import { QUERY_MEMORY } from '../queryPolicy';
|
||||
import { formatTelemetryTime, formatTelemetryValue, telemetryQualityLabel } from '../domain/telemetry';
|
||||
import { parseVehicleProfileSyncCSV, vehicleProfileSyncCSVHeader } from '../domain/profileSync';
|
||||
import { FleetMap } from '../map/FleetMap';
|
||||
@@ -195,8 +196,8 @@ function VehicleRecord({ detail, telemetry, telemetryPending, telemetryError, on
|
||||
|
||||
export default function VehiclePage() {
|
||||
const { vin } = useParams();
|
||||
const query = useQuery({ queryKey: ['vehicle-detail', vin], enabled: Boolean(vin), queryFn: () => api.vehicleDetail(new URLSearchParams({ keyword: vin!, limit: '20' })) });
|
||||
const telemetry = useQuery({ queryKey: ['vehicle-latest-telemetry', vin], enabled: Boolean(vin), queryFn: () => api.latestTelemetry(vin!), staleTime: 10_000, refetchInterval: 20_000, refetchIntervalInBackground: false });
|
||||
const query = useQuery({ queryKey: ['vehicle-detail', vin], enabled: Boolean(vin), queryFn: ({ signal }) => api.vehicleDetail(new URLSearchParams({ keyword: vin!, limit: '20' }), signal), gcTime: QUERY_MEMORY.summaryGcTime });
|
||||
const telemetry = useQuery({ queryKey: ['vehicle-latest-telemetry', vin], enabled: Boolean(vin), queryFn: ({ signal }) => api.latestTelemetry(vin!, signal), staleTime: 10_000, gcTime: QUERY_MEMORY.summaryGcTime, refetchInterval: 20_000, refetchIntervalInBackground: false });
|
||||
if (!vin) return <VehicleSearch />;
|
||||
if (query.isPending) return <PageLoading />;
|
||||
if (query.isError) return <div className="v2-page-error"><InlineError message={query.error instanceof Error ? query.error.message : '车辆档案加载失败'} onRetry={() => query.refetch()} /></div>;
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { cleanup, render, screen } from '@testing-library/react';
|
||||
import { cleanup, fireEvent, render, screen } from '@testing-library/react';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { MemoryRouter, useSearchParams } from 'react-router-dom';
|
||||
import { isRouteChunkError, RoutePage } from './RouteBoundary';
|
||||
import { ROUTER_FUTURE } from './routerConfig';
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
@@ -19,7 +21,7 @@ describe('route recovery boundary', () => {
|
||||
it('isolates a page render failure and keeps a visible recovery action', () => {
|
||||
vi.spyOn(console, 'error').mockImplementation(() => undefined);
|
||||
const BrokenPage = () => { throw new Error('render exploded'); };
|
||||
render(<MemoryRouter initialEntries={['/history']}><RoutePage page={BrokenPage} label="历史数据" /></MemoryRouter>);
|
||||
render(<MemoryRouter future={ROUTER_FUTURE} initialEntries={['/history']}><RoutePage page={BrokenPage} label="历史数据" /></MemoryRouter>);
|
||||
expect(screen.getByRole('alert')).toHaveTextContent('当前模块暂时无法显示');
|
||||
expect(screen.getByRole('button', { name: /刷新当前页面/ })).toBeInTheDocument();
|
||||
expect(screen.getByRole('link', { name: /返回全局监控/ })).toHaveAttribute('href', '/monitor');
|
||||
@@ -29,8 +31,23 @@ describe('route recovery boundary', () => {
|
||||
vi.spyOn(console, 'error').mockImplementation(() => undefined);
|
||||
window.sessionStorage.setItem('vehicle-platform:route-chunk-reload', JSON.stringify({ routeKey: '/statistics', at: Date.now() }));
|
||||
const StaleChunkPage = () => { throw new TypeError('Failed to fetch dynamically imported module: /assets/Statistics-old.js'); };
|
||||
render(<MemoryRouter initialEntries={['/statistics']}><RoutePage page={StaleChunkPage} label="里程查询" /></MemoryRouter>);
|
||||
render(<MemoryRouter future={ROUTER_FUTURE} initialEntries={['/statistics']}><RoutePage page={StaleChunkPage} label="里程查询" /></MemoryRouter>);
|
||||
expect(screen.getByRole('alert')).toHaveTextContent('检测到页面版本更新');
|
||||
expect(screen.getByText(/旧标签页仍引用上一版本资源/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('keeps page state mounted when only URL search parameters change', () => {
|
||||
let mounts = 0;
|
||||
function FilterablePage() {
|
||||
const [count, setCount] = useState(0);
|
||||
const [, setSearchParams] = useSearchParams();
|
||||
useEffect(() => { mounts += 1; }, []);
|
||||
return <><strong>本地状态 {count}</strong><button onClick={() => setCount((value) => value + 1)}>修改本地状态</button><button onClick={() => setSearchParams({ page: '2' })}>修改查询参数</button></>;
|
||||
}
|
||||
render(<MemoryRouter future={ROUTER_FUTURE} initialEntries={['/alerts']}><RoutePage page={FilterablePage} label="告警中心" /></MemoryRouter>);
|
||||
fireEvent.click(screen.getByRole('button', { name: '修改本地状态' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: '修改查询参数' }));
|
||||
expect(screen.getByText('本地状态 1')).toBeInTheDocument();
|
||||
expect(mounts).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -65,7 +65,7 @@ class RecoverableRouteBoundary extends Component<{ children: ReactNode; routeKey
|
||||
export function RoutePage({ page: Page, label }: { page: ElementType; label: string }) {
|
||||
const location = useLocation();
|
||||
const routeKey = `${location.pathname}${location.search}`;
|
||||
return <RecoverableRouteBoundary key={routeKey} routeKey={routeKey}>
|
||||
return <RecoverableRouteBoundary key={location.pathname} routeKey={routeKey}>
|
||||
<Suspense fallback={<PageLoading label={`正在加载${label}`} />}><Page /></Suspense>
|
||||
</RecoverableRouteBoundary>;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
export const ROUTER_FUTURE = {
|
||||
v7_startTransition: true,
|
||||
v7_relativeSplatPath: true
|
||||
} as const;
|
||||
@@ -34,6 +34,11 @@ The web application splits every major route into a hashed lazy-loaded asset. An
|
||||
CURRENT_WEB=/opt/lingniu-vehicle-platform/current/web
|
||||
NEW_WEB=/opt/lingniu-vehicle-platform/releases/$PLATFORM_RELEASE/web
|
||||
|
||||
# Archives created on macOS must disable AppleDouble metadata. As a server-side
|
||||
# guard, remove any metadata files before recording the release manifest.
|
||||
COPYFILE_DISABLE=1 tar -C apps/web/dist -czf "/tmp/$PLATFORM_RELEASE-web.tar.gz" .
|
||||
find "$NEW_WEB" -type f -name '._*' -delete
|
||||
|
||||
# Record only this release's own build assets before adding compatibility files.
|
||||
find "$NEW_WEB/assets" -type f -printf '%P\n' | sort > "$NEW_WEB/.release-assets"
|
||||
|
||||
|
||||
@@ -10,6 +10,8 @@ This document records verified risks, the production controls that address them,
|
||||
| --- | --- | --- | --- |
|
||||
| A stale lazy route asset disappears during release | Navigation leaves the content area blank | Preserve the previous build's original hashed assets for one generation; isolate every route with a recoverable error boundary and one-shot reload | Request an old route asset after the symlink switch; navigate through every primary route without a blank shell |
|
||||
| A pasted batch search temporarily shows the previous fleet | Operators may mistake stale vehicles for batch matches | Hide placeholder rows while `keywords` is changing; report matched and missing plates after the request resolves | Paste newline/comma-separated plates with a duplicate and an unknown plate; verify exact results and missing-count feedback |
|
||||
| URL filters remount the whole route | Pagination or tab changes lose local state and may flash a loading screen | Key the route recovery boundary by pathname only; keep the full URL only for chunk-reload diagnostics | Change search parameters inside a stateful route and assert one mount with preserved local state |
|
||||
| Route-scoped reads continue after navigation | Obsolete responses consume bandwidth and remain in memory | Consume TanStack Query's `AbortSignal` in every V2 read path; immediately collect high-volume access and alert pages after they become inactive | Start a read, navigate away or change its query key, and assert the fetch receives the query signal |
|
||||
| A route is first fetched only after click | Slow transitions and a longer loading flash | Deduplicated route import cache plus pointer, focus and pointer-down preload | Route module unit tests and rendered navigation loop |
|
||||
| Rapid map pans retain large query payloads | Heap growth and eventual interaction jank | Consume React Query `AbortSignal` and immediately garbage-collect inactive map viewport queries | Query-cache churn test keeps exactly one map payload after repeated viewport changes |
|
||||
| Superseded monitor requests continue in the background | Wasted server traffic and stale responses | Pass request cancellation signals through the API client | API client signal test and browser network/console smoke |
|
||||
|
||||
Reference in New Issue
Block a user