diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index f9430ad..32bda97 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -3,8 +3,7 @@ */ import { useAuthStore } from '@/stores/auth' import { pinia } from '@/stores' - -const API_BASE = (import.meta.env.VITE_API_BASE_URL as string | undefined) ?? '/api/v1' +import { fetchApi } from '@/config/apiBase' // 后端返回的错误消息中英映射 const MESSAGE_MAP: Record = { @@ -57,21 +56,22 @@ async function handleResponse(response: Response): Promise { /** GET 请求 */ export async function apiGet(path: string, params?: Record): Promise { - let url = `${API_BASE}${path}` + let requestPath = path if (params) { const searchParams = new URLSearchParams() for (const [key, value] of Object.entries(params)) { searchParams.set(key, String(value)) } - url += `?${searchParams.toString()}` + const separator = requestPath.includes('?') ? '&' : '?' + requestPath += `${separator}${searchParams.toString()}` } - const response = await fetch(url, { method: 'GET', headers: getAuthHeaders() }) + const response = await fetchApi(requestPath, { method: 'GET', headers: getAuthHeaders() }) return handleResponse(response) } /** POST 请求 */ export async function apiPost(path: string, body?: unknown): Promise { - const response = await fetch(`${API_BASE}${path}`, { + const response = await fetchApi(path, { method: 'POST', headers: getAuthHeaders(), body: body !== undefined ? JSON.stringify(body) : undefined, @@ -81,7 +81,7 @@ export async function apiPost(path: string, body?: unknown): Promise { /** PATCH 请求 */ export async function apiPatch(path: string, body: unknown): Promise { - const response = await fetch(`${API_BASE}${path}`, { + const response = await fetchApi(path, { method: 'PATCH', headers: getAuthHeaders(), body: JSON.stringify(body), @@ -91,7 +91,7 @@ export async function apiPatch(path: string, body: unknown): Promise { /** DELETE 请求 */ export async function apiDelete(path: string): Promise { - const response = await fetch(`${API_BASE}${path}`, { + const response = await fetchApi(path, { method: 'DELETE', headers: getAuthHeaders(), }) diff --git a/frontend/src/auth.api.ts b/frontend/src/auth.api.ts index 1fb0cf9..03d4bd0 100644 --- a/frontend/src/auth.api.ts +++ b/frontend/src/auth.api.ts @@ -5,8 +5,7 @@ import type { MessageResponse, RegisterPayload, } from './auth.types' - -const API_BASE_URL = (import.meta.env.VITE_API_BASE_URL as string | undefined) ?? '/api/v1' +import { fetchApi } from '@/config/apiBase' type JsonValue = object | null @@ -40,7 +39,7 @@ function localizeDetail(detail: string): string { } async function request(path: string, payload: JsonValue): Promise { - const response = await fetch(`${API_BASE_URL}${path}`, { + const response = await fetchApi(path, { method: 'POST', headers: { 'Content-Type': 'application/json', diff --git a/frontend/src/config/apiBase.ts b/frontend/src/config/apiBase.ts new file mode 100644 index 0000000..a6d5d50 --- /dev/null +++ b/frontend/src/config/apiBase.ts @@ -0,0 +1,116 @@ +const API_PREFIX = '/api/v1' +const LAN_BACKEND_ORIGIN = 'http://10.252.130.135:8000' +const PUBLIC_BACKEND_ORIGIN = 'http://47.107.130.88:51290' +const PROBE_TIMEOUT_MS = 1200 + +const LAN_API_BASE_URL = `${LAN_BACKEND_ORIGIN}${API_PREFIX}` +const PUBLIC_API_BASE_URL = `${PUBLIC_BACKEND_ORIGIN}${API_PREFIX}` +const ENV_API_BASE_URL = import.meta.env.VITE_API_BASE_URL as string | undefined + +const EXPECTED_OPENAPI_PATHS = ['/api/v1/auth/login', '/api/v1/events/unified'] + +let detectedApiBaseUrl: string | null = ENV_API_BASE_URL ?? null +let detectPromise: Promise | null = null + +function normalizePath(path: string): string { + if (!path) return '/' + return path.startsWith('/') ? path : `/${path}` +} + +function buildUrl(base: string, path: string): string { + return `${base}${normalizePath(path)}` +} + +function isPrivateIpv4(hostname: string): boolean { + const parts = hostname.split('.').map((part) => Number.parseInt(part, 10)) + if (parts.length !== 4 || parts.some((part) => Number.isNaN(part) || part < 0 || part > 255)) { + return false + } + + const a = parts[0] as number + const b = parts[1] as number + if (a === 10) return true + if (a === 172 && b >= 16 && b <= 31) return true + if (a === 192 && b === 168) return true + if (a === 127) return true + return false +} + +function isLanHostname(hostname: string): boolean { + const normalized = hostname.toLowerCase() + if (normalized === 'localhost' || normalized.endsWith('.local')) return true + return isPrivateIpv4(normalized) +} + +async function probeLanBackend(): Promise { + if (typeof window === 'undefined') return false + + const controller = new AbortController() + const timeout = window.setTimeout(() => controller.abort(), PROBE_TIMEOUT_MS) + try { + const response = await fetch(`${LAN_BACKEND_ORIGIN}/openapi.json`, { + method: 'GET', + cache: 'no-store', + signal: controller.signal, + }) + if (!response.ok) return false + + const data = (await response.json()) as { paths?: Record } + const paths = data.paths + if (!paths || typeof paths !== 'object') return false + + return EXPECTED_OPENAPI_PATHS.every((path) => + Object.prototype.hasOwnProperty.call(paths, path), + ) + } catch { + return false + } finally { + window.clearTimeout(timeout) + } +} + +async function detectApiBaseUrl(): Promise { + if (ENV_API_BASE_URL) return ENV_API_BASE_URL + if (typeof window === 'undefined') return PUBLIC_API_BASE_URL + + if (!isLanHostname(window.location.hostname)) { + return PUBLIC_API_BASE_URL + } + + const canUseLan = await probeLanBackend() + return canUseLan ? LAN_API_BASE_URL : PUBLIC_API_BASE_URL +} + +function isLikelyNetworkError(error: unknown): boolean { + return error instanceof TypeError || (error instanceof DOMException && error.name === 'AbortError') +} + +export async function getApiBaseUrl(): Promise { + if (detectedApiBaseUrl) return detectedApiBaseUrl + if (!detectPromise) { + detectPromise = detectApiBaseUrl() + .then((url) => { + detectedApiBaseUrl = url + return url + }) + .finally(() => { + detectPromise = null + }) + } + return detectPromise +} + +export async function fetchApi(path: string, init?: RequestInit): Promise { + const apiBaseUrl = await getApiBaseUrl() + const requestUrl = buildUrl(apiBaseUrl, path) + + try { + return await fetch(requestUrl, init) + } catch (error) { + if (!ENV_API_BASE_URL && apiBaseUrl === LAN_API_BASE_URL && isLikelyNetworkError(error)) { + detectedApiBaseUrl = PUBLIC_API_BASE_URL + return fetch(buildUrl(PUBLIC_API_BASE_URL, path), init) + } + throw error + } +} diff --git a/frontend/src/views/RevisionsView.vue b/frontend/src/views/RevisionsView.vue index 676ac2e..0263cc6 100644 --- a/frontend/src/views/RevisionsView.vue +++ b/frontend/src/views/RevisionsView.vue @@ -34,7 +34,6 @@ const platformIconMap: Record = { 抖音热榜: 'fa-brands fa-tiktok', 抖音: 'fa-brands fa-tiktok', B站热搜: 'fa-brands fa-bilibili', - 'B站热搜': 'fa-brands fa-bilibili', 'bilibili 热搜': 'fa-brands fa-bilibili', 华尔街见闻: 'fa-solid fa-chart-line', 澎湃新闻: 'fa-solid fa-water',