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