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
|
||||
}
|
||||
|
||||
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>
|
||||
|
||||
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 { 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} />
|
||||
|
||||
@ -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}
|
||||
|
||||
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_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
|
||||
|
||||
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 { 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 {}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user