fix(web): retry transient map load failures

This commit is contained in:
lingniu
2026-07-16 07:13:23 +08:00
parent 970ef5d7c0
commit a0448d6615
5 changed files with 29 additions and 12 deletions

View File

@@ -167,13 +167,16 @@ afterEach(() => {
vi.restoreAllMocks();
});
test('renders data that arrives before the delayed AMap SDK is ready', async () => {
test('recovers in place after a transient AMap failure and renders data that arrived while retrying', async () => {
let resolveAMap!: (value: AMapLike) => void;
const delayedAMap = new Promise<AMapLike>((resolve) => {
resolveAMap = resolve;
});
window.__LINGNIU_APP_CONFIG__ = { amapWebJsKey: 'amap-web-key' };
window.AMapLoader = { load: vi.fn(() => delayedAMap) };
const load = vi.fn()
.mockRejectedValueOnce(new Error('temporary map network failure'))
.mockImplementationOnce(() => delayedAMap);
window.AMapLoader = { load };
render(
<FleetMap
@@ -183,6 +186,8 @@ test('renders data that arrives before the delayed AMap SDK is ready', async ()
/>
);
expect(await screen.findByText(/地图加载失败/)).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: /重新加载地图/ }));
expect(setData).not.toHaveBeenCalled();
await act(async () => {
@@ -196,6 +201,8 @@ test('renders data that arrives before the delayed AMap SDK is ready', async ()
const clusterStyles = setStyle.mock.calls[setStyle.mock.calls.length - 1]?.[0] as Array<{ url: string }>;
expect(decodeURIComponent(clusterStyles[5].url)).toContain('>12</text>');
expect(decodeURIComponent(clusterStyles[5].url)).not.toContain('10+');
expect(load).toHaveBeenCalledTimes(2);
expect(screen.queryByText(/地图加载失败/)).not.toBeInTheDocument();
});
test('detaches AMap listeners and destroys the map on unmount', async () => {

View File

@@ -1,4 +1,4 @@
import { IconEyeClosed, IconEyeOpened, IconMapPin } from '@douyinfe/semi-icons';
import { IconEyeClosed, IconEyeOpened, IconMapPin, IconRefresh } from '@douyinfe/semi-icons';
import { useEffect, useMemo, useRef, useState } from 'react';
import { getAMapConfig, isAMapConfigured } from '../../config/appConfig';
import {
@@ -169,6 +169,7 @@ export function FleetMap({ vehicles, selectedVin, onSelect, monitorMap, onSelect
const centeredVinRef = useRef('');
const followSelectedRef = useRef(true);
const [state, setState] = useState<'loading' | 'ready' | 'fallback' | 'error'>('loading');
const [loadAttempt, setLoadAttempt] = useState(0);
const [showLabels, setShowLabels] = useState(true);
const [followSelected, setFollowSelected] = useState(true);
const [mapZoom, setMapZoom] = useState(5);
@@ -314,7 +315,7 @@ export function FleetMap({ vehicles, selectedVin, onSelect, monitorMap, onSelect
amapRef.current = null;
mapRef.current = null;
};
}, []);
}, [loadAttempt]);
useEffect(() => {
const mass = massRef.current;
@@ -511,7 +512,7 @@ export function FleetMap({ vehicles, selectedVin, onSelect, monitorMap, onSelect
<div className={`v2-map-state is-${state}`}>
{state === 'loading' ? <><span className="v2-spinner" /></> : null}
{state === 'fallback' ? `地图未配置,当前已载入 ${renderedPointCount} 个有效坐标` : null}
{state === 'error' ? '地图加载失败,请检查高德 Key、域名白名单和网络' : null}
{state === 'error' ? <><span> Key</span><button type="button" onClick={() => setLoadAttempt((value) => value + 1)}><IconRefresh /></button></> : null}
</div>
) : null}
<div className="v2-map-legend" aria-label="车辆状态图例">

View File

@@ -1,4 +1,4 @@
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 type { HistoryLocationRow, TrackStop } from '../../api/types';
import type { AMapLike } from '../../integrations/amap';
@@ -98,9 +98,11 @@ afterEach(() => {
mapInstances.length = 0;
});
test('defers the map runtime until valid track data exists and releases it when data is cleared', async () => {
test('defers the map runtime until valid data exists, retries a transient failure, and releases it when cleared', async () => {
window.__LINGNIU_APP_CONFIG__ = { amapWebJsKey: 'amap-web-key' };
const load = vi.fn(async () => amapMock());
const load = vi.fn()
.mockRejectedValueOnce(new Error('temporary map network failure'))
.mockResolvedValueOnce(amapMock());
window.AMapLoader = { load };
const view = render(<TrackMap
points={[]}
@@ -125,8 +127,11 @@ test('defers the map runtime until valid track data exists and releases it when
onSelectIndex={() => undefined}
onFollowChange={() => undefined}
/>);
expect(await screen.findByText(/地图加载失败/)).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: /重新加载地图/ }));
await waitFor(() => expect(mapInstances).toHaveLength(1));
expect(load).toHaveBeenCalledTimes(1);
expect(load).toHaveBeenCalledTimes(2);
expect(screen.queryByText(/地图加载失败/)).not.toBeInTheDocument();
view.rerender(<TrackMap
points={[]}

View File

@@ -1,3 +1,4 @@
import { IconRefresh } from '@douyinfe/semi-icons';
import { useEffect, useMemo, useRef, useState } from 'react';
import type { HistoryLocationRow, TrackStop } from '../../api/types';
import { getAMapConfig, isAMapConfigured } from '../../config/appConfig';
@@ -31,6 +32,7 @@ export function TrackMap({ points, stops, activeIndex, showStops, follow, follow
const valid = useMemo(() => points.map((point, index) => ({ point, index })).filter(({ point }) => isValidAMapCoordinate(point.longitude, point.latitude)), [points]);
const hasValidPoints = valid.length > 0;
const [state, setState] = useState<'idle' | 'loading' | 'ready' | 'fallback' | 'error'>(() => hasValidPoints ? 'loading' : 'idle');
const [loadAttempt, setLoadAttempt] = useState(0);
const passedCountByPoint = useMemo(() => {
const counts = new Uint16Array(points.length);
let validIndex = 0;
@@ -84,7 +86,7 @@ export function TrackMap({ points, stops, activeIndex, showStops, follow, follow
mapRef.current = null;
amapRef.current = null;
};
}, [hasValidPoints]);
}, [hasValidPoints, loadAttempt]);
useEffect(() => {
const AMap = amapRef.current;
@@ -151,7 +153,7 @@ export function TrackMap({ points, stops, activeIndex, showStops, follow, follow
{state === 'loading' ? <><span className="v2-spinner" /></> : null}
{state === 'idle' ? '当前轨迹没有可展示的有效坐标' : null}
{state === 'fallback' ? `地图未配置,已载入 ${valid.length} 个有效轨迹点` : null}
{state === 'error' ? '地图加载失败,请检查高德地图配置' : null}
{state === 'error' ? <><span></span><button type="button" onClick={() => setLoadAttempt((value) => value + 1)}><IconRefresh /></button></> : null}
</div> : null}
</div>;
}

View File

@@ -192,7 +192,9 @@ button, a { -webkit-tap-highlight-color: transparent; }
.v2-map-selection-marker > b { position: absolute; top: 18px; left: 18px; width: 12px; height: 12px; border: 3px solid #fff; border-radius: 50%; background: var(--v2-blue); box-shadow: 0 2px 8px rgba(18,104,243,.45); }
.v2-map-state { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; gap: 9px; background: #eef3f7; color: #637083; font-size: 12px; }
.v2-map-state.is-loading { background: rgba(255,255,255,.82); backdrop-filter: blur(2px); }
.v2-map-state.is-error { color: #b42318; }
.v2-map-state.is-error { flex-direction: column; padding: 20px; color: #b42318; text-align: center; }
.v2-map-state.is-error button { display: inline-flex; height: 32px; align-items: center; gap: 6px; border: 1px solid #f2b8b5; border-radius: 7px; background: #fff; padding: 0 11px; color: #a61b13; cursor: pointer; font-size: 11px; font-weight: 700; box-shadow: 0 3px 10px rgba(180,35,24,.08); }
.v2-map-state.is-error button:hover { border-color: #df8a85; background: #fff8f7; }
.v2-map-legend { position: absolute; bottom: 12px; left: 50%; display: flex; width: max-content; max-width: calc(100% - 28px); height: 36px; align-items: center; justify-content: center; gap: 18px; border: 1px solid #d8e2ed; border-radius: 8px; background: #fff; padding: 0 16px; color: #5f6e82; box-shadow: 0 6px 18px rgba(21,32,51,.1); font-size: 9px; transform: translateX(-50%); }
.v2-map-legend span { display: inline-flex; align-items: center; gap: 5px; }
.v2-map-legend i { width: 7px; height: 7px; border-radius: 50%; background: #94a3b8; }