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
This commit is contained in:
jubnl 2026-07-11 18:24:18 +08:00
parent 727692cf4e
commit a956a06792
8 changed files with 349 additions and 32 deletions

View File

@ -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<Defaults> = { 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<HTMLInputElement>) => 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"
/>
</div>

View File

@ -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<string, any>
function getAMap(): AMapModule | undefined {
return (window as unknown as Record<string, unknown>).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<HTMLDivElement>(null)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const mapRef = useRef<any>(null)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const markersRef = useRef<any[]>([])
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const polylinesRef = useRef<any[]>([])
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 (
<div
ref={containerRef}
id="amap-container"
className="w-full h-full bg-[#e5e7eb] flex items-center justify-center text-sm text-gray-400"
>
Loading map...
</div>
)
}
return (
<div
ref={containerRef}
id="amap-container"
className="w-full h-full bg-[#e5e7eb]"
/>
)
}

View File

@ -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 (
<Suspense fallback={<MapView {...props} />}>
<AmapMapViewLazy {...props} />
</Suspense>
)
}
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 (
<Suspense fallback={<MapView {...props} />}>
<MapViewGL {...props} glProvider={glProvider} />

View File

@ -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<void> => {
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 */}
<div>
<label className="block text-sm font-medium text-slate-700 mb-2">{t('settings.mapProvider')}</label>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-2">
<div className="grid grid-cols-1 sm:grid-cols-4 gap-2">
<button
type="button"
onClick={() => changeProvider('leaflet')}
@ -290,6 +294,26 @@ export default function MapSettingsTab(): React.ReactElement {
<div className="hidden sm:block text-xs text-slate-500 mt-0.5">{t('settings.mapMapLibreSubtitle')}</div>
</div>
</button>
<button
type="button"
onClick={() => changeProvider('amap')}
className={`relative flex items-start gap-3 p-3 rounded-lg border text-left transition-colors ${
provider === 'amap'
? 'border-slate-900 bg-slate-50 dark:bg-slate-800 dark:border-slate-200'
: 'border-slate-200 hover:border-slate-400 dark:border-slate-700'
}`}
>
<MapPin size={18} className="mt-0.5 flex-shrink-0 text-slate-700 dark:text-slate-300" />
<div className="min-w-0">
<div className="text-sm font-medium text-slate-900 dark:text-white">
Amap
</div>
<div className="hidden sm:block text-xs text-slate-500 mt-0.5">{t('settings.mapAmapSubtitle')}</div>
</div>
<span className="hidden sm:inline-block absolute top-2 right-2 text-[9px] font-semibold tracking-wide uppercase px-1.5 py-[3px] rounded bg-amber-100 text-amber-800 dark:bg-amber-900/40 dark:text-amber-300 leading-none">
{t('settings.mapExperimental')}
</span>
</button>
</div>
<p className="text-xs text-slate-400 mt-2">
{t('settings.mapProviderHint')}
@ -320,7 +344,7 @@ export default function MapSettingsTab(): React.ReactElement {
)}
{/* GL settings */}
{provider !== 'leaflet' && (
{(provider === 'mapbox-gl' || provider === 'maplibre-gl') && (
<div className="space-y-3">
{provider === 'mapbox-gl' && (
<div>
@ -427,7 +451,15 @@ export default function MapSettingsTab(): React.ReactElement {
<div>
<div style={{ position: 'relative', inset: 0, height: '200px', width: '100%' }}>
{provider !== 'leaflet' ? (
{provider === 'amap' ? (
<Suspense fallback={<div className="w-full h-full bg-[#e5e7eb] flex items-center justify-center text-sm text-gray-400">Loading map...</div>}>
<AmapMapViewLazy
places={mapPlaces}
center={[parseFloat(String(defaultLat)) || 48.8566, parseFloat(String(defaultLng)) || 2.3522]}
zoom={parseInt(String(defaultZoom)) || 10}
/>
</Suspense>
) : provider !== 'leaflet' ? (
<GlMapPreview
provider={provider}
token={mapboxToken}

View File

@ -0,0 +1,40 @@
let loadPromise: Promise<void> | 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<void> {
if (window.AMap) return Promise.resolve()
if (loadPromise) return loadPromise
loadPromise = new Promise<void>((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
}

View File

@ -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

View File

@ -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<string, string>, @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<string, string>)['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' });
}
}
}

View File

@ -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 {}