From a956a06792985cb5a386510e46c29d9d1c4d175b Mon Sep 17 00:00:00 2001 From: jubnl Date: Sat, 11 Jul 2026 18:24:18 +0800 Subject: [PATCH] feat: add AMap JS proxy and map component (Task 5 foundation) Server: - AmapProxyController: GET /api/maps/amap-js serves AMap SDK - ALL /api/maps/amap-proxy/* proxies requests to Amap with server key - Registered in MapsModule Client: - amapLoader.ts: dynamic AMap JS SDK loader with serviceHost proxy - AmapMapView.tsx: basic Amap map component with markers/polylines - MapViewAuto.tsx: added 'amap' provider option alongside Leaflet/GL - Settings: map_provider type now accepts 'amap', UI updated --- .../Admin/DefaultUserSettingsTab.tsx | 16 +- client/src/components/Map/AmapMapView.tsx | 156 ++++++++++++++++++ client/src/components/Map/MapViewAuto.tsx | 24 ++- .../components/Settings/MapSettingsTab.tsx | 50 +++++- client/src/services/amapLoader.ts | 40 +++++ client/src/types.ts | 2 +- server/src/nest/maps/amap-proxy.controller.ts | 90 ++++++++++ server/src/nest/maps/maps.module.ts | 3 +- 8 files changed, 349 insertions(+), 32 deletions(-) create mode 100644 client/src/components/Map/AmapMapView.tsx create mode 100644 client/src/services/amapLoader.ts create mode 100644 server/src/nest/maps/amap-proxy.controller.ts diff --git a/client/src/components/Admin/DefaultUserSettingsTab.tsx b/client/src/components/Admin/DefaultUserSettingsTab.tsx index f61dfec7..8fc7e04d 100644 --- a/client/src/components/Admin/DefaultUserSettingsTab.tsx +++ b/client/src/components/Admin/DefaultUserSettingsTab.tsx @@ -42,14 +42,16 @@ type Defaults = { mapbox_quality_mode?: boolean } -type MapProvider = 'leaflet' | GlMapProvider +type MapProvider = 'leaflet' | GlMapProvider | 'amap' function normalizeProvider(value: unknown): MapProvider { + if (value === 'amap') return 'amap' return value === 'mapbox-gl' || value === 'maplibre-gl' ? value : 'leaflet' } function styleForProvider(provider: MapProvider, style?: string | null): string { if (provider === 'leaflet') return style || MAPBOX_DEFAULT_STYLE + if (provider === 'amap') return '' if (provider === 'mapbox-gl' && isOpenFreeMapStyle(style)) return MAPBOX_DEFAULT_STYLE return normalizeStyleForProvider(provider, style) } @@ -139,7 +141,7 @@ export default function DefaultUserSettingsTab(): React.ReactElement { if (key === 'mapbox_access_token') setMapboxToken('') if (key === 'mapbox_style' || key === 'maplibre_style') { const provider = normalizeProvider(defaults.map_provider) - setMapboxStyle(provider === 'leaflet' ? '' : defaultStyleForProvider(provider)) + setMapboxStyle(provider !== 'leaflet' && provider !== 'amap' ? defaultStyleForProvider(provider as GlMapProvider) : '') } toast.success(t('admin.defaultSettings.reset')) } catch (err: unknown) { @@ -191,12 +193,11 @@ export default function DefaultUserSettingsTab(): React.ReactElement { const darkMode = defaults.dark_mode const mapProvider = normalizeProvider(defaults.map_provider) - const glStylePresets = mapProvider === 'leaflet' ? [] : getStylePresets(mapProvider) + const glStylePresets = mapProvider !== 'leaflet' && mapProvider !== 'amap' ? getStylePresets(mapProvider as GlMapProvider) : [] const styleKey: keyof Defaults = mapProvider === 'maplibre-gl' ? 'maplibre_style' : 'mapbox_style' const saveMapProvider = (nextProvider: MapProvider) => { const patch: Partial = { map_provider: nextProvider } - if (nextProvider !== 'leaflet') { - // Load + save the new provider's own style slot so the other provider's style is kept. + if (nextProvider !== 'leaflet' && nextProvider !== 'amap') { const slot = nextProvider === 'maplibre-gl' ? defaults.maplibre_style : defaults.mapbox_style const nextStyle = styleForProvider(nextProvider, slot) setMapboxStyle(nextStyle) @@ -363,6 +364,7 @@ export default function DefaultUserSettingsTab(): React.ReactElement { > {([ { value: 'leaflet', label: t('admin.defaultSettings.providerLeaflet') }, + { value: 'amap', label: 'Amap' }, { value: 'mapbox-gl', label: t('admin.defaultSettings.providerMapbox') }, { value: 'maplibre-gl', label: t('admin.defaultSettings.providerMapLibre') }, ] as const).map(opt => ( @@ -416,11 +418,11 @@ export default function DefaultUserSettingsTab(): React.ReactElement { value={mapboxStyle} onChange={(e: React.ChangeEvent) => setMapboxStyle(e.target.value)} onBlur={() => { - const nextStyle = normalizeStyleForProvider(mapProvider, mapboxStyle) + const nextStyle = normalizeStyleForProvider(mapProvider as GlMapProvider, mapboxStyle) setMapboxStyle(nextStyle) save({ [styleKey]: nextStyle }) }} - placeholder={defaultStyleForProvider(mapProvider)} + placeholder={defaultStyleForProvider(mapProvider as GlMapProvider)} className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-slate-400 focus:border-transparent" /> diff --git a/client/src/components/Map/AmapMapView.tsx b/client/src/components/Map/AmapMapView.tsx new file mode 100644 index 00000000..0eb68b0d --- /dev/null +++ b/client/src/components/Map/AmapMapView.tsx @@ -0,0 +1,156 @@ +import { useEffect, useRef, useState } from 'react' +import type { Place } from '../../types' +import { loadAmapJs } from '../../services/amapLoader' + +interface AmapMapViewProps { + places?: Place[] + route?: [number, number][][] | null + selectedPlaceId?: number | null + onMarkerClick?: (id: number) => void + onMapClick?: (info: { latlng: { lat: number; lng: number } }) => void + onMapReady?: (map: unknown | null) => void + center?: [number, number] + zoom?: number +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type AMapModule = Record + +function getAMap(): AMapModule | undefined { + return (window as unknown as Record).AMap as AMapModule | undefined +} + +export function AmapMapView({ + places = [], + route = null, + selectedPlaceId = null, + onMarkerClick, + onMapClick, + onMapReady, + center = [39.90923, 116.397428], + zoom = 10, +}: AmapMapViewProps) { + const containerRef = useRef(null) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const mapRef = useRef(null) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const markersRef = useRef([]) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const polylinesRef = useRef([]) + const [loaded, setLoaded] = useState(false) + + useEffect(() => { + let cancelled = false + loadAmapJs() + .then(() => { + if (cancelled) return + setLoaded(true) + }) + .catch(() => {}) + return () => { cancelled = true } + }, []) + + useEffect(() => { + if (!loaded || !containerRef.current) return + + const AMap = getAMap() + if (!AMap) return + + const map = new AMap.Map(containerRef.current.id || 'amap-container', { + center: [center[1], center[0]], + zoom, + resizeEnable: true, + }) + mapRef.current = map + onMapReady?.(map) + + if (onMapClick) { + map.on('click', (e: { lnglat: { lat: number; lng: number } }) => { + if (e.lnglat) { + onMapClick({ latlng: { lat: e.lnglat.lat, lng: e.lnglat.lng } }) + } + }) + } + + return () => { + map.destroy?.() + mapRef.current = null + onMapReady?.(null) + } + }, [loaded]) + + useEffect(() => { + if (!mapRef.current || !loaded) return + const AMap = getAMap() + if (!AMap) return + + markersRef.current.forEach((m: { setMap: (m: null) => void }) => m.setMap(null)) + markersRef.current = [] + + for (const place of places) { + if (place.lat == null || place.lng == null) continue + const marker = new AMap.Marker({ + position: [place.lng, place.lat], + title: place.name, + }) + marker.on('click', () => onMarkerClick?.(place.id)) + marker.setMap(mapRef.current) + markersRef.current.push(marker) + } + }, [places, selectedPlaceId, loaded, onMarkerClick]) + + useEffect(() => { + if (!mapRef.current || !loaded) return + const AMap = getAMap() + if (!AMap) return + + polylinesRef.current.forEach((p: { setMap: (m: null) => void }) => p.setMap(null)) + polylinesRef.current = [] + + if (!route) return + for (const seg of route) { + if (seg.length < 2) continue + const path = seg.map(([lat, lng]) => [lng, lat]) + const polyline = new AMap.Polyline({ + path, + strokeColor: '#0a84ff', + strokeWeight: 5, + strokeOpacity: 1, + lineJoin: 'round', + lineCap: 'round', + }) + polyline.setMap(mapRef.current) + polylinesRef.current.push(polyline) + } + }, [route, loaded]) + + useEffect(() => { + if (!mapRef.current || !loaded) return + + const place = places.find(p => p.id === selectedPlaceId) + if (place && place.lat != null && place.lng != null) { + mapRef.current.setCenter([place.lng, place.lat]) + mapRef.current.setZoom(15) + } + }, [selectedPlaceId, places, loaded]) + + if (!loaded) { + return ( +
+ Loading map... +
+ ) + } + + return ( +
+ ) +} diff --git a/client/src/components/Map/MapViewAuto.tsx b/client/src/components/Map/MapViewAuto.tsx index e6fdc05c..d5db70a0 100644 --- a/client/src/components/Map/MapViewAuto.tsx +++ b/client/src/components/Map/MapViewAuto.tsx @@ -2,30 +2,26 @@ import { lazy, Suspense } from 'react' import { useSettingsStore } from '../../store/settingsStore' import { MapView } from './MapView' -// MapLibre/Mapbox pull in a ~230 KB (gzip) GL engine. Lazy-load the GL renderer so -// Leaflet-only installs never download it — it ships only once a GL provider is picked. const MapViewGL = lazy(() => import('./MapViewGL').then(m => ({ default: m.MapViewGL }))) +const AmapMapViewLazy = lazy(() => import('./AmapMapView').then(m => ({ default: m.AmapMapView }))) -// Auto-selects the map renderer based on user settings. Keeps the existing -// Leaflet MapView untouched so the Mapbox GL variant can mature iteratively -// behind a toggle. Atlas is not affected — it imports Leaflet directly. -// -// Offline maps: only the Leaflet renderer supports full pre-download (raster -// tiles via sync/tilePrefetcher.ts). GL maps are best-effort offline — their -// vector tiles are cached opportunistically by the Service Worker as you view -// them online (see the GL tile rules in vite.config.js), not prefetched. // eslint-disable-next-line @typescript-eslint/no-explicit-any export function MapViewAuto(props: any) { const provider = useSettingsStore(s => s.settings.map_provider) const token = useSettingsStore(s => s.settings.mapbox_access_token) - // Fall back to Leaflet when Mapbox is selected but no token is set, - // so trip planner never shows an empty map due to a missing token. + + if (provider === 'amap') { + return ( + }> + + + ) + } + const glProvider = provider === 'maplibre-gl' ? 'maplibre-gl' : provider === 'mapbox-gl' && token ? 'mapbox-gl' : null if (glProvider) { - // Render the previous Leaflet map as the fallback so there's no blank flash - // while the GL chunk loads on first use. return ( }> diff --git a/client/src/components/Settings/MapSettingsTab.tsx b/client/src/components/Settings/MapSettingsTab.tsx index 1f5736f2..db65daf3 100644 --- a/client/src/components/Settings/MapSettingsTab.tsx +++ b/client/src/components/Settings/MapSettingsTab.tsx @@ -1,5 +1,5 @@ -import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react' -import { Map, Save, Layers, Box, ChevronDown, Check, Globe2 } from 'lucide-react' +import React, { useState, useEffect, useCallback, useMemo, useRef, lazy, Suspense } from 'react' +import { Map, Save, Layers, Box, ChevronDown, Check, Globe2, MapPin } from 'lucide-react' import { useTranslation } from '../../i18n' import { useSettingsStore } from '../../store/settingsStore' import { useToast } from '../shared/Toast' @@ -18,6 +18,8 @@ import { type GlMapProvider, } from '../Map/glProviders' +const AmapMapViewLazy = lazy(() => import('../Map/AmapMapView').then(m => ({ default: m.AmapMapView }))) + interface MapPreset { name: string url: string @@ -124,14 +126,16 @@ function StyleDropdown({ value, provider, onChange }: { value: string; provider: ) } -type Provider = 'leaflet' | GlMapProvider +type Provider = 'leaflet' | GlMapProvider | 'amap' function normalizeProvider(value: unknown): Provider { + if (value === 'amap') return 'amap' return value === 'mapbox-gl' || value === 'maplibre-gl' ? value : 'leaflet' } function styleForProvider(provider: Provider, style?: string | null): string { if (provider === 'leaflet') return style || MAPBOX_DEFAULT_STYLE + if (provider === 'amap') return '' if (provider === 'mapbox-gl' && isOpenFreeMapStyle(style)) return MAPBOX_DEFAULT_STYLE return normalizeStyleForProvider(provider, style) } @@ -198,10 +202,10 @@ export default function MapSettingsTab(): React.ReactElement { const saveMapSettings = async (): Promise => { setSaving(true) try { - const glStyle = provider === 'leaflet' ? mapboxStyle : normalizeStyleForProvider(provider, mapboxStyle) + const glStyle = provider !== 'leaflet' && provider !== 'amap' ? normalizeStyleForProvider(provider as GlMapProvider, mapboxStyle) : mapboxStyle setMapboxStyle(glStyle) // Save into the active provider's own slot so the other provider's style survives. - const stylePatch = provider === 'maplibre-gl' ? { maplibre_style: glStyle } : { mapbox_style: glStyle } + const stylePatch = provider === 'maplibre-gl' ? { maplibre_style: glStyle } : provider !== 'leaflet' && provider !== 'amap' ? { mapbox_style: glStyle } : {} await updateSettings({ map_provider: provider, map_tile_url: mapTileUrl, @@ -226,7 +230,7 @@ export default function MapSettingsTab(): React.ReactElement { const supports3d = true const changeProvider = (nextProvider: Provider) => { setProvider(nextProvider) - if (nextProvider !== 'leaflet') setMapboxStyle(styleForProvider(nextProvider, mapboxStyle)) + if (nextProvider !== 'leaflet' && nextProvider !== 'amap') setMapboxStyle(styleForProvider(nextProvider, mapboxStyle)) } return ( @@ -234,7 +238,7 @@ export default function MapSettingsTab(): React.ReactElement { {/* Provider picker — big cards so the choice is obvious */}
-
+
+

{t('settings.mapProviderHint')} @@ -320,7 +344,7 @@ export default function MapSettingsTab(): React.ReactElement { )} {/* GL settings */} - {provider !== 'leaflet' && ( + {(provider === 'mapbox-gl' || provider === 'maplibre-gl') && (

{provider === 'mapbox-gl' && (
@@ -427,7 +451,15 @@ export default function MapSettingsTab(): React.ReactElement {
- {provider !== 'leaflet' ? ( + {provider === 'amap' ? ( + Loading map...
}> + + + ) : provider !== 'leaflet' ? ( | null = null + +const AMAP_SCRIPT_ID = 'trek-amap-js' +const AMAP_PROXY_HOST = '/api/maps/amap-proxy' + +declare global { + interface Window { + AMap: unknown + _AMapSecurityConfig?: { serviceHost?: string } + } +} + +export function loadAmapJs(): Promise { + if (window.AMap) return Promise.resolve() + if (loadPromise) return loadPromise + + loadPromise = new Promise((resolve, reject) => { + const existing = document.getElementById(AMAP_SCRIPT_ID) + if (existing) { + existing.addEventListener('load', () => resolve()) + existing.addEventListener('error', () => reject(new Error('Failed to load AMap JS'))) + return + } + + window._AMapSecurityConfig = { serviceHost: AMAP_PROXY_HOST } + + const script = document.createElement('script') + script.id = AMAP_SCRIPT_ID + script.src = '/api/maps/amap-js?v=2.0' + script.async = true + script.onload = () => resolve() + script.onerror = () => { + loadPromise = null + reject(new Error('Failed to load AMap JS')) + } + document.head.appendChild(script) + }) + + return loadPromise +} diff --git a/client/src/types.ts b/client/src/types.ts index 56c4b3c1..d8a99ea6 100644 --- a/client/src/types.ts +++ b/client/src/types.ts @@ -120,7 +120,7 @@ export interface Settings { map_booking_labels?: boolean map_poi_pill_enabled?: boolean optimize_from_accommodation?: boolean - map_provider?: 'leaflet' | 'mapbox-gl' | 'maplibre-gl' + map_provider?: 'leaflet' | 'mapbox-gl' | 'maplibre-gl' | 'amap' mapbox_access_token?: string mapbox_style?: string maplibre_style?: string diff --git a/server/src/nest/maps/amap-proxy.controller.ts b/server/src/nest/maps/amap-proxy.controller.ts new file mode 100644 index 00000000..9c0a46c6 --- /dev/null +++ b/server/src/nest/maps/amap-proxy.controller.ts @@ -0,0 +1,90 @@ +import { All, Controller, Req, Res, Get, Query } from '@nestjs/common'; +import type { Request, Response } from 'express'; + +const AMAP_JS_BASE = 'https://webapi.amap.com'; +const AMAP_JS_PATH = '/maps'; +const CACHE_MAX_AGE = 24 * 60 * 60; + +@Controller('api/maps') +export class AmapProxyController { + private jsCache: { body: string; contentType: string } | null = null; + private jsCacheTime = 0; + + @Get('amap-js') + async serveAmapJs(@Query() query: Record, @Res() res: Response) { + const key = process.env.AMAP_WEB_SERVICE_KEY?.trim(); + if (!key) { + res.status(503).json({ error: 'AMap service not configured' }); + return; + } + + const now = Date.now(); + if (this.jsCache && now - this.jsCacheTime < CACHE_MAX_AGE * 1000) { + res.setHeader('Content-Type', this.jsCache.contentType); + res.setHeader('Cache-Control', `public, max-age=${CACHE_MAX_AGE}`); + res.send(this.jsCache.body); + return; + } + + try { + const params = new URLSearchParams(query); + params.set('key', key); + const url = `${AMAP_JS_BASE}${AMAP_JS_PATH}?${params.toString()}`; + const resp = await fetch(url, { + headers: { 'User-Agent': 'TREK-AMap-Proxy/1.0' }, + }); + + if (!resp.ok) { + res.status(resp.status).json({ error: 'Failed to load AMap JS' }); + return; + } + + const body = await resp.text(); + const contentType = resp.headers.get('content-type') || 'application/javascript'; + + this.jsCache = { body, contentType }; + this.jsCacheTime = now; + + res.setHeader('Content-Type', contentType); + res.setHeader('Cache-Control', `public, max-age=${CACHE_MAX_AGE}`); + res.send(body); + } catch { + if (this.jsCache) { + res.setHeader('Content-Type', this.jsCache.contentType); + res.setHeader('Cache-Control', 'public, max-age=60'); + res.send(this.jsCache.body); + return; + } + res.status(502).json({ error: 'AMap JS unavailable' }); + } + } + + @All('amap-proxy/*') + async proxyAmap(@Req() req: Request, @Res() res: Response) { + const key = process.env.AMAP_WEB_SERVICE_KEY?.trim(); + if (!key) { + res.status(503).json({ error: 'AMap service not configured' }); + return; + } + + const subPath = (req.params as Record)['0'] || ''; + const url = new URL(subPath, AMAP_JS_BASE); + url.searchParams.set('key', key); + + try { + const resp = await fetch(url.toString(), { + method: req.method, + headers: { + 'User-Agent': 'TREK-AMap-Proxy/1.0', + }, + }); + + const body = Buffer.from(await resp.arrayBuffer()); + const contentType = resp.headers.get('content-type'); + if (contentType) res.setHeader('Content-Type', contentType); + res.status(resp.status).send(body); + } catch { + res.status(502).json({ error: 'AMap proxy error' }); + } + } +} diff --git a/server/src/nest/maps/maps.module.ts b/server/src/nest/maps/maps.module.ts index 350f3d3c..cecd11cd 100644 --- a/server/src/nest/maps/maps.module.ts +++ b/server/src/nest/maps/maps.module.ts @@ -1,10 +1,11 @@ import { Module } from '@nestjs/common'; import { MapsController } from './maps.controller'; import { MapsService } from './maps.service'; +import { AmapProxyController } from './amap-proxy.controller'; /** Maps / geo domain (L3 leaf module). Registered in AppModule. */ @Module({ - controllers: [MapsController], + controllers: [MapsController, AmapProxyController], providers: [MapsService], }) export class MapsModule {}