diff --git a/vehicle-data-platform/apps/web/src/v2/map/FleetMap.test.tsx b/vehicle-data-platform/apps/web/src/v2/map/FleetMap.test.tsx index 78a77c04..ca60f5cd 100644 --- a/vehicle-data-platform/apps/web/src/v2/map/FleetMap.test.tsx +++ b/vehicle-data-platform/apps/web/src/v2/map/FleetMap.test.tsx @@ -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((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( ); + 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'); 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 () => { diff --git a/vehicle-data-platform/apps/web/src/v2/map/FleetMap.tsx b/vehicle-data-platform/apps/web/src/v2/map/FleetMap.tsx index 4849e926..01237188 100644 --- a/vehicle-data-platform/apps/web/src/v2/map/FleetMap.tsx +++ b/vehicle-data-platform/apps/web/src/v2/map/FleetMap.tsx @@ -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
{state === 'loading' ? <>高德地图加载中 : null} {state === 'fallback' ? `地图未配置,当前已载入 ${renderedPointCount} 个有效坐标` : null} - {state === 'error' ? '地图加载失败,请检查高德 Key、域名白名单和网络' : null} + {state === 'error' ? <>地图加载失败,请检查高德 Key、域名白名单和网络 : null}
) : null}
diff --git a/vehicle-data-platform/apps/web/src/v2/map/TrackMap.test.tsx b/vehicle-data-platform/apps/web/src/v2/map/TrackMap.test.tsx index 8f451d98..5b8e8ad7 100644 --- a/vehicle-data-platform/apps/web/src/v2/map/TrackMap.test.tsx +++ b/vehicle-data-platform/apps/web/src/v2/map/TrackMap.test.tsx @@ -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( 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( 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' ? <>轨迹地图加载中 : null} {state === 'idle' ? '当前轨迹没有可展示的有效坐标' : null} {state === 'fallback' ? `地图未配置,已载入 ${valid.length} 个有效轨迹点` : null} - {state === 'error' ? '地图加载失败,请检查高德地图配置' : null} + {state === 'error' ? <>地图加载失败,请检查高德地图配置或网络 : null}
: null} ; } diff --git a/vehicle-data-platform/apps/web/src/v2/styles/v2.css b/vehicle-data-platform/apps/web/src/v2/styles/v2.css index 728b4a09..2a67af4a 100644 --- a/vehicle-data-platform/apps/web/src/v2/styles/v2.css +++ b/vehicle-data-platform/apps/web/src/v2/styles/v2.css @@ -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; }