feat: support zero-config LAN access for local development

Auto-rewrite loopback API URLs to current page host in browser
Allow CORS for private LAN IPs when localhost origin is configured
Remove display limit for related Pokémon in detail view
This commit is contained in:
2026-05-13 10:55:31 +08:00
parent 231a7bb313
commit 8628bdf68b
6 changed files with 139 additions and 10 deletions

View File

@@ -60,7 +60,32 @@ function normalizeApiBaseUrl(value: unknown): string | null {
}
function activeApiBaseUrl(): string {
return typeof window === 'undefined' ? serverApiBaseUrl : browserApiBaseUrl;
return typeof window === 'undefined'
? serverApiBaseUrl
: resolveBrowserApiBaseUrl(browserApiBaseUrl);
}
function resolveBrowserApiBaseUrl(value: string): string {
if (typeof window === 'undefined') {
return value;
}
try {
const url = new URL(value, window.location.origin);
if (isLoopbackHost(url.hostname) && !isLoopbackHost(window.location.hostname)) {
url.hostname = window.location.hostname;
return url.toString().replace(/\/+$/, '');
}
} catch {
return value;
}
return value;
}
function isLoopbackHost(hostname: string): boolean {
return ['localhost', '127.0.0.1', '::1', '[::1]'].includes(hostname);
}
export function readStoredLocale(): string {

View File

@@ -45,7 +45,32 @@ function normalizeApiBaseUrl(value: unknown): string | null {
}
function activeApiBaseUrl(): string {
return typeof window === 'undefined' ? serverApiBaseUrl : browserApiBaseUrl;
return typeof window === 'undefined'
? serverApiBaseUrl
: resolveBrowserApiBaseUrl(browserApiBaseUrl);
}
function resolveBrowserApiBaseUrl(value: string): string {
if (typeof window === 'undefined') {
return value;
}
try {
const url = new URL(value, window.location.origin);
if (isLoopbackHost(url.hostname) && !isLoopbackHost(window.location.hostname)) {
url.hostname = window.location.hostname;
return url.toString().replace(/\/+$/, '');
}
} catch {
return value;
}
return value;
}
function isLoopbackHost(hostname: string): boolean {
return ['localhost', '127.0.0.1', '::1', '[::1]'].includes(hostname);
}
function apiUrl(path: string): string {

View File

@@ -39,7 +39,6 @@ const tradingDraftItems = ref<Array<{ itemId: number; preference: TradingPrefere
const tradingActiveItemIndex = ref(0);
const timeOfDays = ['早晨', '中午', '傍晚', '晚上'];
const weathers = ['晴天', '阴天', '雨天'];
const relatedPokemonLimit = 6;
const pokemonDetailRouteNames = new Set(['pokemon-detail', 'pokemon-edit']);
const { data: initialPokemon } = useAsyncData<PokemonDetail | null>(
@@ -333,16 +332,14 @@ const relatedPokemonRows = computed(() => {
const selectedTab = relatedHabitatTab.value || (pokemon.value ? habitatTabValue(pokemon.value.environment.id) : '');
if (selectedTab === 'all') {
return rows.slice(0, relatedPokemonLimit);
return rows;
}
if (pokemon.value && selectedTab === habitatTabValue(pokemon.value.environment.id)) {
return rows
.filter((item) => habitatTabValue(item.environment.id) === selectedTab || item.environment.isOpposite)
.slice(0, relatedPokemonLimit);
return rows.filter((item) => habitatTabValue(item.environment.id) === selectedTab || item.environment.isOpposite);
}
return rows.filter((item) => habitatTabValue(item.environment.id) === selectedTab).slice(0, relatedPokemonLimit);
return rows.filter((item) => habitatTabValue(item.environment.id) === selectedTab);
});
const typeSlotClass = computed(() => ({
'pokemon-type-slots--single': (pokemon.value?.types.length ?? 0) === 1