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:
parent
727692cf4e
commit
a956a06792
@ -42,14 +42,16 @@ type Defaults = {
|
|||||||
mapbox_quality_mode?: boolean
|
mapbox_quality_mode?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
type MapProvider = 'leaflet' | GlMapProvider
|
type MapProvider = 'leaflet' | GlMapProvider | 'amap'
|
||||||
|
|
||||||
function normalizeProvider(value: unknown): MapProvider {
|
function normalizeProvider(value: unknown): MapProvider {
|
||||||
|
if (value === 'amap') return 'amap'
|
||||||
return value === 'mapbox-gl' || value === 'maplibre-gl' ? value : 'leaflet'
|
return value === 'mapbox-gl' || value === 'maplibre-gl' ? value : 'leaflet'
|
||||||
}
|
}
|
||||||
|
|
||||||
function styleForProvider(provider: MapProvider, style?: string | null): string {
|
function styleForProvider(provider: MapProvider, style?: string | null): string {
|
||||||
if (provider === 'leaflet') return style || MAPBOX_DEFAULT_STYLE
|
if (provider === 'leaflet') return style || MAPBOX_DEFAULT_STYLE
|
||||||
|
if (provider === 'amap') return ''
|
||||||
if (provider === 'mapbox-gl' && isOpenFreeMapStyle(style)) return MAPBOX_DEFAULT_STYLE
|
if (provider === 'mapbox-gl' && isOpenFreeMapStyle(style)) return MAPBOX_DEFAULT_STYLE
|
||||||
return normalizeStyleForProvider(provider, style)
|
return normalizeStyleForProvider(provider, style)
|
||||||
}
|
}
|
||||||
@ -139,7 +141,7 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
|
|||||||
if (key === 'mapbox_access_token') setMapboxToken('')
|
if (key === 'mapbox_access_token') setMapboxToken('')
|
||||||
if (key === 'mapbox_style' || key === 'maplibre_style') {
|
if (key === 'mapbox_style' || key === 'maplibre_style') {
|
||||||
const provider = normalizeProvider(defaults.map_provider)
|
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'))
|
toast.success(t('admin.defaultSettings.reset'))
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
@ -191,12 +193,11 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
|
|||||||
|
|
||||||
const darkMode = defaults.dark_mode
|
const darkMode = defaults.dark_mode
|
||||||
const mapProvider = normalizeProvider(defaults.map_provider)
|
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 styleKey: keyof Defaults = mapProvider === 'maplibre-gl' ? 'maplibre_style' : 'mapbox_style'
|
||||||
const saveMapProvider = (nextProvider: MapProvider) => {
|
const saveMapProvider = (nextProvider: MapProvider) => {
|
||||||
const patch: Partial<Defaults> = { map_provider: nextProvider }
|
const patch: Partial<Defaults> = { map_provider: nextProvider }
|
||||||
if (nextProvider !== 'leaflet') {
|
if (nextProvider !== 'leaflet' && nextProvider !== 'amap') {
|
||||||
// Load + save the new provider's own style slot so the other provider's style is kept.
|
|
||||||
const slot = nextProvider === 'maplibre-gl' ? defaults.maplibre_style : defaults.mapbox_style
|
const slot = nextProvider === 'maplibre-gl' ? defaults.maplibre_style : defaults.mapbox_style
|
||||||
const nextStyle = styleForProvider(nextProvider, slot)
|
const nextStyle = styleForProvider(nextProvider, slot)
|
||||||
setMapboxStyle(nextStyle)
|
setMapboxStyle(nextStyle)
|
||||||
@ -363,6 +364,7 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
|
|||||||
>
|
>
|
||||||
{([
|
{([
|
||||||
{ value: 'leaflet', label: t('admin.defaultSettings.providerLeaflet') },
|
{ value: 'leaflet', label: t('admin.defaultSettings.providerLeaflet') },
|
||||||
|
{ value: 'amap', label: 'Amap' },
|
||||||
{ value: 'mapbox-gl', label: t('admin.defaultSettings.providerMapbox') },
|
{ value: 'mapbox-gl', label: t('admin.defaultSettings.providerMapbox') },
|
||||||
{ value: 'maplibre-gl', label: t('admin.defaultSettings.providerMapLibre') },
|
{ value: 'maplibre-gl', label: t('admin.defaultSettings.providerMapLibre') },
|
||||||
] as const).map(opt => (
|
] as const).map(opt => (
|
||||||
@ -416,11 +418,11 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
|
|||||||
value={mapboxStyle}
|
value={mapboxStyle}
|
||||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setMapboxStyle(e.target.value)}
|
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setMapboxStyle(e.target.value)}
|
||||||
onBlur={() => {
|
onBlur={() => {
|
||||||
const nextStyle = normalizeStyleForProvider(mapProvider, mapboxStyle)
|
const nextStyle = normalizeStyleForProvider(mapProvider as GlMapProvider, mapboxStyle)
|
||||||
setMapboxStyle(nextStyle)
|
setMapboxStyle(nextStyle)
|
||||||
save({ [styleKey]: 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"
|
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>
|
</div>
|
||||||
|
|||||||
156
client/src/components/Map/AmapMapView.tsx
Normal file
156
client/src/components/Map/AmapMapView.tsx
Normal 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]"
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -2,30 +2,26 @@ import { lazy, Suspense } from 'react'
|
|||||||
import { useSettingsStore } from '../../store/settingsStore'
|
import { useSettingsStore } from '../../store/settingsStore'
|
||||||
import { MapView } from './MapView'
|
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 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
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
export function MapViewAuto(props: any) {
|
export function MapViewAuto(props: any) {
|
||||||
const provider = useSettingsStore(s => s.settings.map_provider)
|
const provider = useSettingsStore(s => s.settings.map_provider)
|
||||||
const token = useSettingsStore(s => s.settings.mapbox_access_token)
|
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'
|
const glProvider = provider === 'maplibre-gl' ? 'maplibre-gl'
|
||||||
: provider === 'mapbox-gl' && token ? 'mapbox-gl'
|
: provider === 'mapbox-gl' && token ? 'mapbox-gl'
|
||||||
: null
|
: null
|
||||||
if (glProvider) {
|
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 (
|
return (
|
||||||
<Suspense fallback={<MapView {...props} />}>
|
<Suspense fallback={<MapView {...props} />}>
|
||||||
<MapViewGL {...props} glProvider={glProvider} />
|
<MapViewGL {...props} glProvider={glProvider} />
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react'
|
import React, { useState, useEffect, useCallback, useMemo, useRef, lazy, Suspense } from 'react'
|
||||||
import { Map, Save, Layers, Box, ChevronDown, Check, Globe2 } from 'lucide-react'
|
import { Map, Save, Layers, Box, ChevronDown, Check, Globe2, MapPin } from 'lucide-react'
|
||||||
import { useTranslation } from '../../i18n'
|
import { useTranslation } from '../../i18n'
|
||||||
import { useSettingsStore } from '../../store/settingsStore'
|
import { useSettingsStore } from '../../store/settingsStore'
|
||||||
import { useToast } from '../shared/Toast'
|
import { useToast } from '../shared/Toast'
|
||||||
@ -18,6 +18,8 @@ import {
|
|||||||
type GlMapProvider,
|
type GlMapProvider,
|
||||||
} from '../Map/glProviders'
|
} from '../Map/glProviders'
|
||||||
|
|
||||||
|
const AmapMapViewLazy = lazy(() => import('../Map/AmapMapView').then(m => ({ default: m.AmapMapView })))
|
||||||
|
|
||||||
interface MapPreset {
|
interface MapPreset {
|
||||||
name: string
|
name: string
|
||||||
url: 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 {
|
function normalizeProvider(value: unknown): Provider {
|
||||||
|
if (value === 'amap') return 'amap'
|
||||||
return value === 'mapbox-gl' || value === 'maplibre-gl' ? value : 'leaflet'
|
return value === 'mapbox-gl' || value === 'maplibre-gl' ? value : 'leaflet'
|
||||||
}
|
}
|
||||||
|
|
||||||
function styleForProvider(provider: Provider, style?: string | null): string {
|
function styleForProvider(provider: Provider, style?: string | null): string {
|
||||||
if (provider === 'leaflet') return style || MAPBOX_DEFAULT_STYLE
|
if (provider === 'leaflet') return style || MAPBOX_DEFAULT_STYLE
|
||||||
|
if (provider === 'amap') return ''
|
||||||
if (provider === 'mapbox-gl' && isOpenFreeMapStyle(style)) return MAPBOX_DEFAULT_STYLE
|
if (provider === 'mapbox-gl' && isOpenFreeMapStyle(style)) return MAPBOX_DEFAULT_STYLE
|
||||||
return normalizeStyleForProvider(provider, style)
|
return normalizeStyleForProvider(provider, style)
|
||||||
}
|
}
|
||||||
@ -198,10 +202,10 @@ export default function MapSettingsTab(): React.ReactElement {
|
|||||||
const saveMapSettings = async (): Promise<void> => {
|
const saveMapSettings = async (): Promise<void> => {
|
||||||
setSaving(true)
|
setSaving(true)
|
||||||
try {
|
try {
|
||||||
const glStyle = provider === 'leaflet' ? mapboxStyle : normalizeStyleForProvider(provider, mapboxStyle)
|
const glStyle = provider !== 'leaflet' && provider !== 'amap' ? normalizeStyleForProvider(provider as GlMapProvider, mapboxStyle) : mapboxStyle
|
||||||
setMapboxStyle(glStyle)
|
setMapboxStyle(glStyle)
|
||||||
// Save into the active provider's own slot so the other provider's style survives.
|
// 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({
|
await updateSettings({
|
||||||
map_provider: provider,
|
map_provider: provider,
|
||||||
map_tile_url: mapTileUrl,
|
map_tile_url: mapTileUrl,
|
||||||
@ -226,7 +230,7 @@ export default function MapSettingsTab(): React.ReactElement {
|
|||||||
const supports3d = true
|
const supports3d = true
|
||||||
const changeProvider = (nextProvider: Provider) => {
|
const changeProvider = (nextProvider: Provider) => {
|
||||||
setProvider(nextProvider)
|
setProvider(nextProvider)
|
||||||
if (nextProvider !== 'leaflet') setMapboxStyle(styleForProvider(nextProvider, mapboxStyle))
|
if (nextProvider !== 'leaflet' && nextProvider !== 'amap') setMapboxStyle(styleForProvider(nextProvider, mapboxStyle))
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -234,7 +238,7 @@ export default function MapSettingsTab(): React.ReactElement {
|
|||||||
{/* Provider picker — big cards so the choice is obvious */}
|
{/* Provider picker — big cards so the choice is obvious */}
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-slate-700 mb-2">{t('settings.mapProvider')}</label>
|
<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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => changeProvider('leaflet')}
|
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 className="hidden sm:block text-xs text-slate-500 mt-0.5">{t('settings.mapMapLibreSubtitle')}</div>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</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>
|
</div>
|
||||||
<p className="text-xs text-slate-400 mt-2">
|
<p className="text-xs text-slate-400 mt-2">
|
||||||
{t('settings.mapProviderHint')}
|
{t('settings.mapProviderHint')}
|
||||||
@ -320,7 +344,7 @@ export default function MapSettingsTab(): React.ReactElement {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* GL settings */}
|
{/* GL settings */}
|
||||||
{provider !== 'leaflet' && (
|
{(provider === 'mapbox-gl' || provider === 'maplibre-gl') && (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{provider === 'mapbox-gl' && (
|
{provider === 'mapbox-gl' && (
|
||||||
<div>
|
<div>
|
||||||
@ -427,7 +451,15 @@ export default function MapSettingsTab(): React.ReactElement {
|
|||||||
|
|
||||||
<div>
|
<div>
|
||||||
<div style={{ position: 'relative', inset: 0, height: '200px', width: '100%' }}>
|
<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
|
<GlMapPreview
|
||||||
provider={provider}
|
provider={provider}
|
||||||
token={mapboxToken}
|
token={mapboxToken}
|
||||||
|
|||||||
40
client/src/services/amapLoader.ts
Normal file
40
client/src/services/amapLoader.ts
Normal 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
|
||||||
|
}
|
||||||
@ -120,7 +120,7 @@ export interface Settings {
|
|||||||
map_booking_labels?: boolean
|
map_booking_labels?: boolean
|
||||||
map_poi_pill_enabled?: boolean
|
map_poi_pill_enabled?: boolean
|
||||||
optimize_from_accommodation?: 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_access_token?: string
|
||||||
mapbox_style?: string
|
mapbox_style?: string
|
||||||
maplibre_style?: string
|
maplibre_style?: string
|
||||||
|
|||||||
90
server/src/nest/maps/amap-proxy.controller.ts
Normal file
90
server/src/nest/maps/amap-proxy.controller.ts
Normal 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' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,10 +1,11 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { MapsController } from './maps.controller';
|
import { MapsController } from './maps.controller';
|
||||||
import { MapsService } from './maps.service';
|
import { MapsService } from './maps.service';
|
||||||
|
import { AmapProxyController } from './amap-proxy.controller';
|
||||||
|
|
||||||
/** Maps / geo domain (L3 leaf module). Registered in AppModule. */
|
/** Maps / geo domain (L3 leaf module). Registered in AppModule. */
|
||||||
@Module({
|
@Module({
|
||||||
controllers: [MapsController],
|
controllers: [MapsController, AmapProxyController],
|
||||||
providers: [MapsService],
|
providers: [MapsService],
|
||||||
})
|
})
|
||||||
export class MapsModule {}
|
export class MapsModule {}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user