feat(api-environment): add API environment selection component and related functionality

This commit is contained in:
RainbowBird
2026-07-01 01:08:21 +08:00
parent 71482d3859
commit f9f20b88ff
4 changed files with 128 additions and 2 deletions
+5 -1
View File
@@ -6,13 +6,16 @@ import { computed, onMounted, shallowRef } from 'vue'
import { RouterLink, RouterView, useRoute } from 'vue-router'
import { Toaster } from 'vue-sonner'
import { adminApi, AdminApiError, signInUrl } from './modules/api'
import ApiEnvironmentSelect from './components/admin-shell/ApiEnvironmentSelect.vue'
import { adminApi, AdminApiError, apiServerUrl, signInUrl } from './modules/api'
const route = useRoute()
const loading = shallowRef(true)
const me = shallowRef<AdminMe | null>(null)
const accessError = shallowRef<string | null>(null)
const currentApiServerUrl = apiServerUrl()
const navItems = [
{ to: '/', icon: 'i-lucide-layout-dashboard', label: 'Overview' },
@@ -138,6 +141,7 @@ onMounted(async () => {
{{ currentTitle }}
</h1>
</div>
<ApiEnvironmentSelect :api-server-url="currentApiServerUrl" />
</header>
<div class="admin-content">
<RouterView />
@@ -0,0 +1,60 @@
<script setup lang="ts">
import { computed } from 'vue'
import { ADMIN_API_ENVIRONMENTS, apiEnvironmentValueFor, buildApiServerSwitchUrl } from '../../modules/server-admin-context'
const props = defineProps<{
apiServerUrl: string
}>()
const selectedEnvironment = computed(() => apiEnvironmentValueFor(props.apiServerUrl))
function switchEnvironment(event: Event) {
const target = event.target
if (!(target instanceof HTMLSelectElement))
return
if (target.value === selectedEnvironment.value)
return
window.location.assign(buildApiServerSwitchUrl(window.location.href, target.value))
}
</script>
<template>
<label :class="['flex', 'items-center', 'gap-2', 'text-xs', 'text-neutral-500', 'dark:text-neutral-400']">
<span :class="['i-lucide-server', 'h-4', 'w-4', 'shrink-0']" />
<span :class="['sr-only']">API environment</span>
<select
:class="[
'h-8',
'max-w-[180px]',
'rounded-md',
'border',
'border-neutral-200',
'bg-white',
'px-2',
'text-xs',
'font-medium',
'text-neutral-700',
'outline-none',
'dark:border-neutral-800',
'dark:bg-neutral-950',
'dark:text-neutral-200',
'focus:border-emerald-500',
'focus:ring-3',
'focus:ring-emerald-500/14',
]"
:value="selectedEnvironment"
@change="switchEnvironment"
>
<option
v-for="environment in ADMIN_API_ENVIRONMENTS"
:key="environment.value"
:value="environment.value"
>
{{ environment.label }} - {{ environment.description }}
</option>
</select>
</label>
</template>
@@ -2,7 +2,7 @@
import { describe, expect, it } from 'vitest'
import { defaultStandaloneApiServerUrl, getServerAdminBootstrapContext, resolveStandaloneServerAdminContext } from './server-admin-context'
import { ADMIN_API_ENVIRONMENTS, apiEnvironmentValueFor, buildApiServerSwitchUrl, defaultStandaloneApiServerUrl, getServerAdminBootstrapContext, resolveStandaloneServerAdminContext } from './server-admin-context'
describe('ui-admin bootstrap context', () => {
it('uses the trusted API server origin carried by standalone server redirects', () => {
@@ -40,6 +40,25 @@ describe('ui-admin bootstrap context', () => {
expect(defaultStandaloneApiServerUrl('https://server-dev.airi-server-admin.pages.dev')).toBe('https://airi-server-dev.up.railway.app')
})
it('exposes production, testing, and local API environment choices', () => {
expect(ADMIN_API_ENVIRONMENTS.map(environment => environment.value)).toEqual([
'https://api.airi.build',
'https://airi-server-dev.up.railway.app',
'http://localhost:3000',
])
})
it('maps custom localhost API origins to the local environment option', () => {
expect(apiEnvironmentValueFor('http://127.0.0.1:8787')).toBe('http://localhost:3000')
})
it('builds a switched admin URL without dropping the current page state', () => {
expect(buildApiServerSwitchUrl(
'https://admin.airi.build/voice-packs/new?draft=1#advanced',
'https://airi-server-dev.up.railway.app/api/admin',
)).toBe('https://admin.airi.build/voice-packs/new?draft=1&api_server_url=https%3A%2F%2Fairi-server-dev.up.railway.app#advanced')
})
it('falls back to the standalone query context when the static placeholder script is still present', () => {
document.body.innerHTML = '<script id="airi-server-admin-context" type="application/json">__AIRI_SERVER_ADMIN_CONTEXT__</script>'
window.history.replaceState(
@@ -3,9 +3,33 @@ export interface ServerAdminBootstrapContext {
currentUrl: string
}
export interface AdminApiEnvironment {
label: string
value: string
description: string
}
const SCRIPT_ID = 'airi-server-admin-context'
const API_SERVER_URL_QUERY_PARAM = 'api_server_url'
export const ADMIN_API_ENVIRONMENTS = [
{
label: 'Production',
value: 'https://api.airi.build',
description: 'api.airi.build',
},
{
label: 'Testing',
value: 'https://airi-server-dev.up.railway.app',
description: 'airi-server-dev.up.railway.app',
},
{
label: 'Local',
value: 'http://localhost:3000',
description: 'localhost:3000',
},
] as const satisfies readonly AdminApiEnvironment[]
const TRUSTED_STANDALONE_API_SERVER_ORIGINS = [
'https://api.airi.build',
'https://airi-server-dev.up.railway.app',
@@ -107,6 +131,25 @@ export function resolveStandaloneServerAdminContext(currentUrl: string): ServerA
}
}
export function apiEnvironmentValueFor(apiServerUrl: string): string {
const origin = new URL(apiServerUrl).origin
const known = ADMIN_API_ENVIRONMENTS.find(environment => environment.value === origin)
if (known)
return known.value
if (TRUSTED_LOCAL_API_SERVER_ORIGIN_PATTERNS.some(pattern => pattern.test(origin)))
return ADMIN_API_ENVIRONMENTS.find(environment => environment.label === 'Local')?.value ?? origin
return origin
}
export function buildApiServerSwitchUrl(currentUrl: string, apiServerUrl: string): string {
const url = new URL(currentUrl)
const origin = new URL(apiServerUrl).origin
url.searchParams.set(API_SERVER_URL_QUERY_PARAM, origin)
return url.toString()
}
function normalizeTrustedApiServerUrl(value: string | null): string | null {
if (!value)
return null