feat(map): amap as map api provider and display template

This commit is contained in:
A1phaN
2026-09-07 10:04:12 +08:00
committed by Maurice
parent a066c94ec5
commit 57eac32f9f
92 changed files with 2910 additions and 70 deletions
+4 -2
View File
@@ -284,7 +284,7 @@ function RouteFallback() {
}
export default function App() {
const { loadUser, isAuthenticated, demoMode, setManaged, setDemoMode, setDevMode, setIsPrerelease, setAppVersion, setHasMapsKey, setServerTimezone, setAppRequireMfa, setTripRemindersEnabled, setPlacesPhotosEnabled, setPlacesAutocompleteEnabled, setPlacesDetailsEnabled, setPlacesEnrichEnabled, setPlaceShadowEnabled } = useAuthStore()
const { loadUser, isAuthenticated, demoMode, setManaged, setDemoMode, setDevMode, setIsPrerelease, setAppVersion, setHasMapsKey, setHasAmapKey, setPlacesProvider, setServerTimezone, setAppRequireMfa, setTripRemindersEnabled, setPlacesPhotosEnabled, setPlacesAutocompleteEnabled, setPlacesDetailsEnabled, setPlacesEnrichEnabled, setPlaceShadowEnabled } = useAuthStore()
const { loadSettings } = useSettingsStore()
const { loadAddons } = useAddonStore()
const { loadPlugins } = usePluginStore()
@@ -301,13 +301,15 @@ export default function App() {
loadUser()
}
}
authApi.getAppConfig().then(async (config: { managed?: boolean; demo_mode?: boolean; dev_mode?: boolean; is_prerelease?: boolean; has_maps_key?: boolean; version?: string; timezone?: string; require_mfa?: boolean; trip_reminders_enabled?: boolean; places_photos_enabled?: boolean; places_autocomplete_enabled?: boolean; places_details_enabled?: boolean; places_enrich_enabled?: boolean; place_shadow_enabled?: boolean; permissions?: Record<string, PermissionLevel> }) => {
authApi.getAppConfig().then(async (config: { managed?: boolean; demo_mode?: boolean; dev_mode?: boolean; is_prerelease?: boolean; has_maps_key?: boolean; has_amap_key?: boolean; places_provider?: string; version?: string; timezone?: string; require_mfa?: boolean; trip_reminders_enabled?: boolean; places_photos_enabled?: boolean; places_autocomplete_enabled?: boolean; places_details_enabled?: boolean; places_enrich_enabled?: boolean; place_shadow_enabled?: boolean; permissions?: Record<string, PermissionLevel> }) => {
setManaged(!!config?.managed)
setDemoMode(!!config?.demo_mode)
if (config?.dev_mode) setDevMode(true)
if (config?.is_prerelease !== undefined) setIsPrerelease(config.is_prerelease)
if (config?.version) setAppVersion(config.version)
if (config?.has_maps_key !== undefined) setHasMapsKey(config.has_maps_key)
if (config?.has_amap_key !== undefined) setHasAmapKey(config.has_amap_key)
if (config?.places_provider) setPlacesProvider(config.places_provider)
if (config?.timezone) setServerTimezone(config.timezone)
if (config?.require_mfa !== undefined) setAppRequireMfa(!!config.require_mfa)
if (config?.trip_reminders_enabled !== undefined) setTripRemindersEnabled(config.trip_reminders_enabled)
+20 -4
View File
@@ -53,8 +53,9 @@ import { NightPauseDrag } from './NightPauseDrag'
import type { DayBoundaryControls } from './dayBoundaryDrag'
import { POI_CATEGORY_BY_KEY, type Poi } from './poiCategories'
import { resolveTrackColor, hasManualTrackColor } from './trackColors'
import { OFM_POSITRON, DEFAULT_MAP_CENTER, DEFAULT_MAP_ZOOM, MAP_MAX_ZOOM, SATELLITE_TILE_URL, SATELLITE_TILE_ATTRIBUTION, SATELLITE_TILE_MAXZOOM, attributionForTile } from '../../constants/mapDefaults'
import { resolveBasemap } from '../../utils/tileUrl'
import { OFM_POSITRON, DEFAULT_MAP_CENTER, DEFAULT_MAP_ZOOM, MAP_MAX_ZOOM, SATELLITE_TILE_URL, SATELLITE_TILE_ATTRIBUTION, SATELLITE_TILE_MAXZOOM, AMAP_SATELLITE, attributionForTile } from '../../constants/mapDefaults'
import { crsForBasemap } from './gcj02Crs'
import { isGcj02Basemap, resolveBasemap } from '../../utils/tileUrl'
import VectorBasemap from './VectorBasemap'
import { useSettingsStore } from '../../store/settingsStore'
import { MapLayerSwitcher } from './MapLayerSwitcher'
@@ -687,6 +688,13 @@ export const MapView = memo(function MapView({
const basemap = useMemo(() => resolveBasemap(tileUrl, OFM_POSITRON), [tileUrl])
const poiClickRef = useRef(onPoiClick)
poiClickRef.current = onPoiClick
/**
* Amap's tiles are drawn in GCJ-02 (see gcj02Crs.ts). The projection has to be
* decided before the map is constructed, because Leaflet cannot change a map's
* CRS afterwards — hence the whole map, not just the tile layer.
*/
const isGcjBasemap = basemap.kind === 'raster' && isGcj02Basemap(basemap.url)
const gcjCrs = useMemo(() => crsForBasemap(isGcjBasemap), [isGcjBasemap])
const poiMarkers = useMemo(() => (pois as Poi[]).map((poi: Poi) => (
<Marker
key={`poi-${poi.osm_id}`}
@@ -1002,6 +1010,10 @@ export const MapView = memo(function MapView({
// VectorBasemap had no ceiling at all. MarkerClusterGroup.onAdd throws
// outright on an infinite one, which took the whole planner down.
maxZoom={MAP_MAX_ZOOM}
// Omitted entirely for a WGS-84 basemap, which is every install that has
// not chosen Amap: passing L.CRS.EPSG3857 explicitly would be the same
// value, but leaving the prop off keeps those maps byte-identical.
{...(gcjCrs ? { crs: gcjCrs } : {})}
className="w-full h-full bg-[#e5e7eb]"
>
{/* The basemap is a vector style by default and a raster template when the
@@ -1012,8 +1024,12 @@ export const MapView = memo(function MapView({
{isSatellite ? (
<TileLayer
key="satellite"
url={SATELLITE_TILE_URL}
attribution={SATELLITE_TILE_ATTRIBUTION}
// Esri's imagery is WGS-84 and Amap's is GCJ-02, and the map's CRS is
// already fixed by the basemap the user chose. Handing the other datum
// to that projection moves the whole photo a few hundred metres, so the
// satellite toggle follows the datum rather than always meaning Esri.
url={isGcjBasemap ? AMAP_SATELLITE : SATELLITE_TILE_URL}
attribution={attributionForTile(isGcjBasemap ? AMAP_SATELLITE : SATELLITE_TILE_URL)}
maxZoom={SATELLITE_TILE_MAXZOOM}
keepBuffer={8}
updateWhenZooming={false}
@@ -0,0 +1,68 @@
import { describe, expect, it } from 'vitest'
import L from 'leaflet'
import { crsForBasemap, getCrsGcj02 } from './gcj02Crs'
const CRS_GCJ02 = getCrsGcj02()
/**
* What these guard is the one failure that cannot be seen in a screenshot review
* of a map full of Chinese labels: a marker drawn a few hundred metres from where
* it belongs still looks like a perfectly plausible map.
*/
describe('CRS_GCJ02', () => {
it('projects a WGS-84 coordinate onto the GCJ-02 tile grid', () => {
const wgs = L.latLng(39.90869, 116.39124)
const shifted = CRS_GCJ02.latLngToPoint(wgs, 15)
const unshifted = L.CRS.EPSG3857.latLngToPoint(wgs, 15)
// At zoom 15 the GCJ-02 offset is worth several hundred pixels, so a map that
// silently kept EPSG3857 would fail here rather than being off by a rounding.
const dx = Math.abs(shifted.x - unshifted.x)
const dy = Math.abs(shifted.y - unshifted.y)
expect(Math.hypot(dx, dy)).toBeGreaterThan(20)
})
it('round-trips a coordinate through the pixel plane', () => {
const wgs = L.latLng(31.2304, 121.4737)
const back = CRS_GCJ02.pointToLatLng(CRS_GCJ02.latLngToPoint(wgs, 18), 18)
// Sub-metre at zoom 18, i.e. the click handler and the marker agree.
expect(back.lat).toBeCloseTo(wgs.lat, 6)
expect(back.lng).toBeCloseTo(wgs.lng, 6)
})
it('leaves a coordinate outside China exactly where EPSG3857 puts it', () => {
// Otherwise choosing an Amap basemap would move every foreign trip in the
// account, which is a far bigger failure than the one this file prevents.
const paris = L.latLng(48.8566, 2.3522)
const shifted = CRS_GCJ02.latLngToPoint(paris, 15)
const plain = L.CRS.EPSG3857.latLngToPoint(paris, 15)
expect(shifted.x).toBeCloseTo(plain.x, 6)
expect(shifted.y).toBeCloseTo(plain.y, 6)
})
it('keeps the Web Mercator tile grid, because Amap uses the standard one', () => {
// Same scale and same transformation as EPSG3857: only the datum differs. If
// this drifts, tiles are requested at the wrong zoom or the wrong origin and
// the map is visibly broken rather than subtly offset.
expect(CRS_GCJ02.scale(12)).toBe(L.CRS.EPSG3857.scale(12))
expect(CRS_GCJ02.code).toBe('TREK:GCJ02')
})
it('measures distance in WGS-84, since that is what it is handed', () => {
const a = L.latLng(39.90869, 116.39124)
const b = L.latLng(39.91869, 116.39124)
expect(CRS_GCJ02.distance(a, b)).toBeCloseTo(L.CRS.EPSG3857.distance(a, b), 6)
})
})
describe('crsForBasemap', () => {
it('returns undefined for a WGS-84 basemap so the map keeps Leaflet\u2019s default', () => {
// Not L.CRS.EPSG3857: passing the prop at all pins the CRS, and the point is
// that a map on an ordinary basemap is constructed exactly as it was before.
expect(crsForBasemap(false)).toBeUndefined()
})
it('returns the shifted CRS for a GCJ-02 basemap', () => {
expect(crsForBasemap(true)).toBe(getCrsGcj02())
})
})
+79
View File
@@ -0,0 +1,79 @@
import L from 'leaflet'
import { gcj02ToWgs84, wgs84ToGcj02 } from '@trek/shared'
/**
* A Leaflet CRS that draws WGS-84 coordinates on GCJ-02 tiles.
*
* Amap's raster tiles are rendered in GCJ-02 — the datum Chinese law requires
* published maps to use — while every coordinate in TREK is WGS-84: the database
* columns, the search results, the routes from OSRM, an imported GPX track. Put
* one on the other untouched and every marker sits a few hundred metres from the
* building it names, which around a city block is the wrong street.
*
* The fix could live in three places, and two of them are worse:
*
* - Converting each marker at the call site means every component that draws
* anything on a map has to know which basemap is loaded — MapView,
* JourneyMap, CollectionMap, the cluster group, the route polylines, the
* bounds fitting, the click handler that turns a pixel back into a place.
* Miss one and it is off by 300 m with no error anywhere.
* - A constant offset is not a fix at all: the shift is a function of position,
* ranging from roughly 100 m to 800 m across the country.
*
* So it goes into the projection, which is the one place every coordinate
* already passes through. `project` shifts WGS-84 → GCJ-02 before the standard
* Web Mercator maths, `unproject` shifts back, and everything above it —
* markers, polylines, `getBounds`, `containerPointToLatLng` — keeps speaking
* WGS-84 without knowing this exists.
*
* Everything else is inherited from EPSG3857 on purpose: Amap's tile grid IS
* standard Web Mercator (same zoom levels, same 256 px tiles, same origin), so
* the scale and the transformation must not change. Only the datum does.
* `distance()` also stays as it is and stays correct, because it is handed
* WGS-84 latitudes.
*/
function buildCrs(): L.CRS {
const mercator = L.Projection.SphericalMercator
const projection: L.Projection = {
project(latlng: L.LatLng): L.Point {
const gcj = wgs84ToGcj02(latlng.lat, latlng.lng)
return mercator.project(L.latLng(gcj.lat, gcj.lng))
},
unproject(point: L.Point): L.LatLng {
const gcj = mercator.unproject(point)
const wgs = gcj02ToWgs84(gcj.lat, gcj.lng)
return L.latLng(wgs.lat, wgs.lng)
},
bounds: mercator.bounds,
}
return L.Util.extend({}, L.CRS.EPSG3857, { code: 'TREK:GCJ02', projection }) as L.CRS
}
/**
* Built on first use, not at import.
*
* Reading `L.Projection.SphericalMercator` while the module graph loads means
* every importer of MapView has to have a fully-formed Leaflet — which the map
* component tests deliberately do not, they mock it down to the handful of
* exports they assert on. A basemap nobody selected should not be able to break
* an unrelated test file, or a build, by existing.
*/
let cached: L.CRS | null = null
export function getCrsGcj02(): L.CRS {
if (!cached) cached = buildCrs()
return cached
}
/**
* The CRS a map should use for a given basemap.
*
* Returning `undefined` rather than `L.CRS.EPSG3857` matters: passing a `crs`
* option to a MapContainer is what pins it, and Leaflet cannot change a map's
* CRS after construction. Handing back undefined lets the component omit the
* prop entirely for the 99% of installs on a WGS-84 basemap, which keeps their
* map byte-identical to what it was.
*/
export function crsForBasemap(isGcj02: boolean): L.CRS | undefined {
return isGcj02 ? getCrsGcj02() : undefined
}
@@ -474,12 +474,49 @@ describe('PlaceDetailsColumn — hours and rating', () => {
* same thing in the shape every other empty state in TREK uses, and whose key is
* missing is not this panel's business.
*/
describe('PlaceDetailsColumn — nothing found', () => {
it('FE-PDC-027: shows the empty state once, and no advice about API keys', async () => {
placeEnrichment.mockResolvedValue({ photos: [], facts: [], description: null })
renderColumn()
describe('PlaceDetailsColumn — no key behind place search', () => {
const empty = () => placeEnrichment.mockResolvedValue({ photos: [], facts: [], description: null })
expect(await screen.findByText('places.details.nothing')).toBeInTheDocument()
it('FE-PDC-027: suggests a key when nothing was found and none is set', async () => {
empty()
renderColumn({ keySuggestion: 'google' })
expect(await screen.findByText('places.details.noKeyTitle')).toBeInTheDocument()
expect(screen.getByText('places.details.noKeyHint')).toBeInTheDocument()
})
it('FE-PDC-027b: suggests the Amap key when that is the one the admin chose', async () => {
// An install on Amap is not "missing a Google key" — the hint has to name
// the key that would actually move search off OpenStreetMap.
empty()
renderColumn({ keySuggestion: 'amap' })
expect(await screen.findByText('places.details.noKeyTitle')).toBeInTheDocument()
expect(screen.getByText('places.details.noKeyHintAmap')).toBeInTheDocument()
})
it('FE-PDC-027c: names both keys when the choice is automatic and neither is set', async () => {
empty()
renderColumn({ keySuggestion: 'any' })
expect(await screen.findByText('places.details.noKeyTitle')).toBeInTheDocument()
expect(screen.getByText('places.details.noKeyHintAny')).toBeInTheDocument()
})
it('FE-PDC-027d: stays quiet when OpenStreetMap was chosen on purpose', async () => {
// The null suggestion: an explicit admin decision, not a gap to fill.
empty()
renderColumn({ keySuggestion: null })
await screen.findByText('places.details.nothing')
expect(screen.queryByText('places.details.noKeyTitle')).not.toBeInTheDocument()
})
it('FE-PDC-028: stays quiet when a key is already configured', async () => {
empty()
renderColumn({ keySuggestion: null })
await screen.findByText('places.details.nothing')
expect(screen.queryByText('places.details.noKeyTitle')).not.toBeInTheDocument()
expect(screen.queryByText('places.details.noKeyHint')).not.toBeInTheDocument()
})
@@ -490,7 +527,7 @@ describe('PlaceDetailsColumn — nothing found', () => {
facts: [],
description: { text: 'Ein Museum.', source: 'wikipedia', sourceUrl: null, license: 'CC BY-SA 4.0' },
})
renderColumn()
renderColumn({ keySuggestion: 'google' })
await screen.findByText('Ein Museum.')
expect(screen.queryByText('places.details.nothing')).not.toBeInTheDocument()
@@ -15,6 +15,7 @@ import {
Loader2,
ScrollText,
ShoppingBag,
Sparkles,
Sprout,
Star,
Sun,
@@ -26,8 +27,19 @@ import { resolveOpenNow, resolvePlaceTimeZone, placeWeekdayIndex } from './place
import { convertHoursLine, isUnknownHoursLine, splitHoursLine } from './placeHoursFormat'
import { safeHttpUrl } from '../../utils/safeUrl'
import EmptyState from '../shared/EmptyState'
import type { KeySuggestion } from '../../utils/placesProvider'
import type { TranslationFn } from '../../types'
/**
* The "add a key" hint line, per which key would move search off OpenStreetMap.
* The Google variant reuses the long-standing key every locale already carries.
*/
const NO_KEY_HINT_KEY: Record<Exclude<KeySuggestion, never>, string> = {
google: 'places.details.noKeyHint',
amap: 'places.details.noKeyHintAmap',
any: 'places.details.noKeyHintAny',
}
/** The place the column is describing. Null while nothing is selected. */
export interface PlaceDetailsSelection {
placeId?: string
@@ -51,7 +63,12 @@ interface PlaceDetailsColumnProps {
timeFormat?: string
/** For grouping the rating count's digits. */
locale?: string
/** False on an instance with no Google key, which is most of them. */
/**
* Which API key the "more detail available" hint should suggest, or null when
* there is nothing to suggest — a configured provider answers, or the admin
* chose OpenStreetMap on purpose and the hint would be a nag.
*/
keySuggestion?: KeySuggestion | null
t: TranslationFn
}
@@ -117,6 +134,7 @@ export default function PlaceDetailsColumn({
language,
timeFormat = '24h',
locale = 'en-US',
keySuggestion = null,
t,
}: PlaceDetailsColumnProps): React.ReactElement {
const [data, setData] = useState<MapsPlaceEnrichmentResult | null>(null)
@@ -236,14 +254,20 @@ export default function PlaceDetailsColumn({
plainly; whose key is missing is not this panel's business, and on a
place the free sources describe fine it read as an advert. */}
{isEmpty && (
<EmptyState
scene="search"
mood="sad"
title={t('places.details.nothing')}
size={92}
fill
surface="var(--bg-secondary)"
/>
<div className="space-y-2">
<EmptyState
scene="search"
mood="sad"
title={t('places.details.nothing')}
size={92}
fill
surface="var(--bg-secondary)"
/>
{/* Only when both are true. With a key behind the search there is
nothing to suggest, and on a place the free sources DID describe
the suggestion would be an advert. */}
{keySuggestion && <NoKeyHint t={t} suggestion={keySuggestion} />}
</div>
)}
{selection && state === 'ready' && !data?.disabled && !isEmpty && (
@@ -472,6 +496,19 @@ function PhotoCredit({ photo }: { photo: PlacePhotoCandidate }): React.ReactElem
* It also names who to ask: the key is an instance-wide setting, so on most
* installs the person reading this cannot act on it themselves.
*/
function NoKeyHint({ t, suggestion }: { t: TranslationFn; suggestion: KeySuggestion }): React.ReactElement {
return (
<div className="rounded-xl border border-accent/25 bg-accent-subtle p-3">
<div className="flex items-center gap-2">
<span className="flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-accent text-accent-text">
<Sparkles className="h-3 w-3" />
</span>
<p className="text-caption font-semibold text-content">{t('places.details.noKeyTitle')}</p>
</div>
<p className="mt-2 text-caption leading-relaxed text-content-secondary">{t(NO_KEY_HINT_KEY[suggestion])}</p>
</div>
)
}
/**
* The star rating, as stars.
@@ -462,6 +462,35 @@ describe('PlaceFormModal', () => {
expect(await screen.findByText('OpenStreetMap')).toBeInTheDocument();
});
// The notice says what is actually true per provider choice, not "add a
// Google key" on an install whose admin picked Amap. See utils/placesProvider.
describe('FE-PLANNER-PLACEFORM-022b: the OSM notice follows the provider choice', () => {
it('suggests the Amap key when the admin chose Amap and none is set', () => {
seedStore(useAuthStore, { user: buildUser(), isAuthenticated: true, hasMapsKey: false, hasAmapKey: false, placesProvider: 'amap' });
render(<PlaceFormModal {...defaultProps} />);
expect(screen.getByText(/高德|Amap/i)).toBeInTheDocument();
});
it('stays quiet when the chosen provider has its key', () => {
seedStore(useAuthStore, { user: buildUser(), isAuthenticated: true, hasMapsKey: false, hasAmapKey: true, placesProvider: 'amap' });
render(<PlaceFormModal {...defaultProps} />);
expect(screen.queryByText(/OpenStreetMap/i)).not.toBeInTheDocument();
});
it('stays quiet when the admin explicitly chose OpenStreetMap', () => {
seedStore(useAuthStore, { user: buildUser(), isAuthenticated: true, hasMapsKey: false, hasAmapKey: false, placesProvider: 'openstreetmap' });
render(<PlaceFormModal {...defaultProps} />);
// A deliberate choice is not a gap to fill — no upsell line at all.
expect(screen.queryByText(/OpenStreetMap/i)).not.toBeInTheDocument();
});
it('stays quiet under auto when a Google key is configured', () => {
seedStore(useAuthStore, { user: buildUser(), isAuthenticated: true, hasMapsKey: true, hasAmapKey: true, placesProvider: 'auto' });
render(<PlaceFormModal {...defaultProps} />);
expect(screen.queryByText(/OpenStreetMap/i)).not.toBeInTheDocument();
});
});
// ── Category ─────────────────────────────────────────────────────────────────
it('FE-PLANNER-PLACEFORM-023: category selector renders options', () => {
@@ -7,6 +7,18 @@ import { mapsApi } from '../../api/client'
import { recordPlacePick } from '../../api/placeShadow'
import { useAuthStore } from '../../store/authStore'
import { useAddonStore } from '../../store/addonStore'
import { effectivePlacesProvider, osmKeySuggestion, type KeySuggestion } from '../../utils/placesProvider'
/**
* The "search is on OpenStreetMap" line, per which key would actually move it
* off. The Google variant reuses the long-standing key, which every locale
* already carries; the other two are new with Amap.
*/
const OSM_ACTIVE_KEY: Record<KeySuggestion, string> = {
google: 'places.osmActive',
amap: 'places.osmActiveAmap',
any: 'places.osmActiveAny',
}
import { useCanDo } from '../../store/permissionsStore'
import { useTripStore } from '../../store/tripStore'
import { useSettingsStore } from '../../store/settingsStore'
@@ -158,7 +170,11 @@ function usePlaceFormModal(props: PlaceFormModalProps) {
const placesSessionRef = useRef(new PlacesSession())
const toast = useToast()
const { t, language, locale } = useTranslation()
const { hasMapsKey, placesEnrichEnabled } = useAuthStore()
const { hasMapsKey, hasAmapKey, placesProvider, placesEnrichEnabled } = useAuthStore()
// What the search notices say depends on what will actually answer: an
// install whose admin picked Amap is not "missing a Google key".
const searchProvider = effectivePlacesProvider({ placesProvider, hasMapsKey, hasAmapKey })
const keySuggestion = osmKeySuggestion(placesProvider)
const can = useCanDo()
const timeFormat = useSettingsStore((s) => s.settings.time_format) || '24h'
const tripObj = useTripStore((s) => s.trip)
@@ -647,7 +663,8 @@ function usePlaceFormModal(props: PlaceFormModalProps) {
language,
locale,
timeFormat,
hasMapsKey,
searchProvider,
keySuggestion,
placesEnrichEnabled,
can,
tripObj,
@@ -719,7 +736,8 @@ export default function PlaceFormModal(props: PlaceFormModalProps) {
toast,
t,
language,
hasMapsKey,
searchProvider,
keySuggestion,
placesEnrichEnabled,
can,
tripObj,
@@ -793,12 +811,21 @@ export default function PlaceFormModal(props: PlaceFormModalProps) {
language={language}
timeFormat={S.timeFormat}
locale={S.locale}
// Null when search genuinely has no key behind it — the column then
// explains which one would help, or stays quiet when OpenStreetMap
// was chosen on purpose.
keySuggestion={searchProvider === 'openstreetmap' ? keySuggestion : null}
t={t}
/>
)}
<form onSubmit={handleSubmit} className={twoColumn || showDetails ? 'flex-1 min-w-0 space-y-3' : 'space-y-3'} onPaste={handlePaste}>
{/* Place Search */}
<div className="bg-surface-secondary rounded-xl p-3 border border-edge">
{searchProvider === 'openstreetmap' && keySuggestion && (
<p className="mb-2 text-xs text-content-faint">
{t(OSM_ACTIVE_KEY[keySuggestion])}
</p>
)}
<div className="relative">
<div className="flex gap-2">
<input
@@ -0,0 +1,49 @@
import { describe, expect, it } from 'vitest'
import { gcj02ToWgs84 } from '@trek/shared'
import { getAmapUrlForPlace } from './placeAmap'
describe('getAmapUrlForPlace', () => {
it('writes the position as lng,lat in GCJ-02', () => {
const url = getAmapUrlForPlace({ name: '天安门', lat: 39.90869, lng: 116.39124 })!
const position = new URL(url).searchParams.get('position')!
const [lng, lat] = position.split(',').map(Number)
// Longitude first — the opposite of every other pair in TREK, and what
// Amap's URI API specifies.
expect(lng).toBeGreaterThan(100)
expect(lat).toBeLessThan(90)
// And in Amap's datum: converting back must land on the stored WGS-84 value.
// Handing the WGS-84 pair over unconverted would drop the pin a few hundred
// metres away, which in a city is the wrong block.
const back = gcj02ToWgs84(lat, lng)
expect(back.lat).toBeCloseTo(39.90869, 4)
expect(back.lng).toBeCloseTo(116.39124, 4)
expect(lat).not.toBeCloseTo(39.90869, 4)
})
it('carries the place name as the marker label', () => {
const url = getAmapUrlForPlace({ name: '故宫博物院', lat: 39.9163, lng: 116.3972 })!
expect(new URL(url).searchParams.get('name')).toBe('故宫博物院')
})
it('labels an unnamed place with its coordinates rather than returning nothing', () => {
// Amap refuses a marker request with no name, so the alternative to this is
// no link at all for a place added by right-clicking the map.
const url = getAmapUrlForPlace({ name: '', lat: 39.9163, lng: 116.3972 })!
const params = new URL(url).searchParams
expect(params.get('name')).toBe(params.get('position'))
})
it('needs coordinates: a name alone has nothing to place', () => {
expect(getAmapUrlForPlace({ name: '某地', lat: null, lng: null } as never)).toBeNull()
expect(getAmapUrlForPlace(null)).toBeNull()
})
it('leaves a coordinate outside China unshifted', () => {
// GCJ-02 does not apply there, and shifting it would move the pin off the
// place for a trip that has nothing to do with China.
const url = getAmapUrlForPlace({ name: 'Eiffel Tower', lat: 48.8584, lng: 2.2945 })!
expect(new URL(url).searchParams.get('position')).toBe('2.294500,48.858400')
})
})
@@ -0,0 +1,33 @@
import { wgs84ToGcj02 } from '@trek/shared'
import type { AssignmentPlace, Place } from '../../types'
type PlaceLike = Pick<Place | AssignmentPlace, 'name' | 'lat' | 'lng'>
/**
* Open a place in Amap (高德地图), which is what a phone in China has installed.
*
* `uri.amap.com` is Amap's own cross-platform handover endpoint: it opens the app
* when it is present and falls back to Amap's web map when it is not, which is
* the same property that made the https form the right choice for CoMaps. The
* `src` and `callnative` parameters are what Amap documents for this: `callnative=1`
* asks it to hand over to the app rather than staying on the web page.
*
* The coordinate is converted to GCJ-02 first. Amap reads `position` in its own
* datum, and TREK stores WGS-84, so handing the stored value over unconverted
* drops the pin a few hundred metres away — far enough, in a city, to be the
* wrong block. This is the mirror image of what the server does when it reads an
* Amap result (see server/src/nest/maps/providers/amap.provider.ts).
*
* Longitude first, unlike every other coordinate pair in this codebase: that is
* the order Amap's URI API specifies.
*/
export function getAmapUrlForPlace(place: PlaceLike | null | undefined): string | null {
if (!place || place.lat == null || place.lng == null) return null
const gcj = wgs84ToGcj02(place.lat, place.lng)
const position = `${gcj.lng.toFixed(6)},${gcj.lat.toFixed(6)}`
const name = place.name?.trim()
// Amap labels the marker from `name` and refuses the request without one, so an
// unnamed place gets its coordinates as the label rather than no link at all.
const label = encodeURIComponent(name || position)
return `https://uri.amap.com/marker?position=${position}&name=${label}&src=trek&coordinate=gaode&callnative=1`
}
@@ -1,11 +1,13 @@
import { isOutsideChina } from '@trek/shared'
import type { AssignmentPlace, Place } from '../../types'
import { getAmapUrlForPlace } from './placeAmap'
import { getCoMapsUrlForPlace } from './placeCoMaps'
import { getGoogleMapsUrlForPlace } from './placeGoogleMaps'
import { getOpenStreetMapUrlForPlace } from './placeOpenStreetMap'
type PlaceLike = Pick<Place | AssignmentPlace, 'name' | 'address' | 'lat' | 'lng' | 'google_place_id' | 'google_ftid'>
export type NavigationAppId = 'google' | 'waze' | 'apple' | 'osm' | 'comaps'
export type NavigationAppId = 'google' | 'waze' | 'apple' | 'osm' | 'comaps' | 'amap'
export interface NavigationTarget {
id: NavigationAppId
@@ -92,6 +94,17 @@ export function getNavigationTargets(
const coMapsUrl = getCoMapsUrlForPlace(place)
if (coMapsUrl) targets.push({ id: 'comaps', label: 'CoMaps', url: coMapsUrl })
// Offered by WHERE the place is, not by who is looking at it. Amap only has a
// map of China, so it is the right choice for a stop in Shanghai whoever is
// planning the trip, and dead weight for one in Lisbon whoever is planning it —
// and this row is deliberately short. showsAppleMaps() above narrows by
// platform for the same reason; this one narrows by geography because that is
// what decides whether the link leads anywhere.
if (place.lat != null && place.lng != null && !isOutsideChina(place.lat, place.lng)) {
const amapUrl = getAmapUrlForPlace(place)
if (amapUrl) targets.push({ id: 'amap', label: '高德地图', url: amapUrl })
}
return targets
}
@@ -79,7 +79,10 @@ export function usePlacesSidebar(props: PlacesSidebarProps) {
const can = useCanDo()
const canEditPlaces = can('place_edit', trip)
const collectionsEnabled = useAddonStore((s) => s.isEnabled('collections'))
// Places-API enrichment (#886) needs a Google Maps key; gate the toggle on it.
// Places-API enrichment (#886) needs a Google Maps key. Not the places
// *provider* choice: enrichment's photos and summary come from Google (and,
// keyless, from Wikimedia), which is independent of which provider answers
// search — an Amap install with a Google key still enriches through Google.
const canEnrichImport = useAuthStore((s) => s.hasMapsKey)
const [fileImportOpen, setFileImportOpen] = useState(false)
@@ -12,6 +12,7 @@ import { GlMapPreviewMapbox, GlMapPreviewMaplibre } from '../Map/glLazy'
import Section from './Section'
import ToggleSwitch from './ToggleSwitch'
import { withTileApiKey } from '../../utils/tileUrl'
import { AMAP_ROAD, AMAP_SATELLITE } from '../../constants/mapDefaults'
import type { Place } from '../../types'
import {
MAPBOX_DEFAULT_STYLE,
@@ -40,6 +41,11 @@ const MAP_PRESETS: MapPreset[] = [
{ name: 'CartoDB Light', url: 'https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png' },
{ name: 'CartoDB Dark', url: 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png' },
{ name: 'Stadia Smooth', url: 'https://tiles.stadiamaps.com/tiles/alidade_smooth/{z}/{x}/{y}{r}.png' },
// Amap (高德). GCJ-02 tiles — the map switches to a shifted projection for
// these so markers still land on the right street (see gcj02Crs.ts). The only
// basemap here that is genuinely good inside mainland China.
{ name: '高德地图 (Amap)', url: AMAP_ROAD },
{ name: '高德卫星 (Amap Satellite)', url: AMAP_SATELLITE },
]
// Tag → chip color mapping. Keeps the dropdown readable at a glance so a
+28
View File
@@ -51,6 +51,33 @@ export const OFM_ATTRIBUTION =
const OSM_ATTRIBUTION = '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>'
/**
* Amap (高德) raster basemaps.
*
* The reason to offer these at all: OpenStreetMap and OpenFreeMap are thin
* inside mainland China, and every foreign tile CDN is slow or unreachable from
* a Chinese network. These are the tiles a Chinese user expects to see.
*
* `style=7` is the road map, `style=6` is satellite imagery. `lang=zh_cn`
* because these are the labels the tiles exist for; an install that wants
* English labels is better served by OpenFreeMap.
*
* A single host rather than the `webrd0{1..4}` shard family Amap also serves:
* `{s}` in a Leaflet template is substituted from `subdomains`, which defaults
* to a/b/c and would produce `webrd0a`. Every consumer of a stored template
* would have to pass digits instead, and over HTTP/2 the sharding buys nothing.
*
* IMPORTANT: these tiles are drawn in GCJ-02, not WGS-84. Everything else in
* TREK — every marker, route and bounding box — is WGS-84, and dropping one
* datum on top of the other puts markers a few hundred metres off the street
* they belong to. isGcj02Basemap() in utils/tileUrl.ts spots these URLs and the
* map switches to the shifted CRS in components/Map/gcj02Crs.ts. Anything added
* here that serves GCJ-02 tiles must be recognised by that predicate too.
*/
export const AMAP_ROAD = 'https://webrd01.is.autonavi.com/appmaptile?lang=zh_cn&size=1&scale=1&style=7&x={x}&y={y}&z={z}'
export const AMAP_SATELLITE = 'https://webst01.is.autonavi.com/appmaptile?style=6&x={x}&y={y}&z={z}'
export const AMAP_ATTRIBUTION = '&copy; <a href="https://amap.com">高德地图</a>'
/**
* Attribution for whatever basemap a map ended up with. OpenFreeMap asks for a
* credit of its own, and printing OpenStreetMap alone under its tiles is both
@@ -61,6 +88,7 @@ export function attributionForTile(url: string | null | undefined): string {
if (!url) return OSM_ATTRIBUTION
if (url.includes('openfreemap.org')) return OFM_ATTRIBUTION
if (url.includes('arcgisonline.com')) return SATELLITE_TILE_ATTRIBUTION
if (url.includes('autonavi.com')) return AMAP_ATTRIBUTION
return OSM_ATTRIBUTION
}
@@ -38,7 +38,8 @@ export default function MAdminSettingsSection({ admin, t }: MAdminSettingsSectio
passkeyLogin, setPasskeyLogin, passkeyConfigured,
webauthnRpId, setWebauthnRpId, webauthnOrigins, setWebauthnOrigins, savingWebauthn, handleSaveWebauthn,
allowedFileTypes, setAllowedFileTypes, savingFileTypes, setSavingFileTypes,
mapsKey, setMapsKey, unsplashKey, setUnsplashKey, savingKeys, validating, validation,
mapsKey, setMapsKey, unsplashKey, setUnsplashKey, amapKey, setAmapKey, savingKeys, validating, validation,
placesProvider, savingPlacesProvider, handleSavePlacesProvider,
setShowRotateJwtModal,
handleToggleAuthSetting, handleToggleRequireMfa,
handleSaveApiKeys, handleValidateKey,
@@ -229,6 +230,7 @@ export default function MAdminSettingsSection({ admin, t }: MAdminSettingsSectio
<div className="flex gap-2">
<div className="min-w-0 flex-1">
<MAdminSecretInput
aria-label={t('admin.mapsKey')}
value={mapsKey}
onChange={(e) => setMapsKey(e.target.value)}
placeholder={t('settings.keyPlaceholder')}
@@ -256,8 +258,39 @@ export default function MAdminSettingsSection({ admin, t }: MAdminSettingsSectio
)}
</MAdminField>
{/* No Test button, same as the desktop tab: /auth/validate-keys only
knows how to probe Google Places and OpenWeatherMap. */}
<MAdminField label={t('admin.amapKey')} hint={t('admin.amapKeyHint')}>
<MAdminSecretInput
aria-label={t('admin.amapKey')}
value={amapKey}
onChange={(e) => setAmapKey(e.target.value)}
placeholder={t('settings.keyPlaceholder')}
/>
</MAdminField>
<MAdminField label={t('admin.placesProvider.title')} hint={t('admin.placesProvider.subtitle')}>
<select
value={placesProvider}
disabled={savingPlacesProvider}
onChange={(e) => handleSavePlacesProvider(e.target.value)}
className="h-[42px] w-full rounded-xl border border-[color:var(--m-rowbr)] bg-[color:var(--m-ic)] px-3 text-[0.84375rem] text-m-ink outline-none disabled:opacity-50"
>
<option value="auto">{t('admin.placesProvider.auto')}</option>
<option value="google">{t('admin.placesProvider.google')}</option>
<option value="amap">{t('admin.placesProvider.amap')}</option>
<option value="openstreetmap">{t('admin.placesProvider.openstreetmap')}</option>
</select>
{((placesProvider === 'google' && !mapsKey) || (placesProvider === 'amap' && !amapKey)) && (
<p className="mt-1 font-geist text-[0.625rem] font-bold text-[color:var(--m-st-warn,#b45309)]">
{t('admin.placesProvider.missingKey')}
</p>
)}
</MAdminField>
<MAdminField label={t('admin.unsplashKey')} hint={t('admin.unsplashKeyHint')}>
<MAdminSecretInput
aria-label={t('admin.unsplashKey')}
value={unsplashKey}
onChange={(e) => setUnsplashKey(e.target.value)}
placeholder={t('settings.keyPlaceholder')}
@@ -18,6 +18,7 @@ import {
type GlMapProvider,
} from '../../../components/Map/glProviders'
import { withTileApiKey } from '../../../utils/tileUrl'
import { AMAP_ROAD, AMAP_SATELLITE } from '../../../constants/mapDefaults'
import MToggle from '../../components/MToggle'
import { MSetCard, MSetEyebrow, MSetSelectRow, MSetInput, MSetButton, MSetHint, MSetRow } from './MSettingsUi'
import MSetPickerSheet from './MSetPickerSheet'
@@ -39,6 +40,11 @@ const MAP_PRESETS: MapPreset[] = [
{ name: 'CartoDB Light', url: 'https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png' },
{ name: 'CartoDB Dark', url: 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png' },
{ name: 'Stadia Smooth', url: 'https://tiles.stadiamaps.com/tiles/alidade_smooth/{z}/{x}/{y}{r}.png' },
// Amap (高德). GCJ-02 tiles — the map switches to a shifted projection for
// these so markers still land on the right street (see gcj02Crs.ts). The only
// basemap here that is genuinely good inside mainland China.
{ name: '高德地图 (Amap)', url: AMAP_ROAD },
{ name: '高德卫星 (Amap Satellite)', url: AMAP_SATELLITE },
]
type Provider = 'leaflet' | GlMapProvider
+34 -3
View File
@@ -1168,9 +1168,12 @@ describe('AdminPage', () => {
const apiKeysHeading = await screen.findByRole('heading', { name: /^api keys$/i });
const apiKeysCard = apiKeysHeading.closest<HTMLElement>('.rounded-xl');
// The Unsplash key is the second 'Enter key...' input (after Maps).
const keyInputs = within(apiKeysCard!).getAllByPlaceholderText('Enter key...');
fireEvent.change(keyInputs[1], { target: { value: 'test-unsplash-key' } });
// By accessible name, not by position: the card gained an Amap field
// between Maps and Unsplash, and an index would have kept passing while
// asserting about the wrong input.
fireEvent.change(within(apiKeysCard!).getByLabelText('Unsplash API Key'), {
target: { value: 'test-unsplash-key' },
});
fireEvent.click(within(apiKeysCard!).getByRole('button', { name: /^save$/i }));
@@ -1178,6 +1181,34 @@ describe('AdminPage', () => {
expect(capturedBody?.unsplash_api_key).toBe('test-unsplash-key');
});
});
it('typing in the Amap API key and clicking Save sends amap_api_key', async () => {
let capturedBody: Record<string, unknown> | undefined;
server.use(
http.put('/api/auth/me/api-keys', async ({ request }) => {
capturedBody = (await request.json()) as Record<string, unknown>;
return HttpResponse.json({ success: true });
})
);
seedStore(useAuthStore, { isAuthenticated: true, user: buildAdmin() });
render(<AdminPage />);
await waitFor(() => expect(screen.getByRole('button', { name: /^users$/i })).toBeInTheDocument());
fireEvent.click(screen.getByRole('button', { name: /settings/i }));
const apiKeysHeading = await screen.findByRole('heading', { name: /^api keys$/i });
const apiKeysCard = apiKeysHeading.closest<HTMLElement>('.bg-white');
fireEvent.change(within(apiKeysCard!).getByLabelText(/Amap/), {
target: { value: 'test-amap-key' },
});
fireEvent.click(within(apiKeysCard!).getByRole('button', { name: /^save$/i }));
await waitFor(() => {
expect(capturedBody?.amap_api_key).toBe('test-amap-key');
});
});
});
describe('FE-PAGE-ADMIN-044: Validate API key in Settings tab', () => {
@@ -253,18 +253,22 @@ describe('AdminSettingsTab', () => {
expect(admin.toggleKey).toHaveBeenCalledWith('maps');
});
it('FE-ADMSET-021: showKeys switches both key inputs to plain text', () => {
renderTab({ showKeys: { maps: true, unsplash: true } });
// Selected by accessible name rather than by position: the API-keys card has
// grown a field before now (Amap), and an index-based lookup silently starts
// asserting about a different input when it does.
it('FE-ADMSET-021: showKeys switches every key input to plain text', () => {
renderTab({ showKeys: { maps: true, unsplash: true, amap: true } });
const inputs = within(card('API Keys')).getAllByPlaceholderText('Enter key...');
expect(inputs[0]).toHaveAttribute('type', 'text');
expect(inputs[1]).toHaveAttribute('type', 'text');
const keys = within(card('API Keys'));
expect(keys.getByLabelText('Google Maps API Key')).toHaveAttribute('type', 'text');
expect(keys.getByLabelText('Unsplash API Key')).toHaveAttribute('type', 'text');
expect(keys.getByLabelText(/Amap/)).toHaveAttribute('type', 'text');
});
it('FE-ADMSET-022: unsplash key input and its visibility toggle', () => {
const admin = renderTab({ unsplashKey: '' });
const unsplashInput = within(card('API Keys')).getAllByPlaceholderText('Enter key...')[1];
const unsplashInput = within(card('API Keys')).getByLabelText('Unsplash API Key');
fireEvent.change(unsplashInput, { target: { value: 'unsplash-key' } });
expect(admin.setUnsplashKey).toHaveBeenCalledWith('unsplash-key');
@@ -272,6 +276,34 @@ describe('AdminSettingsTab', () => {
expect(admin.toggleKey).toHaveBeenCalledWith('unsplash');
});
it('FE-ADMSET-022b: amap key input and its visibility toggle', () => {
const admin = renderTab({ amapKey: '' });
const amapInput = within(card('API Keys')).getByLabelText(/Amap/);
fireEvent.change(amapInput, { target: { value: 'amap-key' } });
expect(admin.setAmapKey).toHaveBeenCalledWith('amap-key');
fireEvent.click(amapInput.parentElement!.querySelector('button')!);
expect(admin.toggleKey).toHaveBeenCalledWith('amap');
});
it('FE-ADMSET-022c: warns when the selected provider has no key behind it', () => {
// A provider chosen without a key answers with OpenStreetMap rather than
// failing, which is quiet enough to be mistaken for the provider working.
renderTab({ placesProvider: 'amap', amapKey: '' });
expect(
within(card('API Keys')).getByText(/falls back to OpenStreetMap/i),
).toBeInTheDocument();
});
it('FE-ADMSET-022d: saving the provider goes through updateAppSettings, not the keys', () => {
const admin = renderTab({ placesProvider: 'auto' });
fireEvent.change(within(card('API Keys')).getByLabelText('Place search provider'), {
target: { value: 'amap' },
});
expect(admin.handleSavePlacesProvider).toHaveBeenCalledWith('amap');
});
it('FE-ADMSET-023: the maps Test button is disabled without a key', () => {
renderTab({ mapsKey: '' });
+62 -1
View File
@@ -35,7 +35,8 @@ export default function AdminSettingsTab({ admin, t }: AdminSettingsTabProps): R
passkeyLogin, setPasskeyLogin, passkeyConfigured,
webauthnRpId, setWebauthnRpId, webauthnOrigins, setWebauthnOrigins, savingWebauthn, handleSaveWebauthn,
allowedFileTypes, setAllowedFileTypes, savingFileTypes, setSavingFileTypes,
mapsKey, setMapsKey, unsplashKey, setUnsplashKey, showKeys, savingKeys, validating, validation,
mapsKey, setMapsKey, unsplashKey, setUnsplashKey, amapKey, setAmapKey, showKeys, savingKeys, validating, validation,
placesProvider, savingPlacesProvider, handleSavePlacesProvider,
managed,
setShowRotateJwtModal,
handleToggleAuthSetting, handleToggleRequireMfa,
@@ -546,6 +547,32 @@ export default function AdminSettingsTab({ admin, t }: AdminSettingsTabProps): R
<p className="text-xs text-content-faint mt-1.5">{t('admin.unsplashKeyHint')}</p>
</div>
</ProviderBlock>
{/* Amap Key. No Test button: /auth/validate-keys only knows how to
probe Google Places and OpenWeatherMap, and a button that silently
tests something else is worse than no button. */}
<ProviderBlock title={t('admin.amapKey')}>
<div>
<div className="relative">
<input
type={showKeys.amap ? 'text' : 'password'}
value={amapKey}
onChange={e => setAmapKey(e.target.value)}
placeholder={t('settings.keyPlaceholder')}
className="w-full pr-10 px-3 py-2 border border-edge rounded-lg text-sm bg-surface-input text-content focus:ring-2 focus:ring-accent focus:border-transparent"
/>
<button
type="button"
onClick={() => toggleKey('amap')}
aria-label={t('admin.amapKey')}
className="absolute right-3 top-1/2 -translate-y-1/2 text-content-faint hover:text-content"
>
{showKeys.amap ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
</button>
</div>
<p className="text-xs text-content-faint mt-1.5">{t('admin.amapKeyHint')}</p>
</div>
</ProviderBlock>
{/* Transit Backend (#1699) — Transitous has no GTFS for much of Asia,
so an install can point transit search at Google instead, on the
same key. Falls back to Transitous when no key resolves. A block of
@@ -605,6 +632,40 @@ export default function AdminSettingsTab({ admin, t }: AdminSettingsTabProps): R
</ProviderBlock>
</>)}
{/* Which keyed provider answers place search. Outside the managed guard
on purpose: the operator owns the credentials, but what their users
search against is still the admin's call (see server managed.ts). */}
<ProviderBlock title={t('admin.placesProvider.title')}>
<div>
<p className="text-xs text-content-faint">{t('admin.placesProvider.subtitle')}</p>
<CustomSelect
value={placesProvider}
disabled={savingPlacesProvider}
onChange={value => {
if (value === placesProvider) return
void handleSavePlacesProvider(String(value))
}}
options={[
{ value: 'auto', label: t('admin.placesProvider.auto') },
{ value: 'google', label: t('admin.placesProvider.google') },
{ value: 'amap', label: t('admin.placesProvider.amap') },
{ value: 'openstreetmap', label: t('admin.placesProvider.openstreetmap') },
]}
size="sm"
style={{ marginTop: 8 }}
/>
{/* A provider chosen without a key behind it answers with
OpenStreetMap rather than failing, which is quiet enough to be
mistaken for the provider working. Say so. */}
{((placesProvider === 'google' && !mapsKey) || (placesProvider === 'amap' && !amapKey)) && (
<p className="flex items-start gap-2 text-xs text-warning bg-warning-soft border border-warning/30 rounded-lg px-3 py-2 mt-2">
<AlertTriangle size={14} className="flex-shrink-0 mt-0.5" />
{t('admin.placesProvider.missingKey')}
</p>
)}
</div>
</ProviderBlock>
{/* The search log sits with the index rather than with a provider:
it records which result somebody picked, so a candidate index can
be judged against real searches later. Off unless switched on,
+31 -2
View File
@@ -168,6 +168,15 @@ export function useAdmin() {
const [mapsKey, setMapsKey] = useState<string>('')
const [weatherKey, setWeatherKey] = useState<string>('')
const [unsplashKey, setUnsplashKey] = useState<string>('')
const [amapKey, setAmapKey] = useState<string>('')
/**
* Which provider answers place search. Not a key, so it saves through
* updateAppSettings rather than with the keys — and it is saved on change
* rather than with the Save button, because it is one choice from a list and
* the effect is immediate everywhere.
*/
const [placesProvider, setPlacesProvider] = useState<string>('auto')
const [savingPlacesProvider, setSavingPlacesProvider] = useState<boolean>(false)
const [showKeys, setShowKeys] = useState<Record<string, boolean>>({})
const [savingKeys, setSavingKeys] = useState<boolean>(false)
const [validating, setValidating] = useState<Record<string, boolean>>({})
@@ -234,6 +243,7 @@ export function useAdmin() {
setPasskeyLogin(!!config.passkey_login)
setPasskeyConfigured(!!config.passkey_configured)
if (config.allowed_file_types) setAllowedFileTypes(config.allowed_file_types)
if (config.places_provider) setPlacesProvider(config.places_provider)
} catch (err: unknown) {
// ignore
}
@@ -245,6 +255,7 @@ export function useAdmin() {
setMapsKey(data.settings?.maps_api_key || '')
setWeatherKey(data.settings?.openweather_api_key || '')
setUnsplashKey(data.settings?.unsplash_api_key || '')
setAmapKey(data.settings?.amap_api_key || '')
} catch (err: unknown) {
// ignore
}
@@ -300,6 +311,7 @@ export function useAdmin() {
maps_api_key: mapsKey,
openweather_api_key: weatherKey,
unsplash_api_key: unsplashKey,
amap_api_key: amapKey,
})
toast.success(t('admin.keySaved'))
} catch (err: unknown) {
@@ -313,7 +325,7 @@ export function useAdmin() {
setValidating({ maps: true, weather: true })
try {
// Save first so validation uses the current values
await updateApiKeys({ maps_api_key: mapsKey, openweather_api_key: weatherKey, unsplash_api_key: unsplashKey })
await updateApiKeys({ maps_api_key: mapsKey, openweather_api_key: weatherKey, unsplash_api_key: unsplashKey, amap_api_key: amapKey })
const result = await authApi.validateKeys()
setValidation(result)
} catch (err: unknown) {
@@ -327,7 +339,7 @@ export function useAdmin() {
setValidating(prev => ({ ...prev, [keyType]: true }))
try {
// Save first so validation uses the current values
await updateApiKeys({ maps_api_key: mapsKey, openweather_api_key: weatherKey, unsplash_api_key: unsplashKey })
await updateApiKeys({ maps_api_key: mapsKey, openweather_api_key: weatherKey, unsplash_api_key: unsplashKey, amap_api_key: amapKey })
const result = await authApi.validateKeys()
setValidation(prev => ({ ...prev, [keyType]: result[keyType] }))
} catch (err: unknown) {
@@ -337,6 +349,21 @@ export function useAdmin() {
}
}
const handleSavePlacesProvider = async (value: string) => {
const previous = placesProvider
setPlacesProvider(value)
setSavingPlacesProvider(true)
try {
await authApi.updateAppSettings({ places_provider: value })
toast.success(t('admin.placesProvider.saved'))
} catch (err: unknown) {
setPlacesProvider(previous)
toast.error(getApiErrorMessage(err, t('common.error')))
} finally {
setSavingPlacesProvider(false)
}
}
const handleCreateUser = async () => {
if (!createForm.username.trim() || !createForm.email.trim() || !createForm.password.trim()) {
toast.error(t('admin.toast.fieldsRequired'))
@@ -464,6 +491,8 @@ export function useAdmin() {
allowedFileTypes, setAllowedFileTypes, savingFileTypes, setSavingFileTypes,
smtpValues, setSmtpValues, smtpLoaded,
mapsKey, setMapsKey, weatherKey, setWeatherKey, unsplashKey, setUnsplashKey,
amapKey, setAmapKey,
placesProvider, savingPlacesProvider, handleSavePlacesProvider,
showKeys, setShowKeys, savingKeys, validating, validation,
updateInfo, setUpdateInfo, showUpdateModal, setShowUpdateModal,
showRotateJwtModal, setShowRotateJwtModal, rotatingJwt, setRotatingJwt,
+17
View File
@@ -46,7 +46,15 @@ interface AuthState {
devMode: boolean
isPrerelease: boolean
appVersion: string
/** Google Maps API key. Please note that secondary map api provider is available,
* check `hasAmapKey`. */
hasMapsKey: boolean
/** The same question for the Amap key, and the admin's provider choice. Not
* boolean "hasSearchKey": a notice that says "add a Google key" on an install
* whose admin picked Amap is wrong twice, and the two facts together are what
* the place-search notices need to say something true. */
hasAmapKey: boolean
placesProvider: string
serverTimezone: string
/** Server policy: all users must enable MFA */
appRequireMfa: boolean
@@ -75,6 +83,8 @@ interface AuthState {
setIsPrerelease: (val: boolean) => void
setAppVersion: (val: string) => void
setHasMapsKey: (val: boolean) => void
setHasAmapKey: (val: boolean) => void
setPlacesProvider: (val: string) => void
setServerTimezone: (tz: string) => void
setAppRequireMfa: (val: boolean) => void
setTripRemindersEnabled: (val: boolean) => void
@@ -125,6 +135,8 @@ export const useAuthStore = create<AuthState>()(
isPrerelease: false,
appVersion: '',
hasMapsKey: false,
hasAmapKey: false,
placesProvider: 'auto',
serverTimezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
appRequireMfa: false,
tripRemindersEnabled: false,
@@ -343,6 +355,9 @@ export const useAuthStore = create<AuthState>()(
if ('maps_api_key' in keys) {
set({ hasMapsKey: !!keys.maps_api_key })
}
if ('amap_api_key' in keys) {
set({ hasAmapKey: !!keys.amap_api_key })
}
} catch (err: unknown) {
throw new Error(getApiErrorMessage(err, 'Error saving API keys'))
}
@@ -386,6 +401,8 @@ export const useAuthStore = create<AuthState>()(
setIsPrerelease: (val: boolean) => set({ isPrerelease: val }),
setAppVersion: (val: string) => set({ appVersion: val }),
setHasMapsKey: (val: boolean) => set({ hasMapsKey: val }),
setHasAmapKey: (val: boolean) => set({ hasAmapKey: val }),
setPlacesProvider: (val: string) => set({ placesProvider: val }),
setServerTimezone: (tz: string) => set({ serverTimezone: tz }),
setAppRequireMfa: (val: boolean) => set({ appRequireMfa: val }),
setTripRemindersEnabled: (val: boolean) => set({ tripRemindersEnabled: val }),
+4
View File
@@ -369,6 +369,10 @@ export interface AppConfig {
oidc_display_name?: string
oidc_only_mode?: boolean
has_maps_key?: boolean
/** Amap (高德地图) key present for this caller — the alternative places provider. */
has_amap_key?: boolean
/** The admin's places provider choice: 'auto' | 'google' | 'amap' | 'openstreetmap'. */
places_provider?: string
allowed_file_types?: string
timezone?: string
/** When true, users without MFA cannot use the app until they enable it */
+63
View File
@@ -0,0 +1,63 @@
import { describe, expect, it } from 'vitest'
import { effectivePlacesProvider, osmKeySuggestion } from './placesProvider'
/**
* These mirror MapsService.resolvePlacesProvider() on the server, case for case.
* The point of the mirror is honest notices, so every case where the notices
* would lie — "add a Google key" on an install whose admin picked Amap — is
* pinned here.
*/
describe('effectivePlacesProvider', () => {
it('auto keeps Google when a Google key is configured, Amap key or not', () => {
// The incumbent wins: an existing install must not move providers because
// a new one became available.
expect(effectivePlacesProvider({ placesProvider: 'auto', hasMapsKey: true, hasAmapKey: true })).toBe('google')
expect(effectivePlacesProvider({ placesProvider: 'auto', hasMapsKey: true, hasAmapKey: false })).toBe('google')
})
it('auto falls to Amap only when there is no Google key', () => {
expect(effectivePlacesProvider({ placesProvider: 'auto', hasMapsKey: false, hasAmapKey: true })).toBe('amap')
})
it('auto with neither key means OpenStreetMap', () => {
expect(effectivePlacesProvider({ placesProvider: 'auto', hasMapsKey: false, hasAmapKey: false })).toBe('openstreetmap')
})
it('an explicit amap choice wins even with a Google key configured', () => {
expect(effectivePlacesProvider({ placesProvider: 'amap', hasMapsKey: true, hasAmapKey: true })).toBe('amap')
})
it('an explicit google choice never silently uses Amap instead', () => {
// Misconfigured means "answer with OSM", matching the server — not "bill
// somebody else's provider".
expect(effectivePlacesProvider({ placesProvider: 'google', hasMapsKey: false, hasAmapKey: true })).toBe('openstreetmap')
})
it('openstreetmap ignores both keys', () => {
expect(effectivePlacesProvider({ placesProvider: 'openstreetmap', hasMapsKey: true, hasAmapKey: true })).toBe('openstreetmap')
})
it('a nonsense stored value degrades to auto rather than throwing', () => {
expect(effectivePlacesProvider({ placesProvider: 'not-a-provider', hasMapsKey: false, hasAmapKey: true })).toBe('amap')
expect(effectivePlacesProvider({ placesProvider: undefined, hasMapsKey: true, hasAmapKey: false })).toBe('google')
})
})
describe('osmKeySuggestion', () => {
it('a chosen provider names its own key', () => {
expect(osmKeySuggestion('google')).toBe('google')
expect(osmKeySuggestion('amap')).toBe('amap')
})
it('auto names both, because landing on OSM means neither key is set', () => {
expect(osmKeySuggestion('auto')).toBe('any')
})
it('an explicit OpenStreetMap choice gets no upsell — it is a decision, not a gap', () => {
expect(osmKeySuggestion('openstreetmap')).toBeNull()
// An unrecognised value behaves like auto: the reader degrades it to auto,
// so the notice must not claim the admin chose OSM when they chose nothing.
expect(osmKeySuggestion('garbage')).toBe('any')
expect(osmKeySuggestion(undefined)).toBe('any')
})
})
+69
View File
@@ -0,0 +1,69 @@
/**
* Which provider place search will actually use, as far as the client can tell.
*
* This mirrors the server's MapsService.resolvePlacesProvider(): the instance's
* places_provider choice, the keys the server reported for this caller, and the
* same precedence. It exists so a notice can say the truth — "search is running
* on OpenStreetMap", not "you lack a Google key" on an install whose admin
* deliberately picked Amap.
*
* A mirror and not the source of truth on purpose: the server resolves keys per
* request against the database, and only it knows for sure. If the two ever
* disagree, the client shows a slightly wrong hint under a notice and nothing
* else — search itself keeps asking the server.
*/
export type PlacesProviderChoice = 'auto' | 'google' | 'amap' | 'openstreetmap'
export type EffectiveSearchProvider = 'google' | 'amap' | 'openstreetmap'
export interface PlacesProviderState {
/** The admin's places_provider choice; anything unrecognised means 'auto'. */
placesProvider?: string | null
/** Whether a Google key resolves for this caller (operator env, instance, own row). */
hasMapsKey: boolean
hasAmapKey: boolean
}
export function effectivePlacesProvider(state: PlacesProviderState): EffectiveSearchProvider {
const choice: PlacesProviderChoice =
state.placesProvider === 'google' || state.placesProvider === 'amap' || state.placesProvider === 'openstreetmap'
? state.placesProvider
: 'auto'
if (choice === 'openstreetmap') return 'openstreetmap'
if (choice === 'google') return state.hasMapsKey ? 'google' : 'openstreetmap'
if (choice === 'amap') return state.hasAmapKey ? 'amap' : 'openstreetmap'
// auto prefers Google, the incumbent: an existing install must not move to a
// different provider, with a different bill, just because Amap appeared.
if (state.hasMapsKey) return 'google'
if (state.hasAmapKey) return 'amap'
return 'openstreetmap'
}
/**
* Which key the "still on OpenStreetMap" upsell should suggest.
*
* A chosen provider names the key that belongs to it — telling an admin who
* picked Amap to add a Google key is advice they already declined. `auto` with
* no key at all is the only case where both are worth naming.
*
* Null when the admin explicitly chose OpenStreetMap: that is a decision, not a
* gap to fill, and the notices stay quiet rather than nag.
*/
export type KeySuggestion = 'google' | 'amap' | 'any'
export function osmKeySuggestion(placesProvider?: string | null): KeySuggestion | null {
switch (placesProvider) {
case 'google':
return 'google'
case 'amap':
return 'amap'
case 'openstreetmap':
return null
// auto resolving to OSM means neither key is configured (a Google key would
// have won), so both are honest suggestions.
default:
return 'any'
}
}
+19
View File
@@ -97,6 +97,25 @@ export function isVectorStyle(url: string | null | undefined): boolean {
return /^https?:\/\//i.test(u) || u.startsWith('mapbox://')
}
/**
* Tiles drawn in GCJ-02 rather than WGS-84.
*
* Amap serves its raster tiles from `*.is.autonavi.com`, and they are shifted
* from the WGS-84 coordinates everything else in TREK uses — by 100 to 800 m
* depending on where in China you are. A map on one of these has to project
* through components/Map/gcj02Crs.ts or every marker lands on the wrong street.
*
* Matched on the host, not on the preset constants, so a self-hosted proxy in
* front of the same tiles still gets the right CRS as long as the hostname
* survives — and so a template a user typed in by hand does too.
*/
const GCJ02_TILE_HOST = /(^|\.)is\.autonavi\.com$/i
export function isGcj02Basemap(url: string | null | undefined): boolean {
if (!url) return false
return GCJ02_TILE_HOST.test(templateHost(url))
}
/**
* What a map should draw, given the user's template and the app's default.
*
+5
View File
@@ -143,6 +143,10 @@ export function buildAdminHook(overrides: Record<string, unknown> = {}): AdminHo
setWeatherKey: vi.fn(),
unsplashKey: '',
setUnsplashKey: vi.fn(),
amapKey: '',
setAmapKey: vi.fn(),
placesProvider: 'auto',
savingPlacesProvider: false,
showKeys: {},
setShowKeys: vi.fn(),
savingKeys: false,
@@ -169,6 +173,7 @@ export function buildAdminHook(overrides: Record<string, unknown> = {}): AdminHo
handleSaveApiKeys: vi.fn(),
handleValidateKeys: vi.fn(),
handleValidateKey: vi.fn(),
handleSavePlacesProvider: vi.fn(),
handleCreateUser: vi.fn(),
handleCreateInvite: vi.fn(),
handleDeleteInvite: vi.fn(),
@@ -250,12 +250,18 @@ describe('MAdminSettingsSection', () => {
const user = userEvent.setup();
const admin = renderSettings();
await user.type(screen.getAllByPlaceholderText('Enter key...')[0], 'm');
// By accessible name rather than by position: the card gained an Amap field
// between Maps and Unsplash, and an index quietly starts asserting about a
// different input when that happens.
await user.type(screen.getByLabelText('Google Maps API Key'), 'm');
expect(admin.setMapsKey).toHaveBeenCalledWith('m');
await user.type(screen.getAllByPlaceholderText('Enter key...')[1], 'u');
await user.type(screen.getByLabelText('Unsplash API Key'), 'u');
expect(admin.setUnsplashKey).toHaveBeenCalledWith('u');
await user.type(screen.getByLabelText(/Amap/), 'a');
expect(admin.setAmapKey).toHaveBeenCalledWith('a');
await user.click(screen.getAllByRole('button', { name: 'Save' })[2]);
expect(admin.handleSaveApiKeys).toHaveBeenCalled();
});
+4
View File
@@ -290,6 +290,7 @@ async function main() {
'admin_ntfy_token',
'maps_api_key',
'unsplash_api_key',
'amap_api_key',
]) {
const row = db.prepare('SELECT value FROM app_settings WHERE key = ?').get(key) as { value: string } | undefined;
if (!row?.value) continue;
@@ -312,6 +313,9 @@ async function main() {
const apiKeyColumns = [
'maps_api_key',
'unsplash_api_key',
// Added with the Amap provider; filtered against the real table below, so a
// database that has not run that migration yet is not a rotation failure.
'amap_api_key',
'openweather_api_key',
'immich_api_key',
'synology_password',
+1 -1
View File
@@ -77,7 +77,7 @@ below.)
**Runtime-toggled** (read live on every access via `readEnv()` /
`RuntimeEnvService`; tests mutate these mid-lifetime):
TREK_MANAGED, PLACES_API_BASE, PLACES_API_KEY, MAPBOX_ACCESS_TOKEN, CARTO_API_KEY, DEMO_MODE, NODE_ENV, APP_VERSION, APP_URL, TREK_API_DOCS_ENABLED,
TREK_MANAGED, PLACES_API_BASE, PLACES_API_KEY, AMAP_API_BASE, AMAP_API_KEY, AMAP_API_SECRET, MAPBOX_ACCESS_TOKEN, CARTO_API_KEY, DEMO_MODE, NODE_ENV, APP_VERSION, APP_URL, TREK_API_DOCS_ENABLED,
TREK_PLUGINS_ENABLED / _DEV_LINK / _IGNORE_TREK_RANGE / _DIR / _DATA_DIR / TREK_PLUGIN_PERMISSIONS,
OIDC_*, SMTP_*, FORCE_HTTPS, COOKIE_SECURE, HSTS_INCLUDE_SUBDOMAINS,
ALLOWED_ORIGINS, UNSPLASH_ACCESS_KEY, WEBAUTHN_*, TZ, ADMIN_EMAIL,
+9
View File
@@ -153,6 +153,15 @@ export function deriveMaps(raw: RawEnv) {
*/
trekPlacesEnabled: raw.TREK_PLACES_ENABLED !== 'false',
placesApiKey: raw.PLACES_API_KEY || undefined,
/** The same two knobs for Amap (高德), which an install in China uses instead. */
amapApiBase: raw.AMAP_API_BASE || undefined,
amapApiKey: raw.AMAP_API_KEY || undefined,
/**
* Amap's optional 数字签名 secret. A key created with one rejects every
* unsigned request, so this is not a hardening option — it is required
* whenever the operator's key was issued that way.
*/
amapApiSecret: raw.AMAP_API_SECRET || undefined,
/** Public pk.* token shipped with a managed instance; reaches the browser by design. */
mapboxToken: raw.MAPBOX_ACCESS_TOKEN || undefined,
/** CARTO basemap key; without one the tiles come back watermarked (#2054). Public too. */
+3
View File
@@ -146,6 +146,9 @@ export const envSchema = z.object({
TREK_PLACES_URL: url,
TREK_PLACES_ENABLED: boolStr,
PLACES_API_KEY: anyString,
AMAP_API_BASE: url,
AMAP_API_KEY: anyString,
AMAP_API_SECRET: anyString,
MAPBOX_ACCESS_TOKEN: anyString,
CARTO_API_KEY: anyString,
DEMO_MODE: boolStr,
+1
View File
@@ -10,6 +10,7 @@ function createTables(db: Database.Database): void {
role TEXT NOT NULL DEFAULT 'user',
maps_api_key TEXT,
unsplash_api_key TEXT,
amap_api_key TEXT,
openweather_api_key TEXT,
avatar TEXT,
oidc_sub TEXT,
@@ -242,6 +242,11 @@ export function applyGlobalMiddleware(
// refused here while nothing in the app could see it: the switch flipped, the
// layer went on, and no tile ever arrived (#2307).
"https://server.arcgisonline.com",
// Amap's raster tiles, for an install whose users are in China, for the
// same reason as the hosts above (#2180). Road (webrd01..04) and
// satellite (webst01..04) are numbered shards of one domain, so the
// wildcard is the whole list.
"https://*.is.autonavi.com",
"https://unpkg.com", "https://open-meteo.com", "https://api.open-meteo.com",
"https://geocoding-api.open-meteo.com", "https://api.frankfurter.dev",
"https://router.project-osrm.org/route/v1/", "https://routing.openstreetmap.de/",
+7
View File
@@ -36,6 +36,12 @@ export const ADMIN_SETTINGS_KEYS = [
'notify_trip_reminder',
'password_login', 'password_registration', 'oidc_login', 'oidc_registration',
'passkey_login', 'webauthn_rp_id', 'webauthn_origins',
// Which places provider answers search/autocomplete/details: 'auto' (the
// default and what every install had before Amap existed), 'google', 'amap' or
// 'openstreetmap'. Validated in updateAppSettings — an unknown value is
// refused rather than stored, because the reader degrades a bad row to 'auto'
// and the admin would be left looking at a setting that does nothing.
'places_provider',
];
// ---------------------------------------------------------------------------
@@ -53,6 +59,7 @@ export function stripUserForClient(user: User): Record<string, unknown> {
maps_api_key: _m,
openweather_api_key: _o,
unsplash_api_key: _u,
amap_api_key: _a,
mfa_secret: _mf,
mfa_backup_codes: _mbc,
...rest
+15
View File
@@ -15,6 +15,9 @@ import { validatePassword } from '../common/passwordPolicy';
import { encryptMfaSecret, decryptMfaSecret } from '../common/crypto/mfaCrypto';
import { decrypt_api_key, maybe_encrypt_api_key, encrypt_api_key } from '../common/crypto/apiKeyCrypto';
import { resolveApiKey } from '../settings/instance-api-keys';
// Type-and-guard only: the app-config read reports the provider choice, it does
// not construct one, so this does not pull the maps domain into auth.
import { isPlacesProviderChoice } from '../maps/providers/places-provider';
import { EphemeralTokenService } from './ephemeral-token.service';
// Import from sessionManager directly, NOT the ../../mcp barrel: the barrel pulls
// the whole tools fan-out (and via the domain bridges, the Nest services) into
@@ -263,6 +266,12 @@ export class AuthService {
// question is only about the instance, which is the first two steps of the
// chain; id 0 matches no row.
const hasGoogleKey = !!resolveApiKey(this.db, 'maps_api_key', authenticatedUser?.id ?? 0, readEnv().maps.placesApiKey).key;
// The same question for Amap, asked the same way. The client needs both to
// tell "search is unavailable" from "search runs on OpenStreetMap", and to
// know whether the provider the admin selected actually has a credential.
const hasAmapKey = !!resolveApiKey(this.db, 'amap_api_key', authenticatedUser?.id ?? 0, readEnv().maps.amapApiKey).key;
const placesProviderRow = this.db.get<{ value: string }>("SELECT value FROM app_settings WHERE key = 'places_provider'")?.value;
const placesProvider = isPlacesProviderChoice(placesProviderRow) ? placesProviderRow : 'auto';
const oidcDisplayName = readEnv().oidc.displayName ||
this.db.get<{ value: string }>("SELECT value FROM app_settings WHERE key = 'oidc_display_name'")?.value || null;
const oidcConfigured = !!(
@@ -313,6 +322,8 @@ export class AuthService {
version,
is_prerelease: version.includes('-pre.'),
has_maps_key: hasGoogleKey,
has_amap_key: hasAmapKey,
places_provider: placesProvider,
oidc_configured: oidcConfigured,
oidc_display_name: oidcConfigured ? (oidcDisplayName || 'SSO') : undefined,
require_mfa: requireMfaRow?.value === 'true',
@@ -695,6 +706,10 @@ export class AuthService {
if (key === 'require_mfa') {
val = body[key] === true || val === 'true' ? 'true' : 'false';
}
// An unknown provider name is dropped, not stored: the maps service
// degrades an unrecognised row to 'auto', so writing one would show the
// admin a saved setting that quietly does nothing.
if (key === 'places_provider' && !isPlacesProviderChoice(val)) continue;
if (key === 'smtp_pass' && val === '••••••••') continue;
if (key === 'smtp_pass') val = encrypt_api_key(val);
if (key === 'admin_webhook_url' && val === '••••••••') continue;
+22 -18
View File
@@ -53,8 +53,8 @@ export class UserProfileService {
/** The three key columns plus the role that decides where a save lands. */
private currentKeys(userId: number) {
return this.db.get<Pick<User, 'role' | 'maps_api_key' | 'openweather_api_key' | 'unsplash_api_key'>>(
'SELECT role, maps_api_key, openweather_api_key, unsplash_api_key FROM users WHERE id = ?',
return this.db.get<Pick<User, 'role' | 'maps_api_key' | 'openweather_api_key' | 'unsplash_api_key' | 'amap_api_key'>>(
'SELECT role, maps_api_key, openweather_api_key, unsplash_api_key, amap_api_key FROM users WHERE id = ?',
userId
);
}
@@ -67,8 +67,8 @@ export class UserProfileService {
* their own column only speaks when no instance value has been set yet.
*/
private storedKeyPlaintext(
name: 'maps_api_key' | 'openweather_api_key' | 'unsplash_api_key',
current: Pick<User, 'maps_api_key' | 'openweather_api_key' | 'unsplash_api_key'> | undefined,
name: 'maps_api_key' | 'openweather_api_key' | 'unsplash_api_key' | 'amap_api_key',
current: Pick<User, 'maps_api_key' | 'openweather_api_key' | 'unsplash_api_key' | 'amap_api_key'> | undefined,
isAdmin: boolean,
): string {
if (isAdmin && (INSTANCE_API_KEY_NAMES as readonly string[]).includes(name)) {
@@ -91,12 +91,12 @@ export class UserProfileService {
*/
private changedKeyNames(
body: Record<string, unknown>,
current: Pick<User, 'maps_api_key' | 'openweather_api_key' | 'unsplash_api_key'> | undefined,
current: Pick<User, 'maps_api_key' | 'openweather_api_key' | 'unsplash_api_key' | 'amap_api_key'> | undefined,
isAdmin: boolean,
skipped: string[] = [],
): string[] {
const norm = (v: unknown) => String(v ?? '').trim();
return (['maps_api_key', 'openweather_api_key', 'unsplash_api_key'] as const).filter(
return (['maps_api_key', 'openweather_api_key', 'unsplash_api_key', 'amap_api_key'] as const).filter(
(name) => body[name] !== undefined && !skipped.includes(name) && norm(body[name]) !== norm(this.storedKeyPlaintext(name, current, isAdmin))
);
}
@@ -135,7 +135,7 @@ export class UserProfileService {
}
updateApiKeys(userId: number, rawBody: unknown) {
const body = rawBody as { maps_api_key?: string; openweather_api_key?: string; unsplash_api_key?: string };
const body = rawBody as { maps_api_key?: string; openweather_api_key?: string; unsplash_api_key?: string; amap_api_key?: string };
const { blocked } = splitManagedKeys(body, this.managed);
for (const key of blocked) delete body[key as keyof typeof body];
const current = this.currentKeys(userId);
@@ -146,17 +146,18 @@ export class UserProfileService {
// `?? null` instead of the former non-null assertions: a user row deleted
// mid-request must degrade to a 0-row UPDATE, not a TypeError/500.
this.db.run(
'UPDATE users SET maps_api_key = ?, openweather_api_key = ?, unsplash_api_key = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?',
'UPDATE users SET maps_api_key = ?, openweather_api_key = ?, unsplash_api_key = ?, amap_api_key = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?',
body.maps_api_key !== undefined ? maybe_encrypt_api_key(body.maps_api_key) : current?.maps_api_key ?? null,
body.openweather_api_key !== undefined ? maybe_encrypt_api_key(body.openweather_api_key) : current?.openweather_api_key ?? null,
body.unsplash_api_key !== undefined ? maybe_encrypt_api_key(body.unsplash_api_key) : current?.unsplash_api_key ?? null,
body.amap_api_key !== undefined ? maybe_encrypt_api_key(body.amap_api_key) : current?.amap_api_key ?? null,
userId
);
this.mirrorInstanceKeys(body, isAdmin);
});
const updated = this.db.get<Pick<User, 'id' | 'username' | 'email' | 'role' | 'maps_api_key' | 'openweather_api_key' | 'unsplash_api_key' | 'avatar' | 'mfa_enabled'>>(
'SELECT id, username, email, role, maps_api_key, openweather_api_key, unsplash_api_key, avatar, mfa_enabled FROM users WHERE id = ?',
const updated = this.db.get<Pick<User, 'id' | 'username' | 'email' | 'role' | 'maps_api_key' | 'openweather_api_key' | 'unsplash_api_key' | 'amap_api_key' | 'avatar' | 'mfa_enabled'>>(
'SELECT id, username, email, role, maps_api_key, openweather_api_key, unsplash_api_key, amap_api_key, avatar, mfa_enabled FROM users WHERE id = ?',
userId
);
@@ -164,7 +165,7 @@ export class UserProfileService {
return {
success: true,
...(blocked.length ? { managed_keys: blocked } : {}),
user: { ...u, maps_api_key: mask_stored_api_key(u?.maps_api_key), openweather_api_key: mask_stored_api_key(u?.openweather_api_key), unsplash_api_key: mask_stored_api_key(u?.unsplash_api_key), avatar_url: avatarUrl(updated || {}) },
user: { ...u, maps_api_key: mask_stored_api_key(u?.maps_api_key), openweather_api_key: mask_stored_api_key(u?.openweather_api_key), unsplash_api_key: mask_stored_api_key(u?.unsplash_api_key), amap_api_key: mask_stored_api_key(u?.amap_api_key), avatar_url: avatarUrl(updated || {}) },
changedKeys,
};
}
@@ -173,8 +174,8 @@ export class UserProfileService {
userId: number,
rawBody: unknown
): { error?: string; status?: number; success?: boolean; user?: Record<string, unknown>; changedKeys?: string[] } {
const body = rawBody as { maps_api_key?: string; openweather_api_key?: string; unsplash_api_key?: string; username?: string; email?: string };
const { maps_api_key, openweather_api_key, unsplash_api_key, username, email } = body;
const body = rawBody as { maps_api_key?: string; openweather_api_key?: string; unsplash_api_key?: string; amap_api_key?: string; username?: string; email?: string };
const { maps_api_key, openweather_api_key, unsplash_api_key, amap_api_key, username, email } = body;
if (username !== undefined) {
const trimmed = username.trim();
@@ -208,6 +209,7 @@ export class UserProfileService {
if (maps_api_key !== undefined && !keyLocked) { updates.push('maps_api_key = ?'); params.push(maybe_encrypt_api_key(maps_api_key)); }
if (openweather_api_key !== undefined && !keyLocked) { updates.push('openweather_api_key = ?'); params.push(maybe_encrypt_api_key(openweather_api_key)); }
if (unsplash_api_key !== undefined && !keyLocked) { updates.push('unsplash_api_key = ?'); params.push(maybe_encrypt_api_key(unsplash_api_key)); }
if (amap_api_key !== undefined && !keyLocked) { updates.push('amap_api_key = ?'); params.push(maybe_encrypt_api_key(amap_api_key)); }
if (username !== undefined) { updates.push('username = ?'); params.push(username.trim()); }
if (email !== undefined) { updates.push('email = ?'); params.push(email.trim()); }
@@ -226,8 +228,8 @@ export class UserProfileService {
});
}
const updated = this.db.get<Pick<User, 'id' | 'username' | 'email' | 'role' | 'maps_api_key' | 'openweather_api_key' | 'unsplash_api_key' | 'avatar' | 'mfa_enabled'>>(
'SELECT id, username, email, role, maps_api_key, openweather_api_key, unsplash_api_key, avatar, mfa_enabled FROM users WHERE id = ?',
const updated = this.db.get<Pick<User, 'id' | 'username' | 'email' | 'role' | 'maps_api_key' | 'openweather_api_key' | 'unsplash_api_key' | 'amap_api_key' | 'avatar' | 'mfa_enabled'>>(
'SELECT id, username, email, role, maps_api_key, openweather_api_key, unsplash_api_key, amap_api_key, avatar, mfa_enabled FROM users WHERE id = ?',
userId
);
@@ -235,14 +237,14 @@ export class UserProfileService {
return {
success: true,
...(blocked.length ? { managed_keys: blocked } : {}),
user: { ...u, maps_api_key: mask_stored_api_key(u?.maps_api_key), openweather_api_key: mask_stored_api_key(u?.openweather_api_key), unsplash_api_key: mask_stored_api_key(u?.unsplash_api_key), avatar_url: avatarUrl(updated || {}) },
user: { ...u, maps_api_key: mask_stored_api_key(u?.maps_api_key), openweather_api_key: mask_stored_api_key(u?.openweather_api_key), unsplash_api_key: mask_stored_api_key(u?.unsplash_api_key), amap_api_key: mask_stored_api_key(u?.amap_api_key), avatar_url: avatarUrl(updated || {}) },
changedKeys,
};
}
getSettings(userId: number): { error?: string; status?: number; settings?: Record<string, unknown> } {
const user = this.db.get<Pick<User, 'role' | 'maps_api_key' | 'openweather_api_key' | 'unsplash_api_key'>>(
'SELECT role, maps_api_key, openweather_api_key, unsplash_api_key FROM users WHERE id = ?',
const user = this.db.get<Pick<User, 'role' | 'maps_api_key' | 'openweather_api_key' | 'unsplash_api_key' | 'amap_api_key'>>(
'SELECT role, maps_api_key, openweather_api_key, unsplash_api_key, amap_api_key FROM users WHERE id = ?',
userId
);
if (user?.role !== 'admin') return { error: 'Admin access required', status: 403 };
@@ -258,6 +260,7 @@ export class UserProfileService {
maps_api_key: null,
openweather_api_key: null,
unsplash_api_key: null,
amap_api_key: null,
managed_keys: [...MANAGED_LOCKED_PROFILE_KEYS],
},
};
@@ -272,6 +275,7 @@ export class UserProfileService {
maps_api_key: readInstanceApiKey(this.db, 'maps_api_key') ?? decrypt_api_key(user.maps_api_key),
openweather_api_key: decrypt_api_key(user.openweather_api_key),
unsplash_api_key: readInstanceApiKey(this.db, 'unsplash_api_key') ?? decrypt_api_key(user.unsplash_api_key),
amap_api_key: readInstanceApiKey(this.db, 'amap_api_key') ?? decrypt_api_key(user.amap_api_key),
},
};
}
+8
View File
@@ -77,6 +77,9 @@ export function isManagedBlocked(env: RuntimeEnvService): boolean {
* the tiles it fetches.
*/
export const MANAGED_LOCKED_SETTING_KEYS = [
// The operator's for the same reason maps_api_key is: Amap bills the account
// the key belongs to, and on a managed install that account is theirs.
'amap_api_key',
'carto_api_key',
'llm_api_key',
'llm_base_url',
@@ -110,6 +113,7 @@ export const MANAGED_LOCKED_PROFILE_KEYS = [
'maps_api_key',
'openweather_api_key',
'unsplash_api_key',
'amap_api_key',
] as const;
/**
@@ -142,6 +146,10 @@ export const MANAGED_CUSTOMER_KEYS = [
'passkey_login',
'password_login',
'password_registration',
// WHICH provider answers place search stays the admin's call even when the
// operator owns both credentials: it decides what their users see, not what
// the operator is billed for — the keys above cover that.
'places_provider',
'require_mfa',
'temperature_unit',
'time_format',
+6 -5
View File
@@ -726,18 +726,19 @@ export function parseWikipediaTag(tag: string | undefined | null): { lang: strin
// Ids that can never resolve against the Google Places API: coordinate pseudo-ids
// (right-click places, in both the coords: and the bare "lat,lng" spelling the
// collection views send), OSM ids the client sends when a place has no
// google_place_id, GERS ids from the TREK index, the raw photo URLs legacy rows
// keep in image_url, and photo cache keys of the form "<placeId>~p3" that
// enrichment mints for the picker.
// google_place_id, GERS ids from the TREK index, Amap POI ids (`amap:<poiid>`,
// which are otherwise shaped exactly like a Google id), the raw photo URLs
// legacy rows keep in image_url, and photo cache keys of the form "<placeId>~p3"
// that enrichment mints for the picker.
// Google answers those with a billable 400 INVALID_ARGUMENT, so every lookup
// sorts them out before the call and uses the OSM/Wikimedia path instead.
// sorts them out before the call and uses the right provider instead.
//
// `gers:` belongs here for the same reason as `node:`, and it matters more: the
// index answers first for search and autocomplete, so a GERS id is what an
// ordinary new place now carries. Leaving it out billed three invalid lookups
// per place — photo refs, editorial summary, and the photo route.
const NON_GOOGLE_PLACE_ID =
/^(?:coords|gers|node|way|relation):|^https?:\/\/|^-?\d+(?:\.\d+)?,\s*-?\d+(?:\.\d+)?$|~p\d+$/i;
/^(?:coords|gers|node|way|relation|amap):|^https?:\/\/|^-?\d+(?:\.\d+)?,\s*-?\d+(?:\.\d+)?$|~p\d+$/i;
// The subset that still has a provider behind it — Overpass for details,
// Wikimedia for photos.
export const OSM_PLACE_ID = /^(?:node|way|relation):/i;
@@ -0,0 +1,506 @@
/**
* Amap (高德地图) Web Service API as a PlacesProvider.
*
* Why this exists: Google Places and Nominatim are both poor inside mainland
* China. Google is unreachable from most Chinese networks, and OpenStreetMap's
* coverage of Chinese POIs — restaurants, shops, the things a trip is actually
* made of — is thin and rarely in Chinese. Amap is what a Chinese user expects
* their map search to behave like.
*
* Two things make this provider different from the Google one:
*
* 1. **Datum.** Amap speaks GCJ-02, TREK speaks WGS-84 everywhere else. This
* file is one of the two places in the codebase allowed to hold a GCJ-02
* coordinate, and it never lets one escape: every outbound coordinate is
* converted on the way out, every inbound one on the way back. Nothing
* downstream — the database, the map, a GPX export — ever sees GCJ-02.
*
* 2. **Errors.** Amap answers HTTP 200 for everything and puts the verdict in
* the body (`status: "0"` plus an `infocode`). A quota-exceeded response and
* a successful one are the same status line, so the body is checked and
* translated into the same `Error & { status }` the Google path throws —
* otherwise the controller's error mapping would report every failure as an
* empty result.
*
* Deliberately not implemented: `photoRefs`/`photoBytes`. Amap's POI photos come
* with no licence statement we could show a user, and the existing Wikimedia
* fallback already produces a picture *with* its attribution. An unattributable
* image is worse than no image, so the photo path falls through to Wikimedia for
* Amap places.
*/
import { createHash } from 'node:crypto';
import { fromAmapLocation, gcj02ToWgs84, toAmapLocation } from '@trek/shared';
import { readEnv } from '../../../app-config';
import { safeFetchFollow } from '../../../utils/ssrfGuard';
import { UA, parseOpeningHours } from '../maps.helpers';
import type {
DetailsOptions,
PlacesProvider,
ProviderCredential,
ProviderPlace,
ProviderSuggestion,
SearchBias,
ViewportBias,
} from './places-provider';
/** The upstream every Web Service call is written against. */
const AMAP_UPSTREAM = 'https://restapi.amap.com';
/**
* The prefix that makes an Amap POI id recognisable anywhere in TREK.
*
* Amap ids are bare hex-ish strings (`B0FFFAB6J2`), indistinguishable from a
* Google place id by shape. Without a namespace, an Amap place opened later
* would be sent to Google, which answers a foreign id with a *billable* 400.
* The separator is a colon to match the existing `node:`/`way:`/`coords:`
* convention — see maps.helpers.ts, where this prefix is part of
* NON_GOOGLE_PLACE_ID for exactly that reason.
*/
export const AMAP_PLACE_ID_PREFIX = 'amap:';
export const AMAP_PLACE_ID = /^amap:(.+)$/i;
export function isAmapPlaceId(placeId: string): boolean {
return AMAP_PLACE_ID.test(placeId);
}
/** The bare provider id, or null when this is not an Amap place. */
export function amapPoiId(placeId: string): string | null {
const m = AMAP_PLACE_ID.exec(placeId);
return m ? m[1] : null;
}
let amapApiCallCount = 0;
/**
* Amap accepts either `lang=zh_cn` or `lang=en`, and nothing else. Anything we
* cannot serve is answered in Chinese rather than rejected, which is the right
* default for a China-only provider.
*/
function toAmapLang(lang?: string): string {
return lang && /^en/i.test(lang) ? 'en' : 'zh_cn';
}
/**
* Amap's own error text for the codes an operator can actually act on.
*
* The raw `info` string is returned for everything else; these three are
* singled out because they are the ones a misconfigured install hits first, and
* because Amap's own wording for them ("INVALID_USER_SCODE") does not tell an
* admin what to change.
*/
const AMAP_INFOCODE_HINTS: Record<string, string> = {
'10001': 'Amap API key is invalid — check that it is a "Web 服务" (web service) key, not a JS API key',
'10003': 'Amap daily request quota exhausted',
'10009': 'Amap key rejected the request: the key is restricted to a different domain or IP',
};
/**
* The HTTP status TREK should answer with for an Amap failure.
*
* Amap's own status line is always 200, and the controller maps a thrown
* `.status` straight through to the client, so a credential problem has to
* arrive as 403 and a quota problem as 429 for the client to say anything
* useful about it.
*/
function statusForInfocode(infocode: string): number {
if (infocode === '10001' || infocode === '10009') return 403;
if (infocode === '10003' || infocode === '10004' || infocode === '10019' || infocode === '10020') return 429;
return 502;
}
interface AmapEnvelope {
status?: string;
info?: string;
infocode?: string;
}
interface AmapTip {
id?: string;
name?: string;
district?: string;
address?: unknown;
location?: unknown;
}
interface AmapPoi {
id?: string;
name?: string;
address?: unknown;
location?: unknown;
pname?: string;
cityname?: string;
adname?: string;
type?: string;
tel?: unknown;
website?: unknown;
biz_ext?: { rating?: unknown; open_time?: unknown };
business?: { rating?: unknown; tel?: unknown; opentime_week?: unknown; opentime_today?: unknown };
}
/**
* Amap returns an empty *array* where a string field has no value — `address:
* []`, `tel: []` — so every optional text field has to be coerced rather than
* read. A bare `poi.address || ''` yields `[]` in a template and `"[]"` in the
* database.
*/
function amapText(value: unknown): string {
if (typeof value === 'string') return value.trim();
if (typeof value === 'number') return String(value);
// Some fields arrive as a one-element array of the value.
if (Array.isArray(value) && value.length === 1) return amapText(value[0]);
return '';
}
function amapNumber(value: unknown): number | null {
const text = amapText(value);
if (!text) return null;
const n = Number.parseFloat(text);
return Number.isFinite(n) ? n : null;
}
export class AmapPlacesProvider implements PlacesProvider {
readonly id = 'amap' as const;
constructor(private readonly credential: ProviderCredential) {}
ownsPlaceId(placeId: string): boolean {
return isAmapPlaceId(placeId);
}
// ── Outbound plumbing ──────────────────────────────────────────────────────
/**
* Build the full URL for one call, key and optional signature attached.
*
* `AMAP_API_BASE` mirrors `PLACES_API_BASE`: an install that routes its
* outbound calls through a proxy or a gateway holding the credential says so
* once, here.
*/
private url(path: string, params: Record<string, string>): string {
const query = new URLSearchParams({ ...params, key: this.credential.key, output: 'JSON' });
const sig = this.signature(params);
if (sig) query.set('sig', sig);
const base = (readEnv().maps.amapApiBase || AMAP_UPSTREAM).replace(/([^/]|^)\/+$/, '$1');
return `${base}${path}?${query.toString()}`;
}
/**
* Amap's optional 数字签名 (digital signature).
*
* A key can be created with a private secret, and such a key rejects every
* unsigned request. The scheme is an MD5 of the request parameters sorted by
* name, with the secret appended — MD5 because Amap specifies MD5, not because
* anything here is choosing a hash. The key itself is deliberately NOT part of
* the signed set on the query-string form of the API.
*
* Unset, which is the common case, nothing is added.
*/
private signature(params: Record<string, string>): string | null {
const secret = readEnv().maps.amapApiSecret;
if (!secret) return null;
const signed = { ...params, key: this.credential.key, output: 'JSON' };
const canonical = Object.keys(signed)
.sort()
.map((k) => `${k}=${signed[k]}`)
.join('&');
return createHash('md5').update(`${canonical}${secret}`).digest('hex');
}
/**
* One call, with the body-carried verdict turned into a real error.
*
* Through safeFetchFollow like every other outbound URL in the maps domain, so
* a proxied base cannot be pointed at something internal.
*/
private async call<T extends AmapEnvelope>(path: string, params: Record<string, string>, label: string): Promise<T> {
const url = this.url(path, params);
amapApiCallCount++;
// The key rides in the query string, so the label is logged without the URL —
// unlike the Google path, where the credential sits in a header.
console.debug(`[Amap API] #${amapApiCallCount} ${label} → ${path}`);
const response = await safeFetchFollow(
url,
{ headers: { 'User-Agent': UA }, signal: AbortSignal.timeout(8000) },
{ bypassInternalIpAllowed: true },
);
if (!response.ok) {
// A transport-level failure, i.e. the proxy in front of Amap rather than
// Amap itself: Amap's own answers are always 200.
this.fail(label, response.status, `Amap ${label} failed with HTTP ${response.status}`);
}
const data = (await response.json()) as T;
if (data.status !== '1') {
const infocode = data.infocode ?? '';
const hint = AMAP_INFOCODE_HINTS[infocode] || data.info || 'Amap API error';
this.fail(label, statusForInfocode(infocode), `${hint} (infocode ${infocode || 'none'})`);
}
return data;
}
private fail(label: string, status: number, message: string): never {
console.error(`[Maps] amap/${label} failed with ${status} userId=${this.credential.userId} keySource=${this.credential.source}`);
const err = new Error(message) as Error & { status: number };
err.status = status;
throw err;
}
// ── Normalisation ──────────────────────────────────────────────────────────
/**
* One POI in the shape the rest of TREK consumes, coordinates back in WGS-84.
*
* `address` is assembled rather than taken as-is: Amap's `address` field is
* the street line alone ("三里屯路19号"), which on its own is not enough to
* tell two identically named branches apart in the place picker. Province,
* city and district come as separate fields for exactly this purpose.
*/
private toPlace(poi: AmapPoi): ProviderPlace | null {
const coords = fromAmapLocation(poi.location);
const business = poi.business ?? {};
const bizExt = poi.biz_ext ?? {};
const street = amapText(poi.address);
const region = [amapText(poi.pname), amapText(poi.cityname), amapText(poi.adname)]
// A municipality repeats itself — pname and cityname are both "北京市".
.filter((part, i, all) => part && all.indexOf(part) === i);
const address = [...region, street].filter(Boolean).join('');
const openText = amapText(business.opentime_week) || amapText(bizExt.open_time);
return {
amap_poi_id: poi.id ? `${AMAP_PLACE_ID_PREFIX}${poi.id}` : null,
name: amapText(poi.name),
address,
// Null rather than dropped: a POI with no geometry still answers "which
// one did I mean" in the picker, and the caller decides whether it is
// usable. `?? null` because 0 is a real coordinate.
lat: coords?.lat ?? null,
lng: coords?.lng ?? null,
rating: amapNumber(business.rating ?? bizExt.rating),
rating_count: null,
website: amapText(poi.website) || null,
phone: amapText(business.tel ?? poi.tel) || null,
// Amap's type is a slash-separated taxonomy ("餐饮服务;中餐厅;川菜"); split so
// it reads like the string array every other provider returns.
types: amapText(poi.type).split(/[;|]/).map((t) => t.trim()).filter(Boolean),
// Amap gives opening hours as free text in the same dialect OSM uses for
// simple cases, so the existing parser gets a chance at it; unparseable
// text is still worth showing verbatim.
opening_hours: openText ? parseOpeningHours(openText).weekdayDescriptions : null,
open_now: openText ? parseOpeningHours(openText).openNow : null,
opening_periods: null,
opening_special_days: null,
summary: null,
reviews: [],
source: 'amap' as const,
};
}
// ── PlacesProvider ─────────────────────────────────────────────────────────
async searchText(query: string, lang?: string, bias?: SearchBias): Promise<ProviderPlace[]> {
const common = {
keywords: query,
offset: '10',
page: '1',
extensions: 'all',
language: toAmapLang(lang),
};
// `place/around` rather than `place/text` when we know where the user is
// looking: Amap's text search has no bias parameter at all, so without this
// a search for "咖啡" from a Shanghai viewport returns Beijing.
const data = bias
? await this.call<AmapEnvelope & { pois?: AmapPoi[] }>(
'/v3/place/around',
{
...common,
location: toAmapLocation(bias.lat, bias.lng),
// Amap caps the radius at 50 km, which is also the default the
// Google path uses for an unspecified bias.
radius: String(Math.min(Math.round(bias.radius ?? 50000), 50000)),
},
'place/around',
)
: await this.call<AmapEnvelope & { pois?: AmapPoi[] }>('/v3/place/text', common, 'place/text');
return (data.pois ?? [])
.map((poi) => this.toPlace(poi))
.filter((p): p is ProviderPlace => p !== null);
}
async autocomplete(
input: string,
lang?: string,
bias?: ViewportBias,
// Amap does not bill per keystroke and has no session concept, so the token
// the Google path uses to group a search has nothing to attach to here.
_sessionToken?: string,
): Promise<ProviderSuggestion[]> {
const params: Record<string, string> = {
keywords: input,
// Without this, inputtips also returns bus stops and road names, which
// cannot be turned into a place with coordinates.
datatype: 'poi',
language: toAmapLang(lang),
};
// inputtips biases around a point, not a rectangle: the viewport centre is
// the closest thing we can give it.
if (bias) {
const lat = (bias.low.lat + bias.high.lat) / 2;
const lng = (bias.low.lng + bias.high.lng) / 2;
params.location = toAmapLocation(lat, lng);
}
const data = await this.call<AmapEnvelope & { tips?: AmapTip[] }>(
'/v3/assistant/inputtips',
params,
'inputtips',
);
return (data.tips ?? [])
// A tip without an id cannot be looked up afterwards, and inputtips does
// return those (a district name, a road).
.filter((tip) => amapText(tip.id))
.slice(0, 5)
.map((tip) => ({
placeId: `${AMAP_PLACE_ID_PREFIX}${amapText(tip.id)}`,
mainText: amapText(tip.name),
secondaryText: [amapText(tip.district), amapText(tip.address)].filter(Boolean).join(' '),
}));
}
async placeDetails(placeId: string, opts: DetailsOptions = {}): Promise<ProviderPlace | null> {
const poiId = amapPoiId(placeId);
if (!poiId) return null;
// v5 rather than v3: v3's place/detail is deprecated and does not return the
// business block (rating, phone, opening hours) that makes a details lookup
// worth making. `show_fields` is v5's field mask — asking for everything
// costs no more, but photos are left out on purpose (see the file header).
const data = await this.call<AmapEnvelope & { pois?: AmapPoi[] }>(
'/v5/place/detail',
{
id: poiId,
show_fields: 'business,indoor',
language: toAmapLang(opts.lang),
},
`place/detail(${poiId})`,
);
const poi = data.pois?.[0];
if (!poi) return null;
const place = this.toPlace(poi);
return place ? { ...place, cached_at: Date.now() } : null;
}
/**
* Reverse geocoding via `geocode/regeo`.
*
* Worth having beyond the datum: the OSM path funnels through a single
* process-wide Nominatim throttle of one request every 1.1 s (Nominatim's
* usage policy), so a booking import that geocodes thirty stops takes half a
* minute of wall clock. Amap has no such rule, so an install on Amap gets its
* imports back at network speed.
*/
async reverse(lat: number, lng: number, lang?: string): Promise<{ name: string | null; address: string | null } | null> {
const data = await this.call<
AmapEnvelope & {
regeocode?: {
formatted_address?: unknown;
addressComponent?: { building?: { name?: unknown }; neighborhood?: { name?: unknown } };
pois?: { name?: unknown }[];
aois?: { name?: unknown }[];
};
}
>(
'/v3/geocode/regeo',
{
location: toAmapLocation(lat, lng),
extensions: 'all',
// 18 is street-address detail, matching the zoom the Nominatim path asks for.
radius: '200',
language: toAmapLang(lang),
},
'geocode/regeo',
);
const regeocode = data.regeocode;
if (!regeocode) return null;
// Same precedence as the Nominatim path: the most specific name Amap knows,
// falling back through the containing area. An AOI ("故宫博物院") beats a POI
// here because the click landed inside it rather than on it.
const name =
amapText(regeocode.aois?.[0]?.name) ||
amapText(regeocode.pois?.[0]?.name) ||
amapText(regeocode.addressComponent?.building?.name) ||
amapText(regeocode.addressComponent?.neighborhood?.name) ||
null;
return { name, address: amapText(regeocode.formatted_address) || null };
}
}
/**
* Coordinates and a name out of a shared Amap link.
*
* The formats people paste, in the order they are tried:
* - `uri.amap.com/marker?position=<lng>,<lat>&name=…` — what our own "open in
* Amap" links look like, and what the mobile app's share sheet produces
* - `amap.com/place/<id>` or `…/detail?id=<id>` — a POI page, whose id we can
* look up when a key is configured
* - `?p=…` / `#…` viewport fragments carrying a bare `lng,lat`
*
* Returned coordinates are WGS-84; the link carries GCJ-02.
*/
export function parseAmapUrl(rawUrl: string): { lat: number; lng: number; name: string | null; poiId: string | null } | null {
let parsed: URL;
try {
parsed = new URL(rawUrl);
} catch {
return null;
}
if (!isAmapHost(parsed.hostname)) return null;
const name = parsed.searchParams.get('name')?.trim() || null;
const poiId = parsed.searchParams.get('id')?.trim() || parsed.pathname.match(/\/place\/([A-Za-z0-9]+)/)?.[1] || null;
// `position=lng,lat` (uri.amap.com) and `p=…,lng,lat,…` (the web viewport) both
// spell longitude first, like every other Amap coordinate.
const position = parsed.searchParams.get('position') || parsed.searchParams.get('location');
const fromPosition = position ? fromAmapLocation(position) : null;
if (fromPosition) return { ...fromPosition, name, poiId };
const anywhere = /(\d{2,3}\.\d+),(\d{1,2}\.\d+)/.exec(`${parsed.search}${parsed.hash}`);
if (anywhere) {
const converted = gcj02ToWgs84(Number.parseFloat(anywhere[2]), Number.parseFloat(anywhere[1]));
return { ...converted, name, poiId };
}
return poiId ? { lat: Number.NaN, lng: Number.NaN, name, poiId } : null;
}
/**
* Amap's own hosts, including the short-link one.
*
* Matched by exact suffix rather than by shape: unlike Google, Amap has no
* ccTLD family, so a pattern would only widen what counts as trusted.
*/
const AMAP_HOSTS = new Set([
'amap.com',
'www.amap.com',
'uri.amap.com',
'wb.amap.com',
'surl.amap.com',
'gaode.com',
'www.gaode.com',
]);
export function isAmapHost(hostname: string): boolean {
return AMAP_HOSTS.has(hostname.toLowerCase());
}
/** The short links that have to be followed before anything can be parsed out. */
export const AMAP_SHORT_HOSTS = ['surl.amap.com'];
@@ -0,0 +1,148 @@
/**
* The places-provider seam.
*
* Before this file, MapsService reached Google inline and every method carried
* its own `if (!apiKey) → OpenStreetMap` branch. That worked for two sources and
* stops working at three: adding Amap that way means a three-way conditional in
* `searchPlaces`, another in `autocompletePlaces`, two more in the details pair,
* and a fourth spelling of "which credential did we end up using" in each.
*
* So the *keyed* providers — the ones an admin configures and that bill somebody
* — are implemented behind this interface, and MapsService keeps what is
* genuinely its own: the caches, the kill switches, the photo orchestration and
* the Wikimedia enrichment.
*
* OpenStreetMap deliberately does NOT implement this interface. It is the
* keyless floor every install falls back to, it has no credential to resolve,
* and its details path is a Nominatim/Overpass merge that answers a different
* question from a single provider lookup. Dressing it up as a provider would
* mean either a fake key or an interface full of optionals. `null` from
* `resolvePlacesProvider` means "use the OSM stack", which is also exactly what
* the code did before.
*
* Every method returns the same normalised place shape regardless of provider —
* `name/address/lat/lng` in WGS-84, plus whatever provider-specific ids the
* client already persists (`google_place_id`, `amap_poi_id`, …). Coordinates are
* WGS-84 by contract: a provider that speaks another datum converts at its own
* boundary (see amap.provider.ts) so nothing downstream has to care.
*/
import type { ApiKeySource } from '../../settings/instance-api-keys';
/** The keyed providers. OpenStreetMap is absent on purpose — see the file header. */
export type PlacesProviderId = 'google' | 'amap';
/**
* What an admin can choose. `auto` keeps whatever the install already used:
* Google when a Google key is configured, Amap when only an Amap key is, and
* the OSM stack when neither is. Existing installs must not change provider
* because a new one became available, so `auto` prefers the incumbent.
*/
export type PlacesProviderChoice = 'auto' | PlacesProviderId | 'openstreetmap';
export const PLACES_PROVIDER_CHOICES: readonly PlacesProviderChoice[] = [
'auto',
'google',
'amap',
'openstreetmap',
];
export function isPlacesProviderChoice(value: unknown): value is PlacesProviderChoice {
return typeof value === 'string' && (PLACES_PROVIDER_CHOICES as readonly string[]).includes(value);
}
/** A place as the rest of TREK consumes it. Open by design — see maps.schema.ts. */
export type ProviderPlace = Record<string, unknown>;
export interface ProviderSuggestion {
placeId: string;
mainText: string;
secondaryText: string;
}
/** Bias a text search toward a point. Radius in metres; WGS-84. */
export interface SearchBias {
lat: number;
lng: number;
radius?: number;
}
/** Bias autocomplete toward a viewport rectangle. WGS-84. */
export interface ViewportBias {
low: { lat: number; lng: number };
high: { lat: number; lng: number };
}
export interface DetailsOptions {
lang?: string;
/**
* Ask for the fields only the richer (and pricier) lookup returns — reviews
* and the editorial summary at Google. A provider without a two-tier lookup
* ignores it and answers the same either way.
*/
expanded?: boolean;
/** Closes a billed autocomplete session, where the provider has the concept. */
sessionToken?: string;
}
/** One photo the provider holds, as a reference to fetch bytes for later. */
export interface ProviderPhotoRef {
/** Provider-opaque handle passed straight back to `photoBytes`. */
name: string;
/** The author as the provider names them — not the provider itself. */
attribution: string | null;
}
/**
* The credential and its provenance, carried so an error can say WHICH of the
* three configured places a key can come from was used without ever logging the
* key. This is the #1939 breadcrumb, kept when the code moved out of the service.
*/
export interface ProviderCredential {
key: string;
source: ApiKeySource | null;
/** Whose request this is; 0 for an unauthenticated read. */
userId: number;
}
export interface PlacesProvider {
readonly id: PlacesProviderId;
/**
* True when this provider is the one that can resolve `placeId`.
*
* Sending an id to the wrong provider is not a harmless miss: Google answers
* a foreign id with a *billable* 400 INVALID_ARGUMENT, which is the reason
* `isGooglePlaceId` exists at all. Every details/photo path checks this first.
*/
ownsPlaceId(placeId: string): boolean;
searchText(query: string, lang?: string, bias?: SearchBias): Promise<ProviderPlace[]>;
autocomplete(
input: string,
lang?: string,
bias?: ViewportBias,
sessionToken?: string,
): Promise<ProviderSuggestion[]>;
/** Null when the provider has no such place — a miss, not an error. */
placeDetails(placeId: string, opts?: DetailsOptions): Promise<ProviderPlace | null>;
/**
* Reverse geocoding, where the provider offers it. Absent means "use
* Nominatim", which is what every install did before and still does for the
* OSM stack. Null means the provider had nothing for this point.
*/
reverse?(lat: number, lng: number, lang?: string): Promise<{ name: string | null; address: string | null } | null>;
/**
* Photo candidates for the enrichment picker. Optional because a provider
* whose image licensing we cannot state is better off not offering any — the
* Wikimedia fallback then supplies the picture, with its licence attached.
*/
photoRefs?(placeId: string, cap: number): Promise<ProviderPhotoRef[]>;
photoBytes?(photoName: string, maxHeightPx?: number): Promise<Buffer | null>;
/** A one-paragraph description of the place, where the provider has one. */
editorialSummary?(placeId: string, lang?: string): Promise<string | null>;
}
@@ -18,10 +18,14 @@ import type { ApiKeySource } from '@trek/shared';
* apiKeyCrypto, so the format matches what the users columns already hold and
* a legacy plaintext value still reads back.
*/
export type InstanceApiKeyName = 'maps_api_key' | 'unsplash_api_key';
export type InstanceApiKeyName = 'maps_api_key' | 'unsplash_api_key' | 'amap_api_key';
/** Instance names whose per-user column is still honoured as a last resort. */
export const INSTANCE_API_KEY_NAMES: readonly InstanceApiKeyName[] = ['maps_api_key', 'unsplash_api_key'];
export const INSTANCE_API_KEY_NAMES: readonly InstanceApiKeyName[] = [
'maps_api_key',
'unsplash_api_key',
'amap_api_key',
];
/**
* Where a resolved key came from. Logged beside a provider error so "works for
@@ -39,6 +43,7 @@ export type { ApiKeySource };
const USER_ROW_SQL: Record<InstanceApiKeyName, string> = {
maps_api_key: 'SELECT maps_api_key FROM users WHERE id = ?',
unsplash_api_key: 'SELECT unsplash_api_key FROM users WHERE id = ?',
amap_api_key: 'SELECT amap_api_key FROM users WHERE id = ?',
};
/** The instance-wide value in cleartext, or null when unset/cleared. */
+2
View File
@@ -8,6 +8,8 @@ export interface User {
password_hash?: string;
maps_api_key?: string | null;
unsplash_api_key?: string | null;
/** Amap (高德) web-service key — the per-user fallback for the Amap provider. */
amap_api_key?: string | null;
openweather_api_key?: string | null;
avatar?: string | null;
oidc_sub?: string | null;
@@ -51,6 +51,7 @@ describe('managed key assignment', () => {
it('MANAGED-KEYS-005: the locked list is pinned verbatim', () => {
expect([...MANAGED_LOCKED_SETTING_KEYS]).toEqual([
'amap_api_key',
'carto_api_key',
'llm_api_key',
'llm_base_url',
+572
View File
@@ -0,0 +1,572 @@
/**
* Unit tests for the Amap (高德) places provider and the provider selection it
* plugs into — AMAP-001 onwards.
*
* What these are really about is the two things that make Amap different from
* Google and that no amount of care at the call site can fix: the datum (GCJ-02
* in, WGS-84 out, in BOTH directions) and the fact that Amap reports failure in
* a 200 response body. Everything else is field mapping, which is checked
* against the shapes Amap actually sends — including `address: []` for a missing
* string, which is the one that bites.
*
* fetch is stubbed; the SSRF guard and the database are mocked, the same way
* maps.service.test.ts does it.
*/
import { describe, it, expect, vi, afterEach } from 'vitest';
import { gcj02ToWgs84, wgs84ToGcj02 } from '@trek/shared';
const { mockDbGet, mockDbRun, mockInstanceGet, mockProviderGet } = vi.hoisted(() => ({
mockDbGet: vi.fn((..._args: unknown[]) => undefined as any),
mockDbRun: vi.fn(),
mockInstanceGet: vi.fn((..._args: unknown[]) => undefined as any),
mockProviderGet: vi.fn((..._args: unknown[]) => undefined as any),
}));
vi.mock('../../../src/db/database', () => ({
db: {
prepare: (sql: string) => ({
get: (...args: unknown[]) => {
if (!sql.includes('app_settings')) return mockDbGet(...args);
return args[0] === 'places_provider' ? mockProviderGet(...args) : mockInstanceGet(...args);
},
all: vi.fn(() => []),
run: mockDbRun,
}),
},
}));
vi.mock('../../../src/utils/ssrfGuard', () => {
class SsrfBlockedError extends Error {}
return {
SsrfBlockedError,
checkSsrf: vi.fn(async () => ({ allowed: true })),
safeFetchFollow: vi.fn(async (url: string, init?: any) => (globalThis.fetch as any)(url, init)),
};
});
vi.mock('../../../src/nest/common/crypto/apiKeyCrypto', () => ({
decrypt_api_key: (v: string | null) => v,
maybe_encrypt_api_key: (v: string | null) => v,
}));
vi.mock('../../../src/config', () => ({ JWT_SECRET: 'test-secret', ENCRYPTION_KEY: '0'.repeat(64) }));
import { db } from '../../../src/db/database';
import { DatabaseService } from '../../../src/nest/database/database.service';
import { MapsService } from '../../../src/nest/maps/maps.service';
import {
AmapPlacesProvider,
amapPoiId,
isAmapHost,
isAmapPlaceId,
parseAmapUrl,
} from '../../../src/nest/maps/providers/amap.provider';
import { GooglePlacesProvider } from '../../../src/nest/maps/providers/google.provider';
import { isGooglePlaceId } from '../../../src/nest/maps/maps.helpers';
import type { PlacePhotoCacheService } from '../../../src/nest/place-photos/place-photo-cache.service';
const photoCacheStub = {
get: vi.fn(() => null),
getErrored: vi.fn(() => false),
put: vi.fn(),
markError: vi.fn(),
getInFlight: vi.fn(() => undefined),
setInFlight: vi.fn(),
serveKey: vi.fn(() => null),
} as unknown as PlacePhotoCacheService;
const svc = new MapsService(new DatabaseService(db as never), photoCacheStub);
/** A provider over a fixed key, which is all these cases need. */
function provider(): AmapPlacesProvider {
return new AmapPlacesProvider({ key: 'amap-test-key', source: 'instance', userId: 3 });
}
/** Amap's success envelope. */
function ok(body: Record<string, unknown>) {
return { ok: true, status: 200, json: async () => ({ status: '1', info: 'OK', infocode: '10000', ...body }) };
}
/** Amap's failure envelope — note the HTTP 200. */
function amapError(infocode: string, info = 'SOMETHING_WRONG') {
return { ok: true, status: 200, json: async () => ({ status: '0', info, infocode }) };
}
/** The URL the single stubbed call was made with. */
function calledUrl(): string {
return String((globalThis.fetch as any).mock.calls[0][0]);
}
/** One POI in Beijing, at a GCJ-02 coordinate. */
const TIANANMEN_GCJ = wgs84ToGcj02(39.90869, 116.39124);
const TIANANMEN_LOCATION = `${TIANANMEN_GCJ.lng.toFixed(6)},${TIANANMEN_GCJ.lat.toFixed(6)}`;
afterEach(() => {
vi.unstubAllGlobals();
mockDbGet.mockReset();
mockDbGet.mockReturnValue(undefined);
mockDbRun.mockReset();
mockInstanceGet.mockReset();
mockInstanceGet.mockReturnValue(undefined);
mockProviderGet.mockReset();
mockProviderGet.mockReturnValue(undefined);
});
// ── Place id namespace ───────────────────────────────────────────────────────
describe('Amap place ids', () => {
it('AMAP-001: namespaces an Amap id so it can never be sent to Google', () => {
expect(isAmapPlaceId('amap:B000A83M61')).toBe(true);
expect(amapPoiId('amap:B000A83M61')).toBe('B000A83M61');
// The whole point: Google's own id check must reject it, because Google
// answers a foreign id with a BILLABLE 400 INVALID_ARGUMENT.
expect(isGooglePlaceId('amap:B000A83M61')).toBe(false);
// And a bare Amap id, which is what we would have had without the prefix,
// is indistinguishable from a Google one — hence the prefix.
expect(isGooglePlaceId('B000A83M61')).toBe(true);
});
it('AMAP-002: is not confused with an OSM id or a coordinate pseudo-id', () => {
expect(isAmapPlaceId('node:240109189')).toBe(false);
expect(isAmapPlaceId('coords:39.9,116.4')).toBe(false);
expect(isAmapPlaceId('ChIJ_____')).toBe(false);
});
});
// ── searchText ───────────────────────────────────────────────────────────────
describe('AmapPlacesProvider.searchText', () => {
it('AMAP-010: converts the returned GCJ-02 coordinate back to WGS-84', async () => {
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue(
ok({
pois: [
{
id: 'B000A83M61',
name: '天安门',
address: '东长安街',
location: TIANANMEN_LOCATION,
pname: '北京市',
cityname: '北京市',
adname: '东城区',
type: '风景名胜;风景名胜相关;旅游景点',
},
],
}),
),
);
const [place] = await provider().searchText('天安门');
// Back within a metre of where it started, i.e. the datum round-tripped.
expect(place.lat as number).toBeCloseTo(39.90869, 4);
expect(place.lng as number).toBeCloseTo(116.39124, 4);
expect(place.amap_poi_id).toBe('amap:B000A83M61');
expect(place.source).toBe('amap');
expect(place.types).toEqual(['风景名胜', '风景名胜相关', '旅游景点']);
});
it('AMAP-011: assembles a full address without repeating a municipality', async () => {
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue(
ok({
pois: [
{
id: 'B1',
name: '故宫',
address: '景山前街4号',
location: TIANANMEN_LOCATION,
pname: '北京市',
cityname: '北京市',
adname: '东城区',
},
],
}),
),
);
const [place] = await provider().searchText('故宫');
expect(place.address).toBe('北京市东城区景山前街4号');
});
it('AMAP-012: survives the empty arrays Amap sends for absent string fields', async () => {
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue(
ok({
pois: [
{
id: 'B2',
name: '某小店',
// These are what Amap really returns when the field has no value —
// not undefined, and not '': an empty ARRAY.
address: [],
tel: [],
website: [],
location: TIANANMEN_LOCATION,
pname: [],
cityname: [],
adname: [],
},
],
}),
),
);
const [place] = await provider().searchText('某小店');
// Not "[]" and not "[object Object]" — the failure this coercion prevents.
expect(place.address).toBe('');
expect(place.phone).toBeNull();
expect(place.website).toBeNull();
});
it('AMAP-013: uses place/around with a bias, because text search cannot be biased', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(ok({ pois: [] })));
await provider().searchText('咖啡', 'zh', { lat: 31.2304, lng: 121.4737, radius: 3000 });
const url = calledUrl();
expect(url).toContain('/v3/place/around');
expect(url).toContain('radius=3000');
// The outbound coordinate is GCJ-02, i.e. NOT the WGS-84 value we passed in.
const location = new URL(url).searchParams.get('location')!;
const [lng, lat] = location.split(',').map(Number);
const back = gcj02ToWgs84(lat, lng);
expect(back.lat).toBeCloseTo(31.2304, 4);
expect(back.lng).toBeCloseTo(121.4737, 4);
expect(lat).not.toBeCloseTo(31.2304, 4);
});
it('AMAP-014: clamps the radius to the 50 km Amap accepts', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(ok({ pois: [] })));
await provider().searchText('x', undefined, { lat: 31.2304, lng: 121.4737, radius: 900000 });
expect(calledUrl()).toContain('radius=50000');
});
it('AMAP-015: uses place/text without a bias', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(ok({ pois: [] })));
await provider().searchText('外滩');
expect(calledUrl()).toContain('/v3/place/text');
});
it('AMAP-016: keeps a POI that has no geometry rather than dropping it', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(ok({ pois: [{ id: 'B3', name: '无坐标', location: [] }] })));
const [place] = await provider().searchText('无坐标');
expect(place.lat).toBeNull();
expect(place.lng).toBeNull();
expect(place.name).toBe('无坐标');
});
});
// ── Error translation ────────────────────────────────────────────────────────
describe('Amap error handling', () => {
it('AMAP-020: turns an invalid-key body into a 403, despite the HTTP 200', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(amapError('10001', 'INVALID_USER_KEY')));
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
await expect(provider().searchText('x')).rejects.toMatchObject({ status: 403 });
// The hint has to name what an admin can change; Amap's own wording does not.
await expect(provider().searchText('x')).rejects.toThrow(/Web 服务/);
errorSpy.mockRestore();
});
it('AMAP-021: turns quota exhaustion into a 429', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(amapError('10003')));
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
await expect(provider().searchText('x')).rejects.toMatchObject({ status: 429 });
errorSpy.mockRestore();
});
it('AMAP-022: reports the key source and the user, never the key', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(amapError('10009')));
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
await expect(provider().searchText('x')).rejects.toMatchObject({ status: 403 });
const logged = errorSpy.mock.calls.map((c) => String(c[0])).join('\n');
expect(logged).toContain('keySource=instance');
expect(logged).toContain('userId=3');
expect(logged).not.toContain('amap-test-key');
errorSpy.mockRestore();
});
it('AMAP-023: an unknown infocode is a 502 and keeps Amap\u2019s own wording', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(amapError('20800', 'OUT_OF_SERVICE')));
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
await expect(provider().searchText('x')).rejects.toMatchObject({ status: 502, message: /OUT_OF_SERVICE/ });
errorSpy.mockRestore();
});
});
// ── autocomplete ─────────────────────────────────────────────────────────────
describe('AmapPlacesProvider.autocomplete', () => {
it('AMAP-030: namespaces every suggestion id and caps the list at five', async () => {
const tips = Array.from({ length: 8 }, (_, i) => ({
id: `T${i}`,
name: `候选${i}`,
district: '北京市东城区',
address: '某路',
}));
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(ok({ tips })));
const suggestions = await provider().autocomplete('候选');
expect(suggestions).toHaveLength(5);
expect(suggestions[0].placeId).toBe('amap:T0');
expect(suggestions[0].mainText).toBe('候选0');
expect(suggestions[0].secondaryText).toBe('北京市东城区 某路');
expect(calledUrl()).toContain('/v3/assistant/inputtips');
});
it('AMAP-031: drops a tip with no id, which cannot be looked up afterwards', async () => {
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue(ok({ tips: [{ name: '一条路', id: [] }, { id: 'T9', name: '有效的' }] })),
);
const suggestions = await provider().autocomplete('路');
expect(suggestions).toHaveLength(1);
expect(suggestions[0].placeId).toBe('amap:T9');
});
it('AMAP-032: biases around the centre of the viewport, in GCJ-02', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(ok({ tips: [] })));
await provider().autocomplete('咖啡', 'zh', {
low: { lat: 31.2, lng: 121.4 },
high: { lat: 31.3, lng: 121.5 },
});
const location = new URL(calledUrl()).searchParams.get('location')!;
const [lng, lat] = location.split(',').map(Number);
const back = gcj02ToWgs84(lat, lng);
expect(back.lat).toBeCloseTo(31.25, 3);
expect(back.lng).toBeCloseTo(121.45, 3);
});
});
// ── placeDetails ─────────────────────────────────────────────────────────────
describe('AmapPlacesProvider.placeDetails', () => {
it('AMAP-040: looks up the bare poi id via v5 and returns WGS-84', async () => {
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue(
ok({
pois: [
{
id: 'B000A83M61',
name: '天安门',
address: '东长安街',
location: TIANANMEN_LOCATION,
business: { rating: '4.8', tel: '010-12345678' },
},
],
}),
),
);
const place = await provider().placeDetails('amap:B000A83M61');
const url = calledUrl();
expect(url).toContain('/v5/place/detail');
// The prefix is ours, not Amap's — it must not reach the API.
expect(new URL(url).searchParams.get('id')).toBe('B000A83M61');
expect(place!.rating).toBe(4.8);
expect(place!.phone).toBe('010-12345678');
expect(place!.lat as number).toBeCloseTo(39.90869, 4);
});
it('AMAP-041: returns null for an id that is not an Amap id, without calling out', async () => {
const fetchSpy = vi.fn();
vi.stubGlobal('fetch', fetchSpy);
expect(await provider().placeDetails('ChIJsomething')).toBeNull();
expect(fetchSpy).not.toHaveBeenCalled();
});
it('AMAP-042: returns null when Amap knows the id but has no POI for it', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(ok({ pois: [] })));
expect(await provider().placeDetails('amap:B000A83M61')).toBeNull();
});
});
// ── reverse ──────────────────────────────────────────────────────────────────
describe('AmapPlacesProvider.reverse', () => {
it('AMAP-050: sends GCJ-02 and prefers the AOI the click landed inside', async () => {
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue(
ok({
regeocode: {
formatted_address: '北京市东城区天安门广场',
aois: [{ name: '天安门广场' }],
pois: [{ name: '国旗杆' }],
addressComponent: { building: { name: '某楼' } },
},
}),
),
);
const answer = await provider().reverse(39.90869, 116.39124);
expect(answer).toEqual({ name: '天安门广场', address: '北京市东城区天安门广场' });
const location = new URL(calledUrl()).searchParams.get('location')!;
const [lng, lat] = location.split(',').map(Number);
expect(gcj02ToWgs84(lat, lng).lat).toBeCloseTo(39.90869, 4);
});
it('AMAP-051: falls through the name candidates when there is no AOI', async () => {
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue(
ok({ regeocode: { formatted_address: '某地址', aois: [], pois: [], addressComponent: { neighborhood: { name: '某社区' } } } }),
),
);
expect((await provider().reverse(39.9, 116.4))!.name).toBe('某社区');
});
});
// ── Shared-link parsing ──────────────────────────────────────────────────────
describe('parseAmapUrl', () => {
it('AMAP-060: reads uri.amap.com marker links as lng,lat in GCJ-02', () => {
const parsed = parseAmapUrl(`https://uri.amap.com/marker?position=${TIANANMEN_LOCATION}&name=%E5%A4%A9%E5%AE%89%E9%97%A8`);
expect(parsed).not.toBeNull();
// Longitude first AND a datum shift: reading this link the Google way would
// put a Beijing landmark in the Indian Ocean.
expect(parsed!.lat).toBeCloseTo(39.90869, 4);
expect(parsed!.lng).toBeCloseTo(116.39124, 4);
expect(parsed!.name).toBe('天安门');
});
it('AMAP-061: picks the poi id out of a place page', () => {
const parsed = parseAmapUrl('https://www.amap.com/place/B000A83M61');
expect(parsed!.poiId).toBe('B000A83M61');
expect(Number.isNaN(parsed!.lat)).toBe(true);
});
it('AMAP-062: refuses a host that is not Amap', () => {
expect(parseAmapUrl('https://amap.com.evil.example/marker?position=116,39')).toBeNull();
expect(parseAmapUrl('not a url')).toBeNull();
});
it('AMAP-063: recognises only Amap\u2019s own hosts', () => {
expect(isAmapHost('uri.amap.com')).toBe(true);
expect(isAmapHost('www.amap.com')).toBe(true);
expect(isAmapHost('surl.amap.com')).toBe(true);
expect(isAmapHost('amap.com.attacker.net')).toBe(false);
expect(isAmapHost('notamap.com')).toBe(false);
});
});
// ── Provider selection ───────────────────────────────────────────────────────
describe('MapsService.resolvePlacesProvider', () => {
/** Make the Google key chain answer, the Amap chain answer, or neither. */
function keys(opts: { google?: string; amap?: string }) {
mockDbGet.mockImplementation((...args: unknown[]) => {
// resolveApiKey reads the caller's own row per name; the SQL differs but
// the stub only sees the bound userId, so answer both columns at once.
return { maps_api_key: opts.google ?? null, amap_api_key: opts.amap ?? null } as any;
});
}
it('AMAP-070: auto keeps Google when a Google key is configured', () => {
keys({ google: 'gkey', amap: 'akey' });
expect(svc.resolvePlacesProvider(1)).toBeInstanceOf(GooglePlacesProvider);
});
it('AMAP-071: auto falls to Amap only when there is no Google key', () => {
keys({ amap: 'akey' });
expect(svc.resolvePlacesProvider(1)).toBeInstanceOf(AmapPlacesProvider);
});
it('AMAP-072: auto with no key at all means the OpenStreetMap stack', () => {
keys({});
expect(svc.resolvePlacesProvider(1)).toBeNull();
});
it('AMAP-073: an explicit amap choice wins over a configured Google key', () => {
mockProviderGet.mockReturnValue({ value: 'amap' });
keys({ google: 'gkey', amap: 'akey' });
expect(svc.resolvePlacesProvider(1)).toBeInstanceOf(AmapPlacesProvider);
});
it('AMAP-074: an explicit google choice never silently uses Amap instead', () => {
mockProviderGet.mockReturnValue({ value: 'google' });
keys({ amap: 'akey' });
// Misconfigured means "answer with OSM", not "bill somebody else's provider".
expect(svc.resolvePlacesProvider(1)).toBeNull();
});
it('AMAP-075: openstreetmap ignores both keys', () => {
mockProviderGet.mockReturnValue({ value: 'openstreetmap' });
keys({ google: 'gkey', amap: 'akey' });
expect(svc.resolvePlacesProvider(1)).toBeNull();
});
it('AMAP-076: a hand-edited nonsense value degrades to auto instead of failing', () => {
mockProviderGet.mockReturnValue({ value: 'not-a-provider' });
keys({ google: 'gkey' });
expect(svc.placesProviderChoice()).toBe('auto');
expect(svc.resolvePlacesProvider(1)).toBeInstanceOf(GooglePlacesProvider);
});
it('AMAP-077: an Amap place stays with Amap even while Google is selected', async () => {
mockProviderGet.mockReturnValue({ value: 'google' });
keys({ google: 'gkey', amap: 'akey' });
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue(ok({ pois: [{ id: 'B9', name: '旧地点', location: TIANANMEN_LOCATION }] })),
);
const { place } = await svc.getPlaceDetails(1, 'amap:B9');
// Reached Amap's endpoint, not Google's — an id outlives the setting.
expect(calledUrl()).toContain('restapi.amap.com');
expect(place!.name).toBe('旧地点');
});
it('AMAP-077b: a Google place still opens after the admin switches to Amap', async () => {
// The mirror of AMAP-077, and the one that matters on a real migration: an
// install with a year of google_place_id rows switches provider, and every
// one of those places has to keep opening against the Google key that is
// still configured.
mockProviderGet.mockReturnValue({ value: 'amap' });
keys({ google: 'gkey', amap: 'akey' });
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
ok: true,
status: 200,
json: async () => ({ id: 'ChIJ123', displayName: { text: 'Old Google place' }, location: { latitude: 48.8, longitude: 2.3 } }),
}),
);
const { place } = await svc.getPlaceDetails(1, 'ChIJ123');
expect(calledUrl()).toContain('places.googleapis.com');
expect(place!.name).toBe('Old Google place');
});
it('AMAP-078: an Amap id is never mistaken for an OSM type:id pair', async () => {
// `amap:B9` contains a colon, like `node:123`. Read as OSM it would go to
// Overpass as element type "amap" and answer every Chinese place with a
// blank record — so the ordering of those two branches is load-bearing.
mockProviderGet.mockReturnValue({ value: 'amap' });
keys({ amap: 'akey' });
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue(ok({ pois: [{ id: 'B9', name: '天安门', location: TIANANMEN_LOCATION }] })),
);
const { place } = await svc.getPlaceDetails(1, 'amap:B9');
expect(place!.source).toBe('amap');
expect(calledUrl()).not.toContain('overpass');
});
});
describe('MapsService.searchPlaces with Amap selected', () => {
it('AMAP-080: reports amap as the source so the client can credit it', async () => {
mockProviderGet.mockReturnValue({ value: 'amap' });
mockDbGet.mockReturnValue({ maps_api_key: null, amap_api_key: 'akey' } as any);
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue(ok({ pois: [{ id: 'B1', name: '外滩', location: TIANANMEN_LOCATION }] })),
);
const result = await svc.searchPlaces(1, '外滩');
expect(result.source).toBe('amap');
expect(result.places).toHaveLength(1);
});
});
+15 -1
View File
@@ -51,6 +51,7 @@ const {
mockCacheGetInFlight,
mockCacheSetInFlight,
mockServeFilePath,
mockProviderGet,
} = vi.hoisted(() => ({
mockDbGet: vi.fn((..._args: unknown[]) => undefined as any),
mockDbRun: vi.fn(),
@@ -59,6 +60,12 @@ const {
// row holds a key", and would otherwise have its stub eaten by the
// app_settings lookup.
mockInstanceGet: vi.fn((..._args: unknown[]) => undefined as any),
// The places_provider row is read before the key chain, so it needs a seam of
// its own: routed through mockInstanceGet it would eat the mockReturnValueOnce
// that a case meant for the instance-wide KEY, and the service would resolve a
// key value as a provider name. Default undefined = the 'auto' choice, which is
// what every install that predates Amap has.
mockProviderGet: vi.fn((..._args: unknown[]) => undefined as any),
preparedSql: [] as string[],
mockCheckSsrf: vi.fn(async (_url: string, _bypassInternalIpAllowed?: boolean): Promise<SsrfCheckStub> => ({
allowed: true,
@@ -83,7 +90,12 @@ vi.mock('../../../src/db/database', () => ({
prepare: (sql: string) => {
preparedSql.push(sql);
return {
get: (...args: unknown[]) => (sql.includes('app_settings') ? mockInstanceGet(...args) : mockDbGet(...args)),
get: (...args: unknown[]) => {
if (!sql.includes('app_settings')) return mockDbGet(...args);
// Keyed by the bound parameter, not just by the table: app_settings
// holds both the instance-wide API keys and the provider choice.
return args[0] === 'places_provider' ? mockProviderGet(...args) : mockInstanceGet(...args);
},
all: vi.fn(() => []),
run: mockDbRun,
};
@@ -195,6 +207,8 @@ afterEach(() => {
mockDbGet.mockReturnValue(undefined);
mockInstanceGet.mockReset();
mockInstanceGet.mockReturnValue(undefined);
mockProviderGet.mockReset();
mockProviderGet.mockReturnValue(undefined);
preparedSql.length = 0;
mockDbRun.mockReset();
mockCheckSsrf.mockReset();
+3
View File
@@ -77,6 +77,8 @@ export const apiKeysUpdateRequestSchema = z.object({
maps_api_key: z.string().nullable().optional(),
openweather_api_key: z.string().nullable().optional(),
unsplash_api_key: z.string().nullable().optional(),
/** Amap (高德) web-service key — the places provider an install in China uses. */
amap_api_key: z.string().nullable().optional(),
});
export type ApiKeysUpdateRequest = z.infer<typeof apiKeysUpdateRequestSchema>;
@@ -84,6 +86,7 @@ export const settingsUpdateRequestSchema = z.object({
maps_api_key: z.string().nullable().optional(),
openweather_api_key: z.string().nullable().optional(),
unsplash_api_key: z.string().nullable().optional(),
amap_api_key: z.string().nullable().optional(),
username: z.string().optional(),
email: z.string().optional(),
});
+103
View File
@@ -0,0 +1,103 @@
import { fromAmapLocation, gcj02ToWgs84, isOutsideChina, toAmapLocation, wgs84ToGcj02 } from './gcj02';
import { describe, expect, it } from 'vitest';
/** Metres between two coordinates — good enough for an assertion at this scale. */
function metresApart(aLat: number, aLng: number, bLat: number, bLng: number): number {
const R = 6371000;
const dLat = ((bLat - aLat) * Math.PI) / 180;
const dLng = ((bLng - aLng) * Math.PI) / 180;
const mLat = ((aLat + bLat) / 2) * (Math.PI / 180);
const x = dLng * Math.cos(mLat);
return Math.sqrt(dLat * dLat + x * x) * R;
}
describe('isOutsideChina', () => {
it('covers the mainland, Hong Kong and Taiwan', () => {
expect(isOutsideChina(39.9042, 116.4074)).toBe(false); // Beijing
expect(isOutsideChina(22.3193, 114.1694)).toBe(false); // Hong Kong
expect(isOutsideChina(25.033, 121.5654)).toBe(false); // Taipei
});
it('excludes the rest of the world so a foreign trip is never shifted', () => {
expect(isOutsideChina(48.8566, 2.3522)).toBe(true); // Paris
expect(isOutsideChina(35.6762, 139.6503)).toBe(true); // Tokyo
expect(isOutsideChina(-33.8688, 151.2093)).toBe(true); // Sydney
});
it('treats a non-finite coordinate as outside rather than shifting NaN', () => {
expect(isOutsideChina(Number.NaN, 116)).toBe(true);
expect(isOutsideChina(39, Number.POSITIVE_INFINITY)).toBe(true);
});
});
describe('wgs84ToGcj02', () => {
// Reference values from the published approximation every coordtransform
// implementation shares; the tolerance is the metre-or-two the algorithm is
// accurate to, not a precision claim about our arithmetic.
it('shifts Tiananmen Square by the expected few hundred metres', () => {
const gcj = wgs84ToGcj02(39.90869, 116.39124);
expect(gcj.lat).toBeCloseTo(39.91009, 4);
expect(gcj.lng).toBeCloseTo(116.39745, 4);
// The whole point of the datum: the offset is big enough to be visibly wrong
// on a map if it were skipped.
expect(metresApart(39.90869, 116.39124, gcj.lat, gcj.lng)).toBeGreaterThan(300);
});
it('leaves a coordinate outside China exactly as it came in', () => {
const paris = wgs84ToGcj02(48.8566, 2.3522);
expect(paris).toEqual({ lat: 48.8566, lng: 2.3522 });
});
});
describe('gcj02ToWgs84', () => {
it('round-trips to sub-metre accuracy across China', () => {
const points: [number, number][] = [
[39.90869, 116.39124], // Beijing
[31.2304, 121.4737], // Shanghai
[22.5431, 114.0579], // Shenzhen
[29.6534, 91.1719], // Lhasa — far west, where the offset is largest
[43.8256, 87.6168], // Ürümqi
[18.2528, 109.5119], // Sanya — far south
];
for (const [lat, lng] of points) {
const gcj = wgs84ToGcj02(lat, lng);
const back = gcj02ToWgs84(gcj.lat, gcj.lng);
expect(metresApart(lat, lng, back.lat, back.lng)).toBeLessThan(1);
}
});
it('is the exact inverse, not the subtract-the-same-offset shortcut', () => {
// The shortcut is out by a few metres; this asserts we are well inside that.
const gcj = wgs84ToGcj02(39.90869, 116.39124);
const back = gcj02ToWgs84(gcj.lat, gcj.lng);
expect(back.lat).toBeCloseTo(39.90869, 7);
expect(back.lng).toBeCloseTo(116.39124, 7);
});
it('leaves a coordinate outside China alone', () => {
expect(gcj02ToWgs84(35.6762, 139.6503)).toEqual({ lat: 35.6762, lng: 139.6503 });
});
});
describe('toAmapLocation', () => {
it('emits lng,lat in GCJ-02 with six decimals', () => {
expect(toAmapLocation(39.90869, 116.39124)).toBe('116.397481,39.910091');
});
});
describe('fromAmapLocation', () => {
it('parses lng,lat back into WGS-84', () => {
const parsed = fromAmapLocation('116.397481,39.910091');
expect(parsed).not.toBeNull();
expect(metresApart(39.90869, 116.39124, parsed!.lat, parsed!.lng)).toBeLessThan(1);
});
it('returns null for the shapes Amap actually sends for a POI with no geometry', () => {
expect(fromAmapLocation([])).toBeNull();
expect(fromAmapLocation('')).toBeNull();
expect(fromAmapLocation(undefined)).toBeNull();
expect(fromAmapLocation('116.397481')).toBeNull();
expect(fromAmapLocation('nope,nope')).toBeNull();
});
});
+153
View File
@@ -0,0 +1,153 @@
/**
* WGS-84 ⇄ GCJ-02 conversion.
*
* Everything TREK stores and draws is WGS-84: the database columns, Nominatim,
* Overpass, OSRM, GPX and KML, and every Leaflet/MapLibre coordinate. Chinese
* providers are not allowed to publish that datum — Amap (高德) returns and
* expects GCJ-02, the "Mars coordinates" obfuscation, which is offset from
* WGS-84 by roughly 300–800 m depending on where you are.
*
* So this file exists to keep exactly one datum inside the app. It is used at
* two boundaries and nowhere else:
*
* - server/src/nest/maps/providers/amap.provider.ts converts on the way out
* and back in, so a place that reaches the database is always WGS-84.
* - client/src/components/Map/gcj02Crs.ts folds the shift into a Leaflet
* projection, so GCJ-02 raster tiles line up with WGS-84 markers without
* any component having to know which basemap is loaded.
*
* The algorithm is the widely used published approximation (the same one every
* `coordtransform`-style library implements). It is not the state secret itself
* and is accurate to a metre or two, which is well inside the precision a POI
* search result carries. `gcj02ToWgs84` inverts it numerically rather than with
* the common one-shot "subtract the same offset" trick: the offset is a function
* of position, so subtracting it at the wrong position leaves a few metres of
* error, and a round trip through a search result and back into a share link
* would drift visibly.
*/
/** Krasovsky 1940 semi-major axis, the ellipsoid the published offset uses. */
const A = 6378245;
/** Its first eccentricity squared. */
const EE = 0.006_693_421_622_965_943;
const PI = Math.PI;
/**
* True when GCJ-02 does not apply and the coordinate must pass through
* untouched.
*
* Without this every foreign trip in TREK would move: the offset formula is
* happy to evaluate anywhere on earth, so applying it to Paris shifts Paris.
* The bounding box is deliberately the crude one every implementation uses —
* it over-covers, reaching into the sea and into neighbouring countries, but
* erring that way only means a coordinate in the border area gets a shift of a
* few hundred metres applied consistently in both directions, which round-trips
* cleanly. A tighter polygon would risk the far worse failure of converting one
* way and not the other.
*
* Hong Kong, Macau and Taiwan are inside the box. That matches what Amap
* actually serves for them, so it is not an oversight.
*/
export function isOutsideChina(lat: number, lng: number): boolean {
if (!Number.isFinite(lat) || !Number.isFinite(lng)) return true;
return lng < 72.004 || lng > 137.8347 || lat < 0.8293 || lat > 55.8271;
}
function transformLat(x: number, y: number): number {
let ret = -100 + 2 * x + 3 * y + 0.2 * y * y + 0.1 * x * y + 0.2 * Math.sqrt(Math.abs(x));
ret += ((20 * Math.sin(6 * x * PI) + 20 * Math.sin(2 * x * PI)) * 2) / 3;
ret += ((20 * Math.sin(y * PI) + 40 * Math.sin((y / 3) * PI)) * 2) / 3;
ret += ((160 * Math.sin((y / 12) * PI) + 320 * Math.sin((y * PI) / 30)) * 2) / 3;
return ret;
}
function transformLng(x: number, y: number): number {
let ret = 300 + x + 2 * y + 0.1 * x * x + 0.1 * x * y + 0.1 * Math.sqrt(Math.abs(x));
ret += ((20 * Math.sin(6 * x * PI) + 20 * Math.sin(2 * x * PI)) * 2) / 3;
ret += ((20 * Math.sin(x * PI) + 40 * Math.sin((x / 3) * PI)) * 2) / 3;
ret += ((150 * Math.sin((x / 12) * PI) + 300 * Math.sin((x / 30) * PI)) * 2) / 3;
return ret;
}
/** The position-dependent offset added to a WGS-84 point to get GCJ-02. */
function offset(lat: number, lng: number): { dLat: number; dLng: number } {
const x = lng - 105;
const y = lat - 35;
let dLat = transformLat(x, y);
let dLng = transformLng(x, y);
const radLat = (lat / 180) * PI;
let magic = Math.sin(radLat);
magic = 1 - EE * magic * magic;
const sqrtMagic = Math.sqrt(magic);
dLat = (dLat * 180) / (((A * (1 - EE)) / (magic * sqrtMagic)) * PI);
dLng = (dLng * 180) / ((A / sqrtMagic) * Math.cos(radLat) * PI);
return { dLat, dLng };
}
export interface LatLng {
lat: number;
lng: number;
}
/** WGS-84 → GCJ-02. Outside China the input is returned unchanged. */
export function wgs84ToGcj02(lat: number, lng: number): LatLng {
if (isOutsideChina(lat, lng)) return { lat, lng };
const { dLat, dLng } = offset(lat, lng);
return { lat: lat + dLat, lng: lng + dLng };
}
/**
* GCJ-02 → WGS-84, solved rather than approximated.
*
* The forward offset is a function of the *WGS-84* position, so the usual
* `wgs - (gcj_of_gcj - gcj)` shortcut evaluates it at the wrong point and
* leaves several metres of error. Newton would need the Jacobian of a sum of
* sines; a fixed-point iteration converges just as fast here because the offset
* varies slowly with position, and it cannot diverge. Three or four passes reach
* the double-precision floor; the loop below stops as soon as a pass moves the
* answer by less than ~1e-9° (about 0.1 mm) and is capped so a pathological
* input cannot spin.
*/
export function gcj02ToWgs84(lat: number, lng: number): LatLng {
if (isOutsideChina(lat, lng)) return { lat, lng };
let guessLat = lat;
let guessLng = lng;
for (let i = 0; i < 10; i++) {
const forward = wgs84ToGcj02(guessLat, guessLng);
const errLat = forward.lat - lat;
const errLng = forward.lng - lng;
if (Math.abs(errLat) < 1e-9 && Math.abs(errLng) < 1e-9) break;
guessLat -= errLat;
guessLng -= errLng;
}
return { lat: guessLat, lng: guessLng };
}
/**
* Amap spells a coordinate `lng,lat` in a single string, with six decimals, and
* rejects more than that on some endpoints. Longitude first is the opposite of
* every other coordinate in this codebase, so the ordering lives here once
* instead of at each call site.
*/
export function toAmapLocation(lat: number, lng: number): string {
const gcj = wgs84ToGcj02(lat, lng);
return `${gcj.lng.toFixed(6)},${gcj.lat.toFixed(6)}`;
}
/**
* Parse Amap's `"lng,lat"` back into a WGS-84 pair, or null when the field is
* missing or unparseable — which Amap does do: a POI with no geometry comes
* back with `location: []` rather than with the key absent.
*/
export function fromAmapLocation(location: unknown): LatLng | null {
if (typeof location !== 'string') return null;
const parts = location.split(',');
if (parts.length !== 2) return null;
// Destructured with a default rather than indexed: under noUncheckedIndexedAccess
// an index into a length-checked array is still `string | undefined`.
const [lngText = '', latText = ''] = parts;
const lng = Number.parseFloat(lngText);
const lat = Number.parseFloat(latText);
if (!Number.isFinite(lat) || !Number.isFinite(lng)) return null;
return gcj02ToWgs84(lat, lng);
}
+12
View File
@@ -154,6 +154,18 @@ const admin: TranslationStrings = {
'admin.weatherKeyHint': 'لبيانات الطقس. مجاني من openweathermap.org',
'admin.unsplashKey': 'مفتاح واجهة برمجة تطبيقات Unsplash',
'admin.unsplashKeyHint': 'للبحث عن الصور. مجاني من unsplash.com/developers',
'admin.amapKey': 'مفتاح واجهة برمجة تطبيقات Amap (高德地图)',
'admin.amapKeyHint':
'للبحث عن الأماكن في البر الرئيسي للصين، حيث لا يمكن الوصول إلى Google وتكون بيانات OpenStreetMap شحيحة. يتطلب مفتاحًا من نوع «Web 服务» (خدمة ويب)، وليس مفتاح JS API. يمكن الحصول عليه من console.amap.com.',
'admin.placesProvider.title': 'مزوّد البحث عن الأماكن',
'admin.placesProvider.subtitle':
'الخدمة التي تتولى البحث عن الأماكن والإكمال التلقائي والتفاصيل. «تلقائي» يستخدم Google عند توفر مفتاح Google، ثم Amap، ثم OpenStreetMap.',
'admin.placesProvider.auto': 'تلقائي',
'admin.placesProvider.google': 'Google Places',
'admin.placesProvider.amap': 'Amap (高德地图)',
'admin.placesProvider.openstreetmap': 'OpenStreetMap',
'admin.placesProvider.missingKey': 'المزوّد المحدد لا يحتوي على مفتاح واجهة برمجة تطبيقات، لذا يعود البحث عن الأماكن إلى OpenStreetMap.',
'admin.placesProvider.saved': 'تم حفظ مزوّد البحث عن الأماكن',
'admin.validateKey': 'اختبار',
'admin.keyValid': 'متصل',
'admin.keyInvalid': 'غير صالح',
+7
View File
@@ -92,6 +92,9 @@ const places: TranslationStrings = {
'places.loadingDetails': 'جارٍ تحميل تفاصيل المكان…',
'places.osmHint':
'يتم البحث عبر OpenStreetMap (بدون صور أو ساعات عمل أو تقييمات). أضف مفتاح Google API في الإعدادات للحصول على جميع التفاصيل.',
'places.osmActive': 'يتم استخدام OpenStreetMap. مفتاح Google API يضيف التقييمات وساعات العمل.',
'places.osmActiveAmap': 'تجري المشاركة عبر OpenStreetMap. يضيف مفتاح واجهة Amap (高德地图) التقييمات وساعات العمل.',
'places.osmActiveAny': 'تجري المشاركة عبر OpenStreetMap. يضيف مفتاح واجهة Google أو Amap (高德地图) التقييمات وساعات العمل.',
'places.details.title': 'تفاصيل المكان',
'places.details.empty': 'اختر نتيجة لعرض التفاصيل',
'places.details.loading': 'جارٍ تحميل التفاصيل...',
@@ -104,6 +107,10 @@ const places: TranslationStrings = {
'places.details.adoptBlocked': 'أفرغ حقل الوصف أولًا',
'places.details.facts': 'معلومات مفيدة',
'places.details.nothing': 'لم يتم العثور على شيء لهذا المكان.',
'places.details.noKeyTitle': 'تتوفر تفاصيل أكثر',
'places.details.noKeyHint': 'بدون مفتاح Google API يستخدم هذا التثبيت المصادر الحرة فقط. اطلب من مسؤول TREK إضافة مفتاح للحصول على التقييمات وساعات العمل والصور.',
'places.details.noKeyHintAmap': 'بدون مفتاح واجهة Amap (高德地图) يمكن لهذا المثيل استخدام المصادر المجانية فقط. اطلب من مسؤول TREK إضافة مفتاح للحصول على التقييمات وساعات العمل.',
'places.details.noKeyHintAny': 'بدون مفتاح واجهة Google أو Amap (高德地图) يمكن لهذا المثيل استخدام المصادر المجانية فقط. اطلب من مسؤول TREK إضافة مفتاح للحصول على التقييمات وساعات العمل.',
'places.details.aboutBrand': 'عن السلسلة',
'places.details.aboutBrandNote': 'يصف السلسلة، لا هذا الفرع.',
'places.details.fact.rating': 'التقييم',
+12
View File
@@ -132,6 +132,18 @@ const admin: TranslationStrings = {
'admin.weatherKeyHint': 'Para dados meteorológicos. Grátis em openweathermap.org',
'admin.unsplashKey': 'Chave de API do Unsplash',
'admin.unsplashKeyHint': 'Para busca de imagens. Grátis em unsplash.com/developers',
'admin.amapKey': 'Chave de API do Amap (高德地图)',
'admin.amapKeyHint':
'Para a busca de lugares na China continental, onde o Google é inacessível e o OpenStreetMap tem poucos dados. Exige uma chave do tipo "Web 服务" (serviço web), não uma chave da API JS. Obtenha em console.amap.com.',
'admin.placesProvider.title': 'Provedor de busca de lugares',
'admin.placesProvider.subtitle':
'Qual serviço responde à busca de lugares, ao autocompletar e aos detalhes. Automático usa o Google quando há uma chave do Google configurada, depois o Amap e depois o OpenStreetMap.',
'admin.placesProvider.auto': 'Automático',
'admin.placesProvider.google': 'Google Places',
'admin.placesProvider.amap': 'Amap (高德地图)',
'admin.placesProvider.openstreetmap': 'OpenStreetMap',
'admin.placesProvider.missingKey': 'O provedor selecionado não tem nenhuma chave de API configurada, então a busca de lugares volta para o OpenStreetMap.',
'admin.placesProvider.saved': 'Provedor de busca de lugares salvo',
'admin.validateKey': 'Testar',
'admin.keyValid': 'Conectado',
'admin.keyInvalid': 'Inválida',
+7
View File
@@ -92,6 +92,9 @@ const places: TranslationStrings = {
'places.loadingDetails': 'Carregando detalhes do lugar…',
'places.osmHint':
'Busca via OpenStreetMap (sem fotos, horários ou avaliações). Adicione uma chave Google nas configurações para detalhes completos.',
'places.osmActive': 'Usando o OpenStreetMap. Uma chave de API do Google adiciona avaliações e horários.',
'places.osmActiveAmap': 'Usando o OpenStreetMap. Uma chave de API do Amap (高德地图) adiciona avaliações e horários de funcionamento.',
'places.osmActiveAny': 'Usando o OpenStreetMap. Uma chave de API do Google ou do Amap (高德地图) adiciona avaliações e horários de funcionamento.',
'places.details.title': 'Detalhes do lugar',
'places.details.empty': 'Escolha um resultado para ver mais',
'places.details.loading': 'Carregando detalhes...',
@@ -104,6 +107,10 @@ const places: TranslationStrings = {
'places.details.adoptBlocked': 'Esvazie primeiro o campo de descrição',
'places.details.facts': 'Bom saber',
'places.details.nothing': 'Nada encontrado para este lugar.',
'places.details.noKeyTitle': 'Mais detalhes disponíveis',
'places.details.noKeyHint': 'Sem uma chave de API do Google, esta instância usa apenas fontes livres. Peça ao administrador do TREK para adicionar uma para avaliações, horários e fotos.',
'places.details.noKeyHintAmap': 'Sem uma chave de API do Amap (高德地图), esta instância só pode usar fontes gratuitas. Peça ao administrador do TREK para adicionar uma, para ter avaliações e horários.',
'places.details.noKeyHintAny': 'Sem uma chave de API do Google ou do Amap (高德地图), esta instância só pode usar fontes gratuitas. Peça ao administrador do TREK para adicionar uma, para ter avaliações e horários.',
'places.details.aboutBrand': 'Sobre a rede',
'places.details.aboutBrandNote': 'Descreve a rede, não esta unidade.',
'places.details.fact.rating': 'Avaliação',
+12
View File
@@ -132,6 +132,18 @@ const admin: TranslationStrings = {
'admin.weatherKeyHint': 'Pro data o počasí. Zdarma na openweathermap.org',
'admin.unsplashKey': 'Klíč API Unsplash',
'admin.unsplashKeyHint': 'Pro vyhledávání obrázků. Zdarma na unsplash.com/developers',
'admin.amapKey': 'API klíč Amap (高德地图)',
'admin.amapKeyHint':
'Pro hledání míst v kontinentální Číně, kde Google není dostupný a OpenStreetMap má málo dat. Vyžaduje klíč typu „Web 服务" (webová služba), nikoli klíč JS API. Získáte na console.amap.com.',
'admin.placesProvider.title': 'Poskytovatel hledání míst',
'admin.placesProvider.subtitle':
'Která služba obsluhuje hledání míst, našeptávač a podrobnosti. Automaticky použije Google, pokud je nastaven klíč Google, poté Amap a poté OpenStreetMap.',
'admin.placesProvider.auto': 'Automaticky',
'admin.placesProvider.google': 'Google Places',
'admin.placesProvider.amap': 'Amap (高德地图)',
'admin.placesProvider.openstreetmap': 'OpenStreetMap',
'admin.placesProvider.missingKey': 'Pro vybraného poskytovatele není nastaven API klíč, hledání míst proto přejde na OpenStreetMap.',
'admin.placesProvider.saved': 'Poskytovatel hledání míst uložen',
'admin.validateKey': 'Testovat',
'admin.keyValid': 'Připojeno',
'admin.keyInvalid': 'Neplatný',
+7
View File
@@ -92,6 +92,9 @@ const places: TranslationStrings = {
'places.loadingDetails': 'Načítání podrobností místa…',
'places.osmHint':
'Používáte hledání přes OpenStreetMap (bez fotek a hodnocení). Pro plné detaily přidejte Google API klíč v nastavení.',
'places.osmActive': 'Používá se OpenStreetMap. Klíč Google API přidá hodnocení a otevírací dobu.',
'places.osmActiveAmap': 'Používá se OpenStreetMap. Klíč API Amap (高德地图) přidá hodnocení a otevírací dobu.',
'places.osmActiveAny': 'Používá se OpenStreetMap. Klíč API Google nebo Amap (高德地图) přidá hodnocení a otevírací dobu.',
'places.details.title': 'Podrobnosti místa',
'places.details.empty': 'Vyber výsledek a zobraz detaily',
'places.details.loading': 'Načítání podrobností...',
@@ -104,6 +107,10 @@ const places: TranslationStrings = {
'places.details.adoptBlocked': 'Nejdřív vyprázdni pole popisu',
'places.details.facts': 'Dobré vědět',
'places.details.nothing': 'Pro toto místo se nic nenašlo.',
'places.details.noKeyTitle': 'K dispozici je více podrobností',
'places.details.noKeyHint': 'Bez klíče Google API používá tato instance jen volné zdroje. Požádejte správce TREK o jeho přidání pro hodnocení, otevírací dobu a fotografie.',
'places.details.noKeyHintAmap': 'Bez klíče API Amap (高德地图) může tato instance používat jen bezplatné zdroje. Požádejte správce TREK, aby klíč přidal — přibydou hodnocení a otevírací doba.',
'places.details.noKeyHintAny': 'Bez klíče API Google nebo Amap (高德地图) může tato instance používat jen bezplatné zdroje. Požádejte správce TREK, aby klíč přidal — přibydou hodnocení a otevírací doba.',
'places.details.aboutBrand': 'O řetězci',
'places.details.aboutBrandNote': 'Popisuje řetězec, ne tuto pobočku.',
'places.details.fact.rating': 'Hodnocení',
+12
View File
@@ -134,6 +134,18 @@ const admin: TranslationStrings = {
'admin.weatherKeyHint': 'Für Wetterdaten. Kostenlos unter openweathermap.org',
'admin.unsplashKey': 'Unsplash-API-Schlüssel',
'admin.unsplashKeyHint': 'Für die Bildsuche. Kostenlos unter unsplash.com/developers',
'admin.amapKey': 'Amap (高德地图) API-Key',
'admin.amapKeyHint':
'Für die Ortssuche in Festlandchina, wo Google nicht erreichbar ist und OpenStreetMap kaum Daten hat. Benötigt einen Key vom Typ „Web 服务" (Webdienst), nicht einen JS-API-Key. Erhältlich auf console.amap.com.',
'admin.placesProvider.title': 'Anbieter für die Ortssuche',
'admin.placesProvider.subtitle':
'Welcher Dienst Ortssuche, Vorschläge und Details beantwortet. Automatisch nutzt Google, wenn ein Google-Key hinterlegt ist, dann Amap, dann OpenStreetMap.',
'admin.placesProvider.auto': 'Automatisch',
'admin.placesProvider.google': 'Google Places',
'admin.placesProvider.amap': 'Amap (高德地图)',
'admin.placesProvider.openstreetmap': 'OpenStreetMap',
'admin.placesProvider.missingKey': 'Für den gewählten Anbieter ist kein API-Key hinterlegt, die Ortssuche fällt daher auf OpenStreetMap zurück.',
'admin.placesProvider.saved': 'Anbieter für die Ortssuche gespeichert',
'admin.validateKey': 'Test',
'admin.keyValid': 'Verbunden',
'admin.keyInvalid': 'Ungültig',
+7
View File
@@ -92,6 +92,9 @@ const places: TranslationStrings = {
'places.loadingDetails': 'Ortsdetails werden geladen…',
'places.osmHint':
'OpenStreetMap-Suche aktiv (ohne Bilder, Öffnungszeiten, Bewertungen). Für erweiterte Daten Google API Key in den Einstellungen hinterlegen.',
'places.osmActive': 'OpenStreetMap wird genutzt. Ein Google-API-Key ergänzt Bewertungen und Öffnungszeiten.',
'places.osmActiveAmap': 'OpenStreetMap ist aktiv. Ein Amap-API-Key (高德地图) ergänzt Bewertungen und Öffnungszeiten.',
'places.osmActiveAny': 'OpenStreetMap ist aktiv. Ein Google- oder Amap-API-Key (高德地图) ergänzt Bewertungen und Öffnungszeiten.',
'places.details.title': 'Ortsdetails',
'places.details.empty': 'Wähle ein Ergebnis für Details',
'places.details.loading': 'Details werden geladen...',
@@ -104,6 +107,10 @@ const places: TranslationStrings = {
'places.details.adoptBlocked': 'Erst das Beschreibungsfeld leeren',
'places.details.facts': 'Gut zu wissen',
'places.details.nothing': 'Zu diesem Ort wurde nichts gefunden.',
'places.details.noKeyTitle': 'Mehr Details möglich',
'places.details.noKeyHint': 'Ohne Google-API-Key nutzt diese Instanz nur freie Quellen. Wende dich an deine TREK-Administration, um Bewertungen, Öffnungszeiten und Fotos freizuschalten.',
'places.details.noKeyHintAmap': 'Ohne Amap-API-Key (高德地图) kann diese Instanz nur freie Quellen nutzen. Bitte die TREK-Administration, einen Key für Bewertungen und Öffnungszeiten hinzuzufügen.',
'places.details.noKeyHintAny': 'Ohne Google- oder Amap-API-Key (高德地图) kann diese Instanz nur freie Quellen nutzen. Bitte die TREK-Administration, einen Key für Bewertungen und Öffnungszeiten hinzuzufügen.',
'places.details.aboutBrand': 'Über die Kette',
'places.details.aboutBrandNote': 'Beschreibt die Kette, nicht diese Filiale.',
'places.details.fact.rating': 'Bewertung',
+12
View File
@@ -174,6 +174,18 @@ const admin: TranslationStrings = {
'admin.weatherKeyHint': 'For weather data. Free at openweathermap.org',
'admin.unsplashKey': 'Unsplash API Key',
'admin.unsplashKeyHint': 'For image search. Free at unsplash.com/developers',
'admin.amapKey': 'Amap (高德地图) API Key',
'admin.amapKeyHint':
'For place search inside mainland China, where Google is unreachable and OpenStreetMap coverage is thin. Needs a "Web 服务" (web service) key, not a JS API key. Get one at console.amap.com.',
'admin.placesProvider.title': 'Place search provider',
'admin.placesProvider.subtitle':
'Which service answers place search, autocomplete and details. Automatic uses Google when a Google key is configured, then Amap, then OpenStreetMap.',
'admin.placesProvider.auto': 'Automatic',
'admin.placesProvider.google': 'Google Places',
'admin.placesProvider.amap': 'Amap (高德地图)',
'admin.placesProvider.openstreetmap': 'OpenStreetMap',
'admin.placesProvider.missingKey': 'The selected provider has no API key configured, so place search falls back to OpenStreetMap.',
'admin.placesProvider.saved': 'Place search provider saved',
'admin.validateKey': 'Test',
'admin.keyValid': 'Connected',
'admin.keyInvalid': 'Invalid',
+7
View File
@@ -93,6 +93,9 @@ const places: TranslationStrings = {
'places.loadingDetails': 'Loading place details…',
'places.osmHint':
'Using OpenStreetMap search (no photos, opening hours, or ratings). Add a Google API key in settings for full details.',
'places.osmActive': 'Using OpenStreetMap. A Google API key adds ratings and opening hours.',
'places.osmActiveAmap': 'Using OpenStreetMap. An Amap (高德地图) API key adds ratings and opening hours.',
'places.osmActiveAny': 'Using OpenStreetMap. A Google or Amap (高德地图) API key adds ratings and opening hours.',
'places.details.title': 'Place details',
'places.details.empty': 'Pick a result to see more',
'places.details.loading': 'Loading details...',
@@ -105,6 +108,10 @@ const places: TranslationStrings = {
'places.details.adoptBlocked': 'Clear the description field first',
'places.details.facts': 'Good to know',
'places.details.nothing': 'Nothing found for this place.',
'places.details.noKeyTitle': 'More detail is available',
'places.details.noKeyHint': 'Without a Google API key this instance can only use free sources. Ask your TREK administrator to add one for ratings, opening hours and photos.',
'places.details.noKeyHintAmap': 'Without an Amap (高德地图) API key this instance can only use free sources. Ask your TREK administrator to add one for ratings and opening hours.',
'places.details.noKeyHintAny': 'Without a Google or Amap (高德地图) API key this instance can only use free sources. Ask your TREK administrator to add one for ratings and opening hours.',
'places.details.aboutBrand': 'About the chain',
'places.details.aboutBrandNote': 'This describes the chain, not this branch.',
'places.details.fact.rating': 'Rating',
+12
View File
@@ -134,6 +134,18 @@ const admin: TranslationStrings = {
'admin.weatherKeyHint': 'Para datos meteorológicos. Gratis en openweathermap.org',
'admin.unsplashKey': 'Clave de API de Unsplash',
'admin.unsplashKeyHint': 'Para la búsqueda de imágenes. Gratis en unsplash.com/developers',
'admin.amapKey': 'Clave de API de Amap (高德地图)',
'admin.amapKeyHint':
'Para la búsqueda de lugares en China continental, donde Google no es accesible y OpenStreetMap apenas tiene datos. Requiere una clave de tipo «Web 服务» (servicio web), no una clave de la API de JS. Consíguela en console.amap.com.',
'admin.placesProvider.title': 'Proveedor de búsqueda de lugares',
'admin.placesProvider.subtitle':
'Qué servicio responde a la búsqueda de lugares, el autocompletado y los detalles. Automático usa Google si hay una clave de Google configurada, luego Amap y luego OpenStreetMap.',
'admin.placesProvider.auto': 'Automático',
'admin.placesProvider.google': 'Google Places',
'admin.placesProvider.amap': 'Amap (高德地图)',
'admin.placesProvider.openstreetmap': 'OpenStreetMap',
'admin.placesProvider.missingKey': 'El proveedor seleccionado no tiene ninguna clave de API configurada, así que la búsqueda de lugares recurre a OpenStreetMap.',
'admin.placesProvider.saved': 'Proveedor de búsqueda de lugares guardado',
'admin.validateKey': 'Probar',
'admin.keyValid': 'Conectado',
'admin.keyInvalid': 'No válida',
+7
View File
@@ -92,6 +92,9 @@ const places: TranslationStrings = {
'places.loadingDetails': 'Cargando detalles del lugar…',
'places.osmHint':
'Usando búsqueda con OpenStreetMap (sin fotos, horarios ni valoraciones). Añade una clave API de Google en Ajustes para obtener todos los detalles.',
'places.osmActive': 'Se usa OpenStreetMap. Una clave de API de Google añade valoraciones y horarios.',
'places.osmActiveAmap': 'Usando OpenStreetMap. Una clave de API de Amap (高德地图) añade valoraciones y horarios.',
'places.osmActiveAny': 'Usando OpenStreetMap. Una clave de API de Google o de Amap (高德地图) añade valoraciones y horarios.',
'places.details.title': 'Detalles del lugar',
'places.details.empty': 'Elige un resultado para ver más',
'places.details.loading': 'Cargando detalles...',
@@ -104,6 +107,10 @@ const places: TranslationStrings = {
'places.details.adoptBlocked': 'Vacía primero el campo de descripción',
'places.details.facts': 'Bueno saber',
'places.details.nothing': 'No se encontró nada para este lugar.',
'places.details.noKeyTitle': 'Hay más detalles disponibles',
'places.details.noKeyHint': 'Sin una clave de API de Google, esta instancia solo usa fuentes libres. Pide a tu administrador de TREK que añada una para valoraciones, horarios y fotos.',
'places.details.noKeyHintAmap': 'Sin una clave de API de Amap (高德地图) esta instancia solo puede usar fuentes gratuitas. Pide al administrador de TREK que añada una para valoraciones y horarios.',
'places.details.noKeyHintAny': 'Sin una clave de API de Google o de Amap (高德地图) esta instancia solo puede usar fuentes gratuitas. Pide al administrador de TREK que añada una para valoraciones y horarios.',
'places.details.aboutBrand': 'Sobre la cadena',
'places.details.aboutBrandNote': 'Describe la cadena, no este local.',
'places.details.fact.rating': 'Valoración',
+12
View File
@@ -133,6 +133,18 @@ const admin: TranslationStrings = {
'admin.weatherKeyHint': 'Pour les données météo. Gratuit sur openweathermap.org',
'admin.unsplashKey': 'Clé API Unsplash',
'admin.unsplashKeyHint': 'Pour la recherche d’images. Gratuit sur unsplash.com/developers',
'admin.amapKey': 'Clé API Amap (高德地图)',
'admin.amapKeyHint':
'Pour la recherche de lieux en Chine continentale, où Google est inaccessible et où OpenStreetMap contient très peu de données. Nécessite une clé de type « Web 服务 » (service web), pas une clé d’API JS. À obtenir sur console.amap.com.',
'admin.placesProvider.title': 'Fournisseur de recherche de lieux',
'admin.placesProvider.subtitle':
'Quel service répond à la recherche de lieux, à l’autocomplétion et aux détails. Automatique utilise Google si une clé Google est configurée, puis Amap, puis OpenStreetMap.',
'admin.placesProvider.auto': 'Automatique',
'admin.placesProvider.google': 'Google Places',
'admin.placesProvider.amap': 'Amap (高德地图)',
'admin.placesProvider.openstreetmap': 'OpenStreetMap',
'admin.placesProvider.missingKey': 'Aucune clé API n’est configurée pour le fournisseur sélectionné : la recherche de lieux se replie donc sur OpenStreetMap.',
'admin.placesProvider.saved': 'Fournisseur de recherche de lieux enregistré',
'admin.validateKey': 'Tester',
'admin.keyValid': 'Connecté',
'admin.keyInvalid': 'Invalide',
+7
View File
@@ -92,6 +92,9 @@ const places: TranslationStrings = {
'places.loadingDetails': 'Chargement des détails du lieu…',
'places.osmHint':
'Recherche via OpenStreetMap (pas de photos, horaires ni notes). Ajoutez une clé API Google dans les paramètres pour plus de détails.',
'places.osmActive': 'OpenStreetMap est utilisé. Une clé API Google ajoute notes et horaires.',
'places.osmActiveAmap': 'OpenStreetMap est utilisé. Une clé API Amap (高德地图) ajoute notes et horaires d’ouverture.',
'places.osmActiveAny': 'OpenStreetMap est utilisé. Une clé API Google ou Amap (高德地图) ajoute notes et horaires d’ouverture.',
'places.details.title': 'Détails du lieu',
'places.details.empty': 'Choisis un résultat pour en voir plus',
'places.details.loading': 'Chargement des détails...',
@@ -104,6 +107,10 @@ const places: TranslationStrings = {
'places.details.adoptBlocked': "Videz d'abord le champ description",
'places.details.facts': 'Bon à savoir',
'places.details.nothing': 'Rien trouvé pour ce lieu.',
'places.details.noKeyTitle': 'Plus de détails disponibles',
'places.details.noKeyHint': 'Sans clé API Google, cette instance n\'utilise que des sources libres. Demandez à votre administrateur TREK d\'en ajouter une pour les avis, les horaires et les photos.',
'places.details.noKeyHintAmap': 'Sans clé API Amap (高德地图), cette instance ne peut utiliser que des sources gratuites. Demandez à l’administrateur TREK d’en ajouter une pour les notes et les horaires.',
'places.details.noKeyHintAny': 'Sans clé API Google ou Amap (高德地图), cette instance ne peut utiliser que des sources gratuites. Demandez à l’administrateur TREK d’en ajouter une pour les notes et les horaires.',
'places.details.aboutBrand': 'À propos de la chaîne',
'places.details.aboutBrandNote': 'Décrit la chaîne, pas cet établissement.',
'places.details.fact.rating': 'Note',
+12
View File
@@ -179,6 +179,18 @@ const admin: TranslationStrings = {
'admin.weatherKeyHint': 'Για δεδομένα καιρού. Δωρεάν στο openweathermap.org',
'admin.unsplashKey': 'Κλειδί API Unsplash',
'admin.unsplashKeyHint': 'Για αναζήτηση εικόνων. Δωρεάν στο unsplash.com/developers',
'admin.amapKey': 'Κλειδί API του Amap (高德地图)',
'admin.amapKeyHint':
'Για αναζήτηση τοποθεσιών στην ηπειρωτική Κίνα, όπου το Google δεν είναι προσβάσιμο και το OpenStreetMap έχει ελάχιστα δεδομένα. Χρειάζεται κλειδί τύπου «Web 服务» (υπηρεσία web), όχι κλειδί JS API. Διαθέσιμο στο console.amap.com.',
'admin.placesProvider.title': 'Πάροχος αναζήτησης τοποθεσιών',
'admin.placesProvider.subtitle':
'Ποια υπηρεσία απαντά στην αναζήτηση τοποθεσιών, στις προτάσεις και στις λεπτομέρειες. Το «Αυτόματα» χρησιμοποιεί το Google όταν υπάρχει κλειδί Google, μετά το Amap και μετά το OpenStreetMap.',
'admin.placesProvider.auto': 'Αυτόματα',
'admin.placesProvider.google': 'Google Places',
'admin.placesProvider.amap': 'Amap (高德地图)',
'admin.placesProvider.openstreetmap': 'OpenStreetMap',
'admin.placesProvider.missingKey': 'Ο επιλεγμένος πάροχος δεν έχει ρυθμισμένο κλειδί API, οπότε η αναζήτηση τοποθεσιών επιστρέφει στο OpenStreetMap.',
'admin.placesProvider.saved': 'Ο πάροχος αναζήτησης τοποθεσιών αποθηκεύτηκε',
'admin.validateKey': 'Δοκιμή',
'admin.keyValid': 'Συνδέθηκε',
'admin.keyInvalid': 'Μη έγκυρο',
+7
View File
@@ -92,6 +92,9 @@ const places: TranslationStrings = {
'places.loadingDetails': 'Φόρτωση λεπτομερειών μέρους…',
'places.osmHint':
'Χρήση αναζήτησης OpenStreetMap (χωρίς φωτογραφίες, ώρες λειτουργίας ή αξιολογήσεις). Προσθέστε ένα κλειδί Google API στις ρυθμίσεις για πλήρεις λεπτομέρειες.',
'places.osmActive': 'Χρησιμοποιείται το OpenStreetMap. Ένα κλειδί Google API προσθέτει αξιολογήσεις και ωράρια.',
'places.osmActiveAmap': 'Χρησιμοποιείται το OpenStreetMap. Ένα κλειδί API του Amap (高德地图) προσθέτει βαθμολογίες και ώρες λειτουργίας.',
'places.osmActiveAny': 'Χρησιμοποιείται το OpenStreetMap. Ένα κλειδί API Google ή Amap (高德地图) προσθέτει βαθμολογίες και ώρες λειτουργίας.',
'places.details.title': 'Λεπτομέρειες τοποθεσίας',
'places.details.empty': 'Επίλεξε ένα αποτέλεσμα για περισσότερα',
'places.details.loading': 'Φόρτωση λεπτομερειών...',
@@ -104,6 +107,10 @@ const places: TranslationStrings = {
'places.details.adoptBlocked': 'Άδειασε πρώτα το πεδίο περιγραφής',
'places.details.facts': 'Καλό να ξέρεις',
'places.details.nothing': 'Δεν βρέθηκε τίποτα για αυτήν την τοποθεσία.',
'places.details.noKeyTitle': 'Διατίθενται περισσότερες λεπτομέρειες',
'places.details.noKeyHint': 'Χωρίς κλειδί Google API, αυτή η εγκατάσταση χρησιμοποιεί μόνο ελεύθερες πηγές. Ζητήστε από τον διαχειριστή TREK να προσθέσει ένα για βαθμολογίες, ώρες λειτουργίας και φωτογραφίες.',
'places.details.noKeyHintAmap': 'Χωρίς κλειδί API Amap (高德地图) αυτή η εγκατάσταση μπορεί να χρησιμοποιεί μόνο δωρεάν πηγές. Ζητήστε από τον διαχειριστή TREK να προσθέσει ένα για βαθμολογίες και ώρες λειτουργίας.',
'places.details.noKeyHintAny': 'Χωρίς κλειδί API Google ή Amap (高德地图) αυτή η εγκατάσταση μπορεί να χρησιμοποιεί μόνο δωρεάν πηγές. Ζητήστε από τον διαχειριστή TREK να προσθέσει ένα για βαθμολογίες και ώρες λειτουργίας.',
'places.details.aboutBrand': 'Σχετικά με την αλυσίδα',
'places.details.aboutBrandNote': 'Περιγράφει την αλυσίδα, όχι αυτό το κατάστημα.',
'places.details.fact.rating': 'Βαθμολογία',
+12
View File
@@ -133,6 +133,18 @@ const admin: TranslationStrings = {
'admin.weatherKeyHint': 'Időjárás adatokhoz. Ingyenes: openweathermap.org',
'admin.unsplashKey': 'Unsplash API-kulcs',
'admin.unsplashKeyHint': 'Képkereséshez. Ingyenes: unsplash.com/developers',
'admin.amapKey': 'Amap (高德地图) API-kulcs',
'admin.amapKeyHint':
'Helykereséshez Kína szárazföldi részén, ahol a Google nem érhető el, az OpenStreetMap adatai pedig hiányosak. „Web 服务" (webszolgáltatás) típusú kulcs kell hozzá, nem JS API-kulcs. A console.amap.com oldalon kérhető.',
'admin.placesProvider.title': 'Helykeresési szolgáltató',
'admin.placesProvider.subtitle':
'Melyik szolgáltatás válaszol a helykeresésre, a javaslatokra és a részletekre. Az automatikus a Google-t használja, ha van Google-kulcs, utána az Amapot, végül az OpenStreetMapet.',
'admin.placesProvider.auto': 'Automatikus',
'admin.placesProvider.google': 'Google Places',
'admin.placesProvider.amap': 'Amap (高德地图)',
'admin.placesProvider.openstreetmap': 'OpenStreetMap',
'admin.placesProvider.missingKey': 'A kiválasztott szolgáltatóhoz nincs API-kulcs beállítva, ezért a helykeresés az OpenStreetMapre esik vissza.',
'admin.placesProvider.saved': 'Helykeresési szolgáltató elmentve',
'admin.validateKey': 'Teszt',
'admin.keyValid': 'Csatlakozva',
'admin.keyInvalid': 'Érvénytelen',
+7
View File
@@ -92,6 +92,9 @@ const places: TranslationStrings = {
'places.loadingDetails': 'Hely adatainak betöltése…',
'places.osmHint':
'OpenStreetMap keresés aktív (képek, nyitvatartás és értékelések nélkül). Bővített adatokhoz add meg a Google API kulcsot a beállításokban.',
'places.osmActive': 'Az OpenStreetMap van használatban. Egy Google API-kulcs értékeléseket és nyitvatartást ad hozzá.',
'places.osmActiveAmap': 'OpenStreetMap használatban. Egy Amap (高德地图) API-kulcs értékeléseket és nyitvatartást ad.',
'places.osmActiveAny': 'OpenStreetMap használatban. Egy Google vagy Amap (高德地图) API-kulcs értékeléseket és nyitvatartást ad.',
'places.details.title': 'Hely részletei',
'places.details.empty': 'Válassz egy találatot a részletekhez',
'places.details.loading': 'Részletek betöltése...',
@@ -104,6 +107,10 @@ const places: TranslationStrings = {
'places.details.adoptBlocked': 'Előbb ürítsd ki a leírás mezőt',
'places.details.facts': 'Jó tudni',
'places.details.nothing': 'Ehhez a helyhez nem található semmi.',
'places.details.noKeyTitle': 'További részletek érhetők el',
'places.details.noKeyHint': 'Google API-kulcs nélkül ez a példány csak szabad forrásokat használ. Kérd a TREK rendszergazdáját, hogy adjon hozzá egyet az értékelésekhez, nyitvatartáshoz és fotókhoz.',
'places.details.noKeyHintAmap': 'Amap (高德地图) API-kulcs nélkül ez a példány csak ingyenes forrásokat használhat. Kérd a TREK rendszergazdáját, hogy adjon hozzá egyet értékelésekhez és nyitvatartáshoz.',
'places.details.noKeyHintAny': 'Google vagy Amap (高德地图) API-kulcs nélkül ez a példány csak ingyenes forrásokat használhat. Kérd a TREK rendszergazdáját, hogy adjon hozzá egyet értékelésekhez és nyitvatartáshoz.',
'places.details.aboutBrand': 'A láncról',
'places.details.aboutBrandNote': 'A láncot írja le, nem ezt az egységet.',
'places.details.fact.rating': 'Értékelés',
+12
View File
@@ -175,6 +175,18 @@ const admin: TranslationStrings = {
'admin.weatherKeyHint': 'Untuk data cuaca. Gratis di openweathermap.org',
'admin.unsplashKey': 'Kunci API Unsplash',
'admin.unsplashKeyHint': 'Untuk pencarian gambar. Gratis di unsplash.com/developers',
'admin.amapKey': 'Kunci API Amap (高德地图)',
'admin.amapKeyHint':
'Untuk pencarian tempat di Tiongkok daratan, tempat Google tidak dapat diakses dan data OpenStreetMap sangat sedikit. Perlu kunci jenis "Web 服务" (layanan web), bukan kunci JS API. Dapatkan di console.amap.com.',
'admin.placesProvider.title': 'Penyedia pencarian tempat',
'admin.placesProvider.subtitle':
'Layanan mana yang menangani pencarian tempat, saran otomatis, dan detail. Otomatis memakai Google jika kunci Google tersedia, lalu Amap, lalu OpenStreetMap.',
'admin.placesProvider.auto': 'Otomatis',
'admin.placesProvider.google': 'Google Places',
'admin.placesProvider.amap': 'Amap (高德地图)',
'admin.placesProvider.openstreetmap': 'OpenStreetMap',
'admin.placesProvider.missingKey': 'Penyedia yang dipilih belum memiliki kunci API, sehingga pencarian tempat kembali ke OpenStreetMap.',
'admin.placesProvider.saved': 'Penyedia pencarian tempat disimpan',
'admin.validateKey': 'Uji',
'admin.keyValid': 'Terhubung',
'admin.keyInvalid': 'Tidak valid',
+7
View File
@@ -91,6 +91,9 @@ const places: TranslationStrings = {
'places.loadingDetails': 'Memuat detail tempat…',
'places.osmHint':
'Menggunakan pencarian OpenStreetMap (tanpa foto, jam buka, atau penilaian). Tambahkan Google API key di pengaturan untuk detail lengkap.',
'places.osmActive': 'Menggunakan OpenStreetMap. Kunci API Google menambahkan rating dan jam buka.',
'places.osmActiveAmap': 'Menggunakan OpenStreetMap. Kunci API Amap (高德地图) menambahkan rating dan jam buka.',
'places.osmActiveAny': 'Menggunakan OpenStreetMap. Kunci API Google atau Amap (高德地图) menambahkan rating dan jam buka.',
'places.details.title': 'Detail tempat',
'places.details.empty': 'Pilih hasil untuk melihat detail',
'places.details.loading': 'Memuat detail...',
@@ -103,6 +106,10 @@ const places: TranslationStrings = {
'places.details.adoptBlocked': 'Kosongkan dulu kolom deskripsi',
'places.details.facts': 'Perlu diketahui',
'places.details.nothing': 'Tidak ada yang ditemukan untuk tempat ini.',
'places.details.noKeyTitle': 'Tersedia detail lebih lanjut',
'places.details.noKeyHint': 'Tanpa kunci API Google, instans ini hanya memakai sumber bebas. Minta administrator TREK menambahkannya untuk rating, jam buka, dan foto.',
'places.details.noKeyHintAmap': 'Tanpa kunci API Amap (高德地图), instansi ini hanya bisa memakai sumber gratis. Minta administrator TREK menambahkannya untuk rating dan jam buka.',
'places.details.noKeyHintAny': 'Tanpa kunci API Google atau Amap (高德地图), instansi ini hanya bisa memakai sumber gratis. Minta administrator TREK menambahkannya untuk rating dan jam buka.',
'places.details.aboutBrand': 'Tentang jaringan',
'places.details.aboutBrandNote': 'Menjelaskan jaringannya, bukan cabang ini.',
'places.details.fact.rating': 'Peringkat',
+12
View File
@@ -133,6 +133,18 @@ const admin: TranslationStrings = {
'admin.weatherKeyHint': 'Per i dati meteo. Gratuita su openweathermap.org',
'admin.unsplashKey': 'Chiave API Unsplash',
'admin.unsplashKeyHint': 'Per la ricerca di immagini. Gratuita su unsplash.com/developers',
'admin.amapKey': 'Chiave API Amap (高德地图)',
'admin.amapKeyHint':
'Per la ricerca di luoghi nella Cina continentale, dove Google non è raggiungibile e OpenStreetMap ha pochi dati. Serve una chiave di tipo «Web 服务» (servizio web), non una chiave API JS. Si ottiene su console.amap.com.',
'admin.placesProvider.title': 'Provider per la ricerca di luoghi',
'admin.placesProvider.subtitle':
'Quale servizio risponde a ricerca di luoghi, completamento automatico e dettagli. Automatico usa Google se è configurata una chiave Google, poi Amap, poi OpenStreetMap.',
'admin.placesProvider.auto': 'Automatico',
'admin.placesProvider.google': 'Google Places',
'admin.placesProvider.amap': 'Amap (高德地图)',
'admin.placesProvider.openstreetmap': 'OpenStreetMap',
'admin.placesProvider.missingKey': 'Il provider selezionato non ha alcuna chiave API configurata, quindi la ricerca di luoghi ripiega su OpenStreetMap.',
'admin.placesProvider.saved': 'Provider per la ricerca di luoghi salvato',
'admin.validateKey': 'Testa',
'admin.keyValid': 'Connessa',
'admin.keyInvalid': 'Non valida',
+7
View File
@@ -92,6 +92,9 @@ const places: TranslationStrings = {
'places.loadingDetails': 'Caricamento dettagli del luogo…',
'places.osmHint':
'Uso della ricerca OpenStreetMap (senza foto, orari di apertura o valutazioni). Aggiungi una chiave API Google nelle impostazioni per i dettagli completi.',
'places.osmActive': 'Si usa OpenStreetMap. Una chiave API Google aggiunge valutazioni e orari.',
'places.osmActiveAmap': 'Uso OpenStreetMap. Una chiave API Amap (高德地图) aggiunge valutazioni e orari.',
'places.osmActiveAny': 'Uso OpenStreetMap. Una chiave API Google o Amap (高德地图) aggiunge valutazioni e orari.',
'places.details.title': 'Dettagli del luogo',
'places.details.empty': 'Scegli un risultato per saperne di più',
'places.details.loading': 'Caricamento dettagli...',
@@ -104,6 +107,10 @@ const places: TranslationStrings = {
'places.details.adoptBlocked': 'Svuota prima il campo descrizione',
'places.details.facts': 'Da sapere',
'places.details.nothing': 'Nessun risultato per questo luogo.',
'places.details.noKeyTitle': 'Sono disponibili più dettagli',
'places.details.noKeyHint': 'Senza una chiave API di Google questa istanza usa solo fonti libere. Chiedi all\'amministratore di TREK di aggiungerne una per valutazioni, orari e foto.',
'places.details.noKeyHintAmap': 'Senza una chiave API Amap (高德地图) questa istanza può usare solo fonti gratuite. Chiedi all’amministratore TREK di aggiungerne una per valutazioni e orari.',
'places.details.noKeyHintAny': 'Senza una chiave API Google o Amap (高德地图) questa istanza può usare solo fonti gratuite. Chiedi all’amministratore TREK di aggiungerne una per valutazioni e orari.',
'places.details.aboutBrand': 'Sulla catena',
'places.details.aboutBrandNote': 'Descrive la catena, non questa sede.',
'places.details.fact.rating': 'Valutazione',
+12
View File
@@ -167,6 +167,18 @@ const admin: TranslationStrings = {
'admin.weatherKeyHint': '天気データ用。openweathermap.org で無料',
'admin.unsplashKey': 'Unsplash APIキー',
'admin.unsplashKeyHint': '画像検索用。unsplash.com/developers で無料',
'admin.amapKey': 'Amap(高德地图)API キー',
'admin.amapKeyHint':
'Google に接続できず OpenStreetMap のデータも少ない中国本土での地点検索用です。JS API キーではなく「Web 服务」(Web サービス)タイプのキーが必要です。console.amap.com で取得できます。',
'admin.placesProvider.title': '地点検索のプロバイダー',
'admin.placesProvider.subtitle':
'地点検索・入力候補・詳細をどのサービスが担当するか。自動は、Google キーが設定されていれば Google、次に Amap、次に OpenStreetMap を使います。',
'admin.placesProvider.auto': '自動',
'admin.placesProvider.google': 'Google Places',
'admin.placesProvider.amap': 'Amap(高德地图)',
'admin.placesProvider.openstreetmap': 'OpenStreetMap',
'admin.placesProvider.missingKey': '選択したプロバイダーに API キーが設定されていないため、地点検索は OpenStreetMap にフォールバックします。',
'admin.placesProvider.saved': '地点検索のプロバイダーを保存しました',
'admin.validateKey': 'テスト',
'admin.keyValid': '接続済み',
'admin.keyInvalid': '無効',
+7
View File
@@ -92,6 +92,9 @@ const places: TranslationStrings = {
'places.loadingDetails': '詳細を読み込み中…',
'places.osmHint':
'OpenStreetMapで検索しています(写真・営業時間・評価なし)。設定でGoogle APIキーを追加すると詳細が表示されます。',
'places.osmActive': 'OpenStreetMapを使用中。Google APIキーで評価と営業時間が追加されます。',
'places.osmActiveAmap': 'OpenStreetMap を使用中です。Amap(高德地图)の API キーを追加すると評価や営業時間が表示されます。',
'places.osmActiveAny': 'OpenStreetMap を使用中です。Google または Amap(高德地图)の API キーを追加すると評価や営業時間が表示されます。',
'places.details.title': '場所の詳細',
'places.details.empty': '検索結果を選ぶと詳細が表示されます',
'places.details.loading': '詳細を読み込み中...',
@@ -104,6 +107,10 @@ const places: TranslationStrings = {
'places.details.adoptBlocked': '先に説明欄を空にしてください',
'places.details.facts': '知っておくと便利',
'places.details.nothing': 'この場所の情報は見つかりませんでした。',
'places.details.noKeyTitle': 'さらに詳しい情報を表示できます',
'places.details.noKeyHint': 'Google API キーがない場合、このインスタンスは自由に使えるソースのみを利用します。評価・営業時間・写真のために、TREK の管理者にキーの追加を依頼してください。',
'places.details.noKeyHintAmap': 'Amap(高德地图)の API キーがない場合、このサーバーは無料のソースのみ使用できます。評価や営業時間のために TREK 管理者にキーの追加を依頼してください。',
'places.details.noKeyHintAny': 'Google または Amap(高德地图)の API キーがない場合、このサーバーは無料のソースのみ使用できます。評価や営業時間のために TREK 管理者にキーの追加を依頼してください。',
'places.details.aboutBrand': 'チェーンについて',
'places.details.aboutBrandNote': 'この店舗ではなくチェーン全体の説明です。',
'places.details.fact.rating': '評価',
+12
View File
@@ -172,6 +172,18 @@ const admin: TranslationStrings = {
'admin.weatherKeyHint': '날씨 데이터용. openweathermap.org에서 무료 발급',
'admin.unsplashKey': 'Unsplash API 키',
'admin.unsplashKeyHint': '이미지 검색용. unsplash.com/developers에서 무료 발급',
'admin.amapKey': 'Amap(高德地图) API 키',
'admin.amapKeyHint':
'Google에 접속할 수 없고 OpenStreetMap 데이터도 부족한 중국 본토에서 장소를 검색할 때 사용합니다. JS API 키가 아니라 "Web 服务"(웹 서비스) 유형의 키가 필요합니다. console.amap.com에서 발급받을 수 있습니다.',
'admin.placesProvider.title': '장소 검색 제공자',
'admin.placesProvider.subtitle':
'장소 검색, 자동 완성, 상세 정보를 어느 서비스가 처리할지 결정합니다. 자동은 Google 키가 있으면 Google을, 그다음 Amap을, 그다음 OpenStreetMap을 사용합니다.',
'admin.placesProvider.auto': '자동',
'admin.placesProvider.google': 'Google Places',
'admin.placesProvider.amap': 'Amap(高德地图)',
'admin.placesProvider.openstreetmap': 'OpenStreetMap',
'admin.placesProvider.missingKey': '선택한 제공자에 API 키가 설정되어 있지 않아 장소 검색이 OpenStreetMap으로 대체됩니다.',
'admin.placesProvider.saved': '장소 검색 제공자를 저장했습니다',
'admin.validateKey': '테스트',
'admin.keyValid': '연결됨',
'admin.keyInvalid': '유효하지 않음',
+7
View File
@@ -91,6 +91,9 @@ const places: TranslationStrings = {
'places.loadingDetails': '장소 상세 정보 불러오는 중…',
'places.osmHint':
'OpenStreetMap 검색 사용 중 (사진, 영업 시간, 평점 없음). 전체 정보를 위해 설정에서 Google API 키를 추가하세요.',
'places.osmActive': 'OpenStreetMap을 사용 중입니다. Google API 키를 추가하면 평점과 영업시간이 표시됩니다.',
'places.osmActiveAmap': 'OpenStreetMap을 사용하고 있습니다. Amap(高德地图) API 키를 추가하면 평점과 영업시간을 볼 수 있습니다.',
'places.osmActiveAny': 'OpenStreetMap을 사용하고 있습니다. Google 또는 Amap(高德地图) API 키를 추가하면 평점과 영업시간을 볼 수 있습니다.',
'places.details.title': '장소 세부정보',
'places.details.empty': '결과를 선택하면 자세히 볼 수 있어요',
'places.details.loading': '세부정보를 불러오는 중...',
@@ -103,6 +106,10 @@ const places: TranslationStrings = {
'places.details.adoptBlocked': '설명란을 먼저 비워 주세요',
'places.details.facts': '알아두면 좋아요',
'places.details.nothing': '이 장소에 대한 정보를 찾지 못했습니다.',
'places.details.noKeyTitle': '더 자세한 정보를 볼 수 있습니다',
'places.details.noKeyHint': 'Google API 키가 없으면 이 인스턴스는 무료 소스만 사용합니다. 평점, 영업시간, 사진을 보려면 TREK 관리자에게 키 추가를 요청하세요.',
'places.details.noKeyHintAmap': 'Amap(高德地图) API 키가 없으면 이 서버는 무료 출처만 사용할 수 있습니다. 평점과 영업시간을 위해 TREK 관리자에게 키 추가를 요청하세요.',
'places.details.noKeyHintAny': 'Google 또는 Amap(高德地图) API 키가 없으면 이 서버는 무료 출처만 사용할 수 있습니다. 평점과 영업시간을 위해 TREK 관리자에게 키 추가를 요청하세요.',
'places.details.aboutBrand': '체인 소개',
'places.details.aboutBrandNote': '이 지점이 아니라 체인에 대한 설명입니다.',
'places.details.fact.rating': '평점',
+12
View File
@@ -134,6 +134,18 @@ const admin: TranslationStrings = {
'admin.weatherKeyHint': 'Voor weergegevens. Gratis op openweathermap.org',
'admin.unsplashKey': 'Unsplash API-sleutel',
'admin.unsplashKeyHint': 'Voor het zoeken naar afbeeldingen. Gratis op unsplash.com/developers',
'admin.amapKey': 'Amap (高德地图) API-sleutel',
'admin.amapKeyHint':
'Voor het zoeken naar plaatsen in het Chinese vasteland, waar Google onbereikbaar is en OpenStreetMap weinig gegevens heeft. Vereist een sleutel van het type ‘Web 服务’ (webservice), geen JS API-sleutel. Verkrijgbaar op console.amap.com.',
'admin.placesProvider.title': 'Provider voor plaatszoeken',
'admin.placesProvider.subtitle':
'Welke dienst het zoeken naar plaatsen, de suggesties en de details verzorgt. Automatisch gebruikt Google als er een Google-sleutel is ingesteld, daarna Amap, daarna OpenStreetMap.',
'admin.placesProvider.auto': 'Automatisch',
'admin.placesProvider.google': 'Google Places',
'admin.placesProvider.amap': 'Amap (高德地图)',
'admin.placesProvider.openstreetmap': 'OpenStreetMap',
'admin.placesProvider.missingKey': 'Voor de gekozen provider is geen API-sleutel ingesteld, dus het zoeken naar plaatsen valt terug op OpenStreetMap.',
'admin.placesProvider.saved': 'Provider voor plaatszoeken opgeslagen',
'admin.validateKey': 'Testen',
'admin.keyValid': 'Verbonden',
'admin.keyInvalid': 'Ongeldig',
+7
View File
@@ -92,6 +92,9 @@ const places: TranslationStrings = {
'places.loadingDetails': 'Plaatsgegevens laden…',
'places.osmHint':
"Zoeken via OpenStreetMap (geen foto's, openingstijden of beoordelingen). Voeg een Google API-sleutel toe in instellingen voor volledige details.",
'places.osmActive': 'OpenStreetMap wordt gebruikt. Een Google API-sleutel voegt beoordelingen en openingstijden toe.',
'places.osmActiveAmap': 'OpenStreetMap is actief. Een Amap (高德地图) API-sleutel voegt beoordelingen en openingstijden toe.',
'places.osmActiveAny': 'OpenStreetMap is actief. Een Google- of Amap (高德地图) API-sleutel voegt beoordelingen en openingstijden toe.',
'places.details.title': 'Plaatsdetails',
'places.details.empty': 'Kies een resultaat voor meer',
'places.details.loading': 'Details laden...',
@@ -104,6 +107,10 @@ const places: TranslationStrings = {
'places.details.adoptBlocked': 'Maak eerst het beschrijvingsveld leeg',
'places.details.facts': 'Goed om te weten',
'places.details.nothing': 'Niets gevonden voor deze plaats.',
'places.details.noKeyTitle': 'Meer details beschikbaar',
'places.details.noKeyHint': 'Zonder Google API-sleutel gebruikt deze installatie alleen vrije bronnen. Vraag je TREK-beheerder er een toe te voegen voor beoordelingen, openingstijden en foto\'s.',
'places.details.noKeyHintAmap': 'Zonder Amap (高德地图) API-sleutel kan deze instantie alleen gratis bronnen gebruiken. Vraag de TREK-beheerder er een toe te voegen voor beoordelingen en openingstijden.',
'places.details.noKeyHintAny': 'Zonder Google- of Amap (高德地图) API-sleutel kan deze instantie alleen gratis bronnen gebruiken. Vraag de TREK-beheerder er een toe te voegen voor beoordelingen en openingstijden.',
'places.details.aboutBrand': 'Over de keten',
'places.details.aboutBrandNote': 'Beschrijft de keten, niet deze vestiging.',
'places.details.fact.rating': 'Beoordeling',
+12
View File
@@ -125,6 +125,18 @@ const admin: TranslationStrings = {
'admin.weatherKeyHint': 'Do danych pogodowych. Uzyskaj go bezpłatnie na openweathermap.org',
'admin.unsplashKey': 'Klucz API Unsplash',
'admin.unsplashKeyHint': 'Do wyszukiwania obrazów. Bezpłatnie na unsplash.com/developers',
'admin.amapKey': 'Klucz API Amap (高德地图)',
'admin.amapKeyHint':
'Do wyszukiwania miejsc w Chinach kontynentalnych, gdzie Google jest nieosiągalny, a OpenStreetMap ma niewiele danych. Wymaga klucza typu „Web 服务" (usługa sieciowa), a nie klucza JS API. Można go uzyskać na console.amap.com.',
'admin.placesProvider.title': 'Dostawca wyszukiwania miejsc',
'admin.placesProvider.subtitle':
'Która usługa obsługuje wyszukiwanie miejsc, podpowiedzi i szczegóły. Automatycznie używa Google, jeśli ustawiono klucz Google, następnie Amap, a potem OpenStreetMap.',
'admin.placesProvider.auto': 'Automatycznie',
'admin.placesProvider.google': 'Google Places',
'admin.placesProvider.amap': 'Amap (高德地图)',
'admin.placesProvider.openstreetmap': 'OpenStreetMap',
'admin.placesProvider.missingKey': 'Wybrany dostawca nie ma ustawionego klucza API, więc wyszukiwanie miejsc wróci do OpenStreetMap.',
'admin.placesProvider.saved': 'Zapisano dostawcę wyszukiwania miejsc',
'admin.validateKey': 'Testuj',
'admin.keyValid': 'Połączono',
'admin.keyInvalid': 'Niepoprawny',
+7
View File
@@ -82,6 +82,9 @@ const places: TranslationStrings = {
'places.loadingDetails': 'Ładowanie szczegółów miejsca…',
'places.osmHint':
'Korzystając z OpenStreetMap (brak zdjęć, godzin otwarcia czy ocen). Dodaj klucz API Google w ustawieniach aby uzyskać pełne dane.',
'places.osmActive': 'Używany jest OpenStreetMap. Klucz Google API dodaje oceny i godziny otwarcia.',
'places.osmActiveAmap': 'Korzystasz z OpenStreetMap. Klucz API Amap (高德地图) dodaje oceny i godziny otwarcia.',
'places.osmActiveAny': 'Korzystasz z OpenStreetMap. Klucz API Google lub Amap (高德地图) dodaje oceny i godziny otwarcia.',
'places.details.title': 'Szczegóły miejsca',
'places.details.empty': 'Wybierz wynik, aby zobaczyć więcej',
'places.details.loading': 'Wczytywanie szczegółów...',
@@ -94,6 +97,10 @@ const places: TranslationStrings = {
'places.details.adoptBlocked': 'Najpierw wyczyść pole opisu',
'places.details.facts': 'Warto wiedzieć',
'places.details.nothing': 'Nic nie znaleziono dla tego miejsca.',
'places.details.noKeyTitle': 'Dostępnych jest więcej szczegółów',
'places.details.noKeyHint': 'Bez klucza API Google ta instancja korzysta tylko ze źródeł otwartych. Poproś administratora TREK o dodanie klucza, aby uzyskać oceny, godziny otwarcia i zdjęcia.',
'places.details.noKeyHintAmap': 'Bez klucza API Amap (高德地图) ta instancja może korzystać tylko z darmowych źródeł. Poproś administratora TREK o dodanie klucza, aby uzyskać oceny i godziny otwarcia.',
'places.details.noKeyHintAny': 'Bez klucza API Google lub Amap (高德地图) ta instancja może korzystać tylko z darmowych źródeł. Poproś administratora TREK o dodanie klucza, aby uzyskać oceny i godziny otwarcia.',
'places.details.aboutBrand': 'O sieci',
'places.details.aboutBrandNote': 'Opisuje sieć, a nie ten lokal.',
'places.details.fact.rating': 'Ocena',
+12
View File
@@ -134,6 +134,18 @@ const admin: TranslationStrings = {
'admin.weatherKeyHint': 'Для данных о погоде. Бесплатно на openweathermap.org',
'admin.unsplashKey': 'Ключ API Unsplash',
'admin.unsplashKeyHint': 'Для поиска изображений. Бесплатно на unsplash.com/developers',
'admin.amapKey': 'API-ключ Amap (高德地图)',
'admin.amapKeyHint':
'Для поиска мест в континентальном Китае, где Google недоступен, а данных OpenStreetMap мало. Нужен ключ типа «Web 服务» (веб-сервис), а не ключ JS API. Получить можно на console.amap.com.',
'admin.placesProvider.title': 'Провайдер поиска мест',
'admin.placesProvider.subtitle':
'Какой сервис отвечает за поиск мест, подсказки и детали. «Автоматически» использует Google, если задан ключ Google, затем Amap, затем OpenStreetMap.',
'admin.placesProvider.auto': 'Автоматически',
'admin.placesProvider.google': 'Google Places',
'admin.placesProvider.amap': 'Amap (高德地图)',
'admin.placesProvider.openstreetmap': 'OpenStreetMap',
'admin.placesProvider.missingKey': 'Для выбранного провайдера не задан API-ключ, поэтому поиск мест переключается на OpenStreetMap.',
'admin.placesProvider.saved': 'Провайдер поиска мест сохранён',
'admin.validateKey': 'Проверить',
'admin.keyValid': 'Подключено',
'admin.keyInvalid': 'Недействителен',
+7
View File
@@ -92,6 +92,9 @@ const places: TranslationStrings = {
'places.loadingDetails': 'Загрузка данных о месте…',
'places.osmHint':
'Поиск через OpenStreetMap (без фото, часов работы и рейтингов). Добавьте API-ключ Google в настройках для полной информации.',
'places.osmActive': 'Используется OpenStreetMap. Ключ Google API добавит рейтинги и часы работы.',
'places.osmActiveAmap': 'Используется OpenStreetMap. Ключ API Amap (高德地图) добавит оценки и часы работы.',
'places.osmActiveAny': 'Используется OpenStreetMap. Ключ API Google или Amap (高德地图) добавит оценки и часы работы.',
'places.details.title': 'Сведения о месте',
'places.details.empty': 'Выберите результат, чтобы узнать больше',
'places.details.loading': 'Загрузка сведений...',
@@ -104,6 +107,10 @@ const places: TranslationStrings = {
'places.details.adoptBlocked': 'Сначала очистите поле описания',
'places.details.facts': 'Полезно знать',
'places.details.nothing': 'Для этого места ничего не найдено.',
'places.details.noKeyTitle': 'Доступно больше подробностей',
'places.details.noKeyHint': 'Без ключа Google API этот экземпляр использует только свободные источники. Попросите администратора TREK добавить ключ для оценок, часов работы и фотографий.',
'places.details.noKeyHintAmap': 'Без ключа API Amap (高德地图) этот сервер может использовать только бесплатные источники. Попросите администратора TREK добавить ключ — появятся оценки и часы работы.',
'places.details.noKeyHintAny': 'Без ключа API Google или Amap (高德地图) этот сервер может использовать только бесплатные источники. Попросите администратора TREK добавить ключ — появятся оценки и часы работы.',
'places.details.aboutBrand': 'О сети',
'places.details.aboutBrandNote': 'Описывает сеть, а не это заведение.',
'places.details.fact.rating': 'Рейтинг',
+12
View File
@@ -176,6 +176,18 @@ const admin: TranslationStrings = {
'admin.weatherKeyHint': "Hava durumu verileri için. openweathermap.org'da ücretsiz",
'admin.unsplashKey': 'Unsplash API Anahtarı',
'admin.unsplashKeyHint': "Görsel araması için. unsplash.com/developers'da ücretsiz",
'admin.amapKey': 'Amap (高德地图) API Anahtarı',
'admin.amapKeyHint':
'Google’ın erişilemediği ve OpenStreetMap verisinin çok az olduğu Çin anakarasında yer arama için. «Web 服务» (web servisi) türünde bir anahtar gerekir, JS API anahtarı değil. console.amap.com adresinden alınır.',
'admin.placesProvider.title': 'Yer arama sağlayıcısı',
'admin.placesProvider.subtitle':
'Yer aramasını, otomatik tamamlamayı ve ayrıntıları hangi servisin yanıtlayacağı. Otomatik, Google anahtarı varsa Google’ı, sonra Amap’i, sonra OpenStreetMap’i kullanır.',
'admin.placesProvider.auto': 'Otomatik',
'admin.placesProvider.google': 'Google Places',
'admin.placesProvider.amap': 'Amap (高德地图)',
'admin.placesProvider.openstreetmap': 'OpenStreetMap',
'admin.placesProvider.missingKey': 'Seçilen sağlayıcı için API anahtarı ayarlanmamış, bu yüzden yer arama OpenStreetMap’e geri döner.',
'admin.placesProvider.saved': 'Yer arama sağlayıcısı kaydedildi',
'admin.validateKey': 'Test et',
'admin.keyValid': 'Bağlı',
'admin.keyInvalid': 'Geçersiz',
+7
View File
@@ -94,6 +94,9 @@ const places: TranslationStrings = {
'places.loadingDetails': 'Yer ayrıntıları yükleniyor…',
'places.osmHint':
'OpenStreetMap aramasını kullanma (fotoğraf, açılış saatleri veya derecelendirme yok). Tüm ayrıntılar için ayarlara bir Google API anahtarı ekleyin.',
'places.osmActive': 'OpenStreetMap kullanılıyor. Google API anahtarı puan ve çalışma saatleri ekler.',
'places.osmActiveAmap': 'OpenStreetMap kullanılıyor. Bir Amap (高德地图) API anahtarı puanlar ve çalışma saatleri ekler.',
'places.osmActiveAny': 'OpenStreetMap kullanılıyor. Bir Google veya Amap (高德地图) API anahtarı puanlar ve çalışma saatleri ekler.',
'places.details.title': 'Yer ayrıntıları',
'places.details.empty': 'Ayrıntılar için bir sonuç seç',
'places.details.loading': 'Ayrıntılar yükleniyor...',
@@ -106,6 +109,10 @@ const places: TranslationStrings = {
'places.details.adoptBlocked': 'Önce açıklama alanını boşalt',
'places.details.facts': 'Bilmekte fayda var',
'places.details.nothing': 'Bu yer için bir şey bulunamadı.',
'places.details.noKeyTitle': 'Daha fazla ayrıntı mevcut',
'places.details.noKeyHint': 'Google API anahtarı olmadan bu örnek yalnızca özgür kaynakları kullanır. Puanlar, açılış saatleri ve fotoğraflar için TREK yöneticinizden bir anahtar eklemesini isteyin.',
'places.details.noKeyHintAmap': 'Amap (高德地图) API anahtarı olmadan bu sunucu yalnızca ücretsiz kaynakları kullanabilir. Puanlar ve çalışma saatleri için TREK yöneticisinden bir anahtar eklemesini isteyin.',
'places.details.noKeyHintAny': 'Google veya Amap (高德地图) API anahtarı olmadan bu sunucu yalnızca ücretsiz kaynakları kullanabilir. Puanlar ve çalışma saatleri için TREK yöneticisinden bir anahtar eklemesini isteyin.',
'places.details.aboutBrand': 'Zincir hakkında',
'places.details.aboutBrandNote': 'Bu şubeyi değil, zinciri anlatır.',
'places.details.fact.rating': 'Puan',
+12
View File
@@ -134,6 +134,18 @@ const admin: TranslationStrings = {
'admin.weatherKeyHint': 'Для даних про погоду. Безкоштовно на openweathermap.org',
'admin.unsplashKey': 'Ключ API Unsplash',
'admin.unsplashKeyHint': 'Для пошуку зображень. Безкоштовно на unsplash.com/developers',
'admin.amapKey': 'API-ключ Amap (高德地图)',
'admin.amapKeyHint':
'Для пошуку місць у континентальному Китаї, де Google недоступний, а даних OpenStreetMap обмаль. Потрібен ключ типу «Web 服务» (вебсервіс), а не ключ JS API. Отримати можна на console.amap.com.',
'admin.placesProvider.title': 'Постачальник пошуку місць',
'admin.placesProvider.subtitle':
'Яка служба відповідає за пошук місць, підказки та деталі. «Автоматично» використовує Google, якщо задано ключ Google, потім Amap, потім OpenStreetMap.',
'admin.placesProvider.auto': 'Автоматично',
'admin.placesProvider.google': 'Google Places',
'admin.placesProvider.amap': 'Amap (高德地图)',
'admin.placesProvider.openstreetmap': 'OpenStreetMap',
'admin.placesProvider.missingKey': 'Для вибраного постачальника не задано API-ключ, тому пошук місць повертається до OpenStreetMap.',
'admin.placesProvider.saved': 'Постачальника пошуку місць збережено',
'admin.validateKey': 'Перевірити',
'admin.keyValid': 'Підключено',
'admin.keyInvalid': 'Недійсний',
+7
View File
@@ -92,6 +92,9 @@ const places: TranslationStrings = {
'places.loadingDetails': 'Завантаження даних про місце…',
'places.osmHint':
'Пошук через OpenStreetMap (без фото, годин роботи та рейтингів). Додайте API-ключ Google у налаштуваннях для повної інформації.',
'places.osmActive': 'Використовується OpenStreetMap. Ключ Google API додасть рейтинги та години роботи.',
'places.osmActiveAmap': 'Використовується OpenStreetMap. Ключ API Amap (高德地图) додасть оцінки й години роботи.',
'places.osmActiveAny': 'Використовується OpenStreetMap. Ключ API Google або Amap (高德地图) додасть оцінки й години роботи.',
'places.details.title': 'Відомості про місце',
'places.details.empty': 'Виберіть результат, щоб побачити більше',
'places.details.loading': 'Завантаження відомостей...',
@@ -104,6 +107,10 @@ const places: TranslationStrings = {
'places.details.adoptBlocked': 'Спершу очистіть поле опису',
'places.details.facts': 'Корисно знати',
'places.details.nothing': 'Для цього місця нічого не знайдено.',
'places.details.noKeyTitle': 'Доступно більше деталей',
'places.details.noKeyHint': 'Без ключа Google API цей екземпляр використовує лише вільні джерела. Попросіть адміністратора TREK додати ключ, щоб отримати оцінки, години роботи та фото.',
'places.details.noKeyHintAmap': 'Без ключа API Amap (高德地图) цей сервер може використовувати лише безкоштовні джерела. Попросіть адміністратора TREK додати ключ — з’являться оцінки й години роботи.',
'places.details.noKeyHintAny': 'Без ключа API Google або Amap (高德地图) цей сервер може використовувати лише безкоштовні джерела. Попросіть адміністратора TREK додати ключ — з’являться оцінки й години роботи.',
'places.details.aboutBrand': 'Про мережу',
'places.details.aboutBrandNote': 'Описує мережу, а не цей заклад.',
'places.details.fact.rating': 'Рейтинг',
+12
View File
@@ -171,6 +171,18 @@ const admin: TranslationStrings = {
'admin.weatherKeyHint': '用於天氣資料。在 openweathermap.org 免費獲取',
'admin.unsplashKey': 'Unsplash API 金鑰',
'admin.unsplashKeyHint': '用於圖片搜尋。在 unsplash.com/developers 免費獲取',
'admin.amapKey': '高德地圖 API Key',
'admin.amapKeyHint':
'用於中國大陸境內的地點搜尋(Google 無法存取,OpenStreetMap 內容也很少)。需要「Web 服務」類型的 Key,而非 JS API Key。在 console.amap.com 申請。',
'admin.placesProvider.title': '地點搜尋來源',
'admin.placesProvider.subtitle':
'決定由哪個服務處理地點搜尋、輸入提示和詳情。自動:配了 Google Key 就用 Google,否則用高德,都沒有則用 OpenStreetMap。',
'admin.placesProvider.auto': '自動',
'admin.placesProvider.google': 'Google Places',
'admin.placesProvider.amap': '高德地圖',
'admin.placesProvider.openstreetmap': 'OpenStreetMap',
'admin.placesProvider.missingKey': '所選來源尚未設定 API Key,地點搜尋會回退到 OpenStreetMap。',
'admin.placesProvider.saved': '地點搜尋來源已儲存',
'admin.validateKey': '測試',
'admin.keyValid': '已連線',
'admin.keyInvalid': '無效',
+7
View File
@@ -90,6 +90,9 @@ const places: TranslationStrings = {
'places.mapsSearchError': '地點搜尋失敗。',
'places.loadingDetails': '正在載入地點詳情…',
'places.osmHint': '使用 OpenStreetMap 搜尋(無照片、營業時間或評分)。在設定中新增 Google API 金鑰以獲取完整資訊。',
'places.osmActive': '正在使用 OpenStreetMap。加入 Google API 金鑰可取得評分與營業時間。',
'places.osmActiveAmap': '正在使用 OpenStreetMap。新增高德 API 金鑰可獲得評分和營業時間。',
'places.osmActiveAny': '正在使用 OpenStreetMap。新增 Google 或高德 API 金鑰可獲得評分和營業時間。',
'places.details.title': '地點詳細資料',
'places.details.empty': '選擇一個結果查看詳細資訊',
'places.details.loading': '正在載入詳細資料...',
@@ -102,6 +105,10 @@ const places: TranslationStrings = {
'places.details.adoptBlocked': '請先清空描述欄位',
'places.details.facts': '值得一看',
'places.details.nothing': '找不到此地點的資訊。',
'places.details.noKeyTitle': '可取得更多詳細資訊',
'places.details.noKeyHint': '沒有 Google API 金鑰時,此執行個體僅使用免費來源。請聯絡 TREK 管理員新增金鑰,以取得評分、營業時間與照片。',
'places.details.noKeyHintAmap': '沒有高德 API 金鑰時,此實例僅使用免費來源。請聯絡 TREK 管理員新增金鑰,以取得評分和營業時間。',
'places.details.noKeyHintAny': '沒有 Google 或高德 API 金鑰時,此實例僅使用免費來源。請聯絡 TREK 管理員新增金鑰,以取得評分和營業時間。',
'places.details.aboutBrand': '關於該連鎖品牌',
'places.details.aboutBrandNote': '描述的是連鎖品牌,而非本分店。',
'places.details.fact.rating': '評分',
+12
View File
@@ -132,6 +132,18 @@ const admin: TranslationStrings = {
'admin.weatherKeyHint': '用于天气数据。在 openweathermap.org 免费获取',
'admin.unsplashKey': 'Unsplash API 密钥',
'admin.unsplashKeyHint': '用于图片搜索。在 unsplash.com/developers 免费获取',
'admin.amapKey': '高德地图 API Key',
'admin.amapKeyHint':
'用于中国大陆境内的地点搜索(Google 无法访问,OpenStreetMap 内容也很少)。需要「Web 服务」类型的 Key,不是 JS API Key。在 console.amap.com 申请。',
'admin.placesProvider.title': '地点搜索源',
'admin.placesProvider.subtitle':
'决定由哪个服务处理地点搜索、输入提示和详情。自动:配了 Google Key 就用 Google,否则用高德,都没有则用 OpenStreetMap。',
'admin.placesProvider.auto': '自动',
'admin.placesProvider.google': 'Google Places',
'admin.placesProvider.amap': '高德地图',
'admin.placesProvider.openstreetmap': 'OpenStreetMap',
'admin.placesProvider.missingKey': '所选源还没有配置 API Key,地点搜索会回退到 OpenStreetMap。',
'admin.placesProvider.saved': '地点搜索源已保存',
'admin.validateKey': '测试',
'admin.keyValid': '已连接',
'admin.keyInvalid': '无效',
+7
View File
@@ -90,6 +90,9 @@ const places: TranslationStrings = {
'places.mapsSearchError': '地点搜索失败。',
'places.loadingDetails': '正在加载地点详情…',
'places.osmHint': '使用 OpenStreetMap 搜索(无照片、营业时间或评分)。在设置中添加 Google API 密钥以获取完整信息。',
'places.osmActive': '正在使用 OpenStreetMap。添加 Google API 密钥可获得评分和营业时间。',
'places.osmActiveAmap': '正在使用 OpenStreetMap。添加高德 API 密钥可获得评分和营业时间。',
'places.osmActiveAny': '正在使用 OpenStreetMap。添加 Google 或高德 API 密钥可获得评分和营业时间。',
'places.details.title': '地点详情',
'places.details.empty': '选择一个结果查看详情',
'places.details.loading': '正在加载详情...',
@@ -102,6 +105,10 @@ const places: TranslationStrings = {
'places.details.adoptBlocked': '请先清空描述字段',
'places.details.facts': '值得一看',
'places.details.nothing': '未找到该地点的信息。',
'places.details.noKeyTitle': '可获取更多详情',
'places.details.noKeyHint': '没有 Google API 密钥时,此实例仅使用免费来源。请联系 TREK 管理员添加密钥,以获取评分、营业时间和照片。',
'places.details.noKeyHintAmap': '没有高德 API 密钥时,此实例仅使用免费来源。请联系 TREK 管理员添加密钥,以获取评分和营业时间。',
'places.details.noKeyHintAny': '没有 Google 或高德 API 密钥时,此实例仅使用免费来源。请联系 TREK 管理员添加密钥,以获取评分和营业时间。',
'places.details.aboutBrand': '关于该连锁品牌',
'places.details.aboutBrandNote': '描述的是连锁品牌,而非本门店。',
'places.details.fact.rating': '评分',
+4
View File
@@ -68,6 +68,10 @@ export * from './roadtrip/day-boundary.schema';
// has them ready if rich-text input ever ships.
export * from './sanitize/sanitize';
// WGS-84 ⇄ GCJ-02. Shared because the two boundaries that need it sit on
// opposite sides: the Amap provider on the server, the Leaflet CRS on the client.
export * from './geo/gcj02';
// i18n registry (language list + pure helpers — no locale data)
export * from './i18n/languages';
+37
View File
@@ -314,6 +314,43 @@ To get a key: create a free account at [unsplash.com/developers](https://unsplas
---
## Place Search (Amap / 高德地图)
Google Places is unreachable from most networks inside mainland China, and OpenStreetMap's coverage of Chinese POIs —
restaurants, shops, the things a trip is actually made of — is thin and rarely in Chinese. TREK can use
[Amap (高德地图)](https://lbs.amap.com/) instead, as a first-class place provider: search, autocomplete, place details
and reverse geocoding all go through it, and there is an Amap basemap to match (see [[Map Settings|Map-Settings]]).
| Variable | Description | Default |
|-------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|-----------------------------|
| `AMAP_API_KEY` | Amap **Web 服务** (web service) key. When set, it takes priority over any key configured in **Admin → Settings → API Keys**, exactly like `PLACES_API_KEY` does for Google. | unset |
| `AMAP_API_SECRET` | The private secret (数字签名) of that key. **Required if — and only if** the key was created with signing enabled: such a key rejects every unsigned request. Leave unset otherwise. | unset |
| `AMAP_API_BASE` | Send the calls somewhere other than `https://restapi.amap.com` — an egress proxy, a cache, a gateway that holds the credential. The replacement has to speak the same API. Mirrors `PLACES_API_BASE`. | `https://restapi.amap.com` |
**Get a key** at [console.amap.com](https://console.amap.com/dev/key/app): create an application, then add a key of
type **Web 服务**. A **Web 端 (JS API)** key is a different kind of credential and will be rejected with
`INVALID_USER_KEY` — this is the single most common misconfiguration.
**Two ways to configure it** — pick one; the env var wins if both are present:
1. **Environment variable** (this page) — instance-wide, ideal for Docker/Helm where you already manage config as env.
2. **Admin → Settings → API Keys** — paste it into the **高德地图 API Key** field. Stored encrypted at rest.
Setting a key is not enough on its own: **Admin → Settings → API Keys → Place search provider** decides which provider
answers. `Automatic`, the default, keeps Google when a Google key is configured, then falls back to Amap, then to
OpenStreetMap — so adding an Amap key never silently moves an existing install off Google. Choose **Amap** explicitly
to make it the provider.
### Coordinates
Amap speaks **GCJ-02**, the offset datum Chinese law requires published maps to use; everything TREK stores is
**WGS-84**. The conversion happens at the boundary, in both directions, so what lands in the database, in a GPX export
or on a map is always WGS-84 — a place added through Amap and opened in OpenStreetMap later is in the right spot. You
do not need to configure anything for this, but it is worth knowing if you compare raw coordinates against Amap's own
website, which will differ by a few hundred metres.
---
## Storage & Paths
Storage backends and category assignment are configured in
+20
View File
@@ -69,6 +69,26 @@ OpenFreeMap instead of drawing watermarked tiles. Save a key and your template i
Offline pre-download works on OpenFreeMap and on CARTO. It does not work on the OpenStreetMap presets, whose tile
servers do not permit bulk downloading.
## Amap (高德地图) basemap
Two presets — **高德地图 (Amap)** for the road map and **高德卫星 (Amap Satellite)** for imagery. They need no key and
no account, and inside mainland China they are far better than anything else in the list: OpenStreetMap and
OpenFreeMap are sparse there, and the foreign tile CDNs are slow or unreachable.
These tiles are drawn in **GCJ-02**, an offset datum, while every coordinate in TREK is WGS-84. TREK handles that for
you: a map on an Amap basemap switches to a shifted projection, so markers, routes, clusters and the coordinates you
get from clicking the map all stay where they belong. Nothing is stored differently — only the drawing changes.
Two consequences worth knowing:
- The **satellite toggle** follows the basemap. On an Amap basemap it shows Amap imagery rather than Esri's, because
mixing the two datums on one map would move the photo a few hundred metres out from under the markers.
- Amap has **no coverage outside China**. A trip to Lisbon on an Amap basemap gets a nearly empty map, so this is a
choice for instances whose trips are mostly domestic.
For place *search* via Amap — a separate setting from the basemap — see
[[Places and Search|Places-and-Search]] and [[Environment Variables|Environment-Variables]].
## Mapbox GL — access token and style
Enter your **public token** (`pk.*`) from [mapbox.com → Access tokens](https://console.mapbox.com/account/access-tokens/).
+35 -2
View File
@@ -25,7 +25,38 @@ When a key is present, the autocomplete uses the Google Places API, which can re
### Without a Google Maps API key
TREK falls back to OpenStreetMap (Nominatim) automatically — no API key needed. A notice appears above the search box — *Using OpenStreetMap. A Google API key adds ratings and opening hours.* Results include name, address, and coordinates.
TREK falls back to OpenStreetMap (Nominatim) automatically — no API key needed. A notice appears above the search box — *Using OpenStreetMap…* — and what it suggests follows the **Place search provider** setting: a Google key when the provider is Google, an Amap key when it is Amap, either one when the choice is Automatic and neither key is set. When the provider choice is OpenStreetMap the notice stays quiet: that is a decision, not a gap. Results include name, address, and coordinates.
### With an Amap (高德地图) API key
> **Admin:** Set the key in **Admin → Settings → API Keys → 高德地图 API Key**, then pick **Amap** under **Place search
> provider** in the same card. It needs a **Web 服务** (web service) key from
> [console.amap.com](https://console.amap.com/dev/key/app) — a JS API key is a different credential and is rejected.
Google Places is unreachable from most networks inside mainland China, and OpenStreetMap knows very little about
Chinese restaurants, shops and shopping centres. With Amap selected, search, autocomplete, place details and the
reverse geocoding behind right-click-to-add-a-place all go through Amap instead, in Chinese, with ratings, phone
numbers and opening hours where Amap has them.
Amap does not supply place photos here. Its images come with no licence statement TREK could show next to them, so an
Amap place gets its picture from Wikimedia Commons like any other — with the credit and licence attached.
Places remember which provider found them, so switching provider later does not break anything you already saved: an
existing Google place still opens against Google, and vice versa.
You can also paste an Amap share link — `uri.amap.com/marker?…`, `amap.com/place/…`, or a `surl.amap.com` short link —
into the search box, the same way a Google Maps URL works below.
### Which provider answers
**Admin → Settings → API Keys → Place search provider**:
| Setting | What answers place search |
|------------------|-------------------------------------------------------------------------------|
| `Automatic` | Google when a Google key is set, otherwise Amap when an Amap key is, otherwise OpenStreetMap. This is the default and preserves what an existing install already did. |
| `Google Places` | Google. With no Google key, search falls back to OpenStreetMap rather than to Amap. |
| `Amap (高德地图)` | Amap. With no Amap key, search falls back to OpenStreetMap. |
| `OpenStreetMap` | Always OpenStreetMap, ignoring both keys. |
## Place details while searching
@@ -53,10 +84,12 @@ Pictures are copied to your own server and served from there — nothing is load
The column is desktop-only; the mobile place sheet is unchanged.
## Pasting a Google Maps URL
## Pasting a map URL
Paste a `maps.app.goo.gl/…`, `goo.gl/maps/…`, or `maps.google.*/…` URL directly into the search box and press the search button. TREK resolves it server-side and populates the name, address, and coordinates.
Amap links work the same way: `uri.amap.com/marker?…`, `amap.com/place/…` and `surl.amap.com` short links. Their coordinates are converted from GCJ-02 to WGS-84 on the way in, so the place lands where it belongs on every other map.
## Entering coordinates manually
**Paste** a `lat, lng` pair (e.g. `48.8566, 2.3522`) into the **Latitude** field — comma, semicolon or space separated. TREK detects the pair and fills both coordinate fields at once. This works on paste only: the coordinate fields accept digits, a decimal point and a leading minus, so typing a pair by hand drops the separator and leaves a single invalid number (`48.85662.3522`) behind. Type the two values into their own fields instead.