From 2a58a57a7cac1c7aa7f234aaa495b1c9dd88e976 Mon Sep 17 00:00:00 2001 From: imfangwenjie <13213138+imfangwenjie@users.noreply.github.com> Date: Tue, 23 Jun 2026 19:40:07 +0800 Subject: [PATCH] fix(frontend): use configured API base for direct requests Route direct fetch calls, setup requests, gateway usage checks, and ops WebSocket connections through the configured API base instead of assuming same-origin backend paths. Also derive OAuth callback suggestions from the configured API base so split frontend/API deployments show usable redirect URLs. --- frontend/src/api/admin/ops.ts | 9 ++--- frontend/src/api/client.ts | 8 ++--- frontend/src/api/setup.ts | 3 +- frontend/src/api/url.ts | 33 +++++++++++++++++ .../components/account/AccountTestModal.vue | 5 +-- .../admin/account/AccountTestModal.vue | 5 +-- frontend/src/i18n/locales/en.ts | 2 +- frontend/src/i18n/locales/zh.ts | 2 +- frontend/src/views/KeyUsageView.vue | 3 +- frontend/src/views/admin/SettingsView.vue | 36 ++++++------------- frontend/src/views/setup/SetupWizardView.vue | 3 +- frontend/src/views/user/CustomPageView.vue | 5 +-- frontend/src/views/user/StripePopupView.vue | 3 +- 13 files changed, 72 insertions(+), 45 deletions(-) create mode 100644 frontend/src/api/url.ts diff --git a/frontend/src/api/admin/ops.ts b/frontend/src/api/admin/ops.ts index dd39426b80..ba021f4f85 100644 --- a/frontend/src/api/admin/ops.ts +++ b/frontend/src/api/admin/ops.ts @@ -4,7 +4,7 @@ * - Dashboard overview (raw path) */ -import { apiClient } from '../client' +import { apiClient, buildGatewayUrl } from '../client' import type { PaginatedResponse } from '@/types' export type OpsQueryMode = 'auto' | 'raw' | 'preagg' @@ -593,9 +593,10 @@ export function subscribeQPS(onMessage: (data: any) => void, options: SubscribeQ isConnecting = true setStatus(hasConnectedOnce ? 'reconnecting' : 'connecting') - const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:' - const wsBaseUrl = options.wsBaseUrl || import.meta.env.VITE_WS_BASE_URL || window.location.host - const wsURL = new URL(`${protocol}//${wsBaseUrl}/api/v1/admin/ops/ws/qps`) + const wsBaseUrl = options.wsBaseUrl || import.meta.env.VITE_WS_BASE_URL + const wsURL = wsBaseUrl + ? new URL(`${window.location.protocol === 'https:' ? 'wss:' : 'ws:'}//${wsBaseUrl}/api/v1/admin/ops/ws/qps`) + : new URL(buildGatewayUrl('/api/v1/admin/ops/ws/qps').replace(/^http/, 'ws')) // Do NOT put admin JWT in the URL query string (it can leak via access logs, proxies, etc). // Browsers cannot set Authorization headers for WebSockets, so we pass the token via diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 1ba3c0676a..c1b9abcd5e 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -6,13 +6,13 @@ import axios, { AxiosInstance, AxiosError, InternalAxiosRequestConfig, AxiosResponse } from 'axios' import type { ApiResponse } from '@/types' import { getLocale } from '@/i18n' +import { getAPIBaseURL } from './url' +export { buildApiUrl, buildGatewayUrl } from './url' // ==================== Axios Instance Configuration ==================== -const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || '/api/v1' - export const apiClient: AxiosInstance = axios.create({ - baseURL: API_BASE_URL, + baseURL: getAPIBaseURL(), withCredentials: true, timeout: 30000, headers: { @@ -203,7 +203,7 @@ apiClient.interceptors.response.use( try { // Call refresh endpoint directly to avoid circular dependency const refreshResponse = await axios.post( - `${API_BASE_URL}/auth/refresh`, + `${getAPIBaseURL()}/auth/refresh`, { refresh_token: refreshToken }, { headers: { 'Content-Type': 'application/json' } } ) diff --git a/frontend/src/api/setup.ts b/frontend/src/api/setup.ts index 1097c95bb1..70a8a92403 100644 --- a/frontend/src/api/setup.ts +++ b/frontend/src/api/setup.ts @@ -2,10 +2,11 @@ * Setup API endpoints */ import axios from 'axios' +import { buildGatewayUrl } from './url' // Create a separate client for setup endpoints (not under /api/v1) const setupClient = axios.create({ - baseURL: '', + baseURL: buildGatewayUrl('/').replace(/\/+$/, ''), timeout: 30000, headers: { 'Content-Type': 'application/json' diff --git a/frontend/src/api/url.ts b/frontend/src/api/url.ts new file mode 100644 index 0000000000..97f93654b7 --- /dev/null +++ b/frontend/src/api/url.ts @@ -0,0 +1,33 @@ +const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || '/api/v1' + +function normalizePath(path: string): string { + return path.startsWith('/') ? path : `/${path}` +} + +export function getAPIBaseURL(): string { + return String(API_BASE_URL || '/api/v1') +} + +export function buildApiUrl(path: string): string { + const base = getAPIBaseURL().replace(/\/+$/, '') + let suffix = normalizePath(path) + if (suffix === '/api/v1') { + suffix = '' + } else if (suffix.startsWith('/api/v1/')) { + suffix = suffix.slice('/api/v1'.length) + } + return `${base}${suffix}` +} + +export function buildGatewayUrl(path: string): string { + const suffix = normalizePath(path) + try { + const origin = + typeof window === 'undefined' + ? new URL(getAPIBaseURL()).origin + : new URL(getAPIBaseURL(), window.location.origin).origin + return `${origin}${suffix}` + } catch { + return suffix + } +} diff --git a/frontend/src/components/account/AccountTestModal.vue b/frontend/src/components/account/AccountTestModal.vue index f0359f23fc..5192d0b667 100644 --- a/frontend/src/components/account/AccountTestModal.vue +++ b/frontend/src/components/account/AccountTestModal.vue @@ -249,6 +249,7 @@ import Select from '@/components/common/Select.vue' import TextArea from '@/components/common/TextArea.vue' import { Icon } from '@/components/icons' import { useClipboard } from '@/composables/useClipboard' +import { buildApiUrl } from '@/api/client' import { adminAPI } from '@/api/admin' import type { Account, ClaudeModel } from '@/types' @@ -417,8 +418,8 @@ const startTest = async () => { abortController = new AbortController() try { - // Create EventSource for SSE - const url = `/api/v1/admin/accounts/${props.account.id}/test` + // Use the configured API base; EventSource does not support POST. + const url = buildApiUrl(`/admin/accounts/${props.account.id}/test`) // Use fetch with streaming for SSE since EventSource doesn't support POST const response = await fetch(url, { diff --git a/frontend/src/components/admin/account/AccountTestModal.vue b/frontend/src/components/admin/account/AccountTestModal.vue index 86354906d5..ca9d06ee1e 100644 --- a/frontend/src/components/admin/account/AccountTestModal.vue +++ b/frontend/src/components/admin/account/AccountTestModal.vue @@ -238,6 +238,7 @@ import Select from '@/components/common/Select.vue' import TextArea from '@/components/common/TextArea.vue' import { Icon } from '@/components/icons' import { useClipboard } from '@/composables/useClipboard' +import { buildApiUrl } from '@/api/client' import { adminAPI } from '@/api/admin' import type { Account, ClaudeModel } from '@/types' @@ -399,8 +400,8 @@ const startTest = async () => { abortController = new AbortController() try { - // Create EventSource for SSE - const url = `/api/v1/admin/accounts/${props.account.id}/test` + // Use the configured API base; EventSource does not support POST. + const url = buildApiUrl(`/admin/accounts/${props.account.id}/test`) // Use fetch with streaming for SSE since EventSource doesn't support POST const response = await fetch(url, { diff --git a/frontend/src/i18n/locales/en.ts b/frontend/src/i18n/locales/en.ts index 0ac93a9811..757c683ecc 100644 --- a/frontend/src/i18n/locales/en.ts +++ b/frontend/src/i18n/locales/en.ts @@ -5825,7 +5825,7 @@ export default { apiBaseUrl: 'API Base URL', apiBaseUrlPlaceholder: 'https://api.example.com', apiBaseUrlHint: - 'Used for "Use Key" and "Import to CC Switch" features. Leave empty to use current site URL.', + 'Used for "Use Key", "Import to CC Switch", and callback URL suggestions. Leave empty to use current site URL.', tablePreferencesTitle: 'Global Table Preferences', tablePreferencesDescription: 'Configure default pagination behavior for shared table components', tableDefaultPageSize: 'Default Rows Per Page', diff --git a/frontend/src/i18n/locales/zh.ts b/frontend/src/i18n/locales/zh.ts index ec9c51a171..a11e6a99b2 100644 --- a/frontend/src/i18n/locales/zh.ts +++ b/frontend/src/i18n/locales/zh.ts @@ -5977,7 +5977,7 @@ export default { siteSubtitleHint: '显示在登录和注册页面', siteSubtitlePlaceholder: '订阅转 API 转换平台', apiBaseUrl: 'API 端点地址', - apiBaseUrlHint: '用于"使用密钥"和"导入到 CC Switch"功能,留空则使用当前站点地址', + apiBaseUrlHint: '用于"使用密钥"、"导入到 CC Switch"和回调地址建议,留空则使用当前站点地址', apiBaseUrlPlaceholder: 'https://api.example.com', tablePreferencesTitle: '通用表格设置', tablePreferencesDescription: '设置后台与用户侧表格组件的默认分页行为', diff --git a/frontend/src/views/KeyUsageView.vue b/frontend/src/views/KeyUsageView.vue index c3a303d453..8bc429ca3d 100644 --- a/frontend/src/views/KeyUsageView.vue +++ b/frontend/src/views/KeyUsageView.vue @@ -422,6 +422,7 @@ import { useI18n } from 'vue-i18n' import { useAppStore } from '@/stores' import LocaleSwitcher from '@/components/common/LocaleSwitcher.vue' import Icon from '@/components/icons/Icon.vue' +import { buildGatewayUrl } from '@/api/client' const { t, locale } = useI18n() const appStore = useAppStore() @@ -856,7 +857,7 @@ function getBrowserTimezone(): string { async function fetchUsage(key: string) { const dateParams = getDateParams() - const url = '/v1/usage' + (dateParams ? '?' + dateParams : '') + const url = buildGatewayUrl('/v1/usage') + (dateParams ? '?' + dateParams : '') const res = await fetch(url, { headers: { 'Authorization': 'Bearer ' + key }, }) diff --git a/frontend/src/views/admin/SettingsView.vue b/frontend/src/views/admin/SettingsView.vue index 5ae9869f0d..f3e58ae1aa 100644 --- a/frontend/src/views/admin/SettingsView.vue +++ b/frontend/src/views/admin/SettingsView.vue @@ -8192,13 +8192,15 @@ const addQuotaNotifyEmail = () => { const currentOrigin = typeof window !== "undefined" ? window.location.origin : ""; +function buildApiCallbackUrl(path: string): string { + const base = (form.api_base_url || currentOrigin).replace(/\/+$/, ""); + const apiRoot = base.endsWith("/api/v1") ? base : `${base}/api/v1`; + return `${apiRoot}${path.startsWith("/") ? path : `/${path}`}`; +} + // LinuxDo OAuth redirect URL suggestion const linuxdoRedirectUrlSuggestion = computed(() => { - if (typeof window === "undefined") return ""; - const origin = - window.location.origin || - `${window.location.protocol}//${window.location.host}`; - return `${origin}/api/v1/auth/oauth/linuxdo/callback`; + return buildApiCallbackUrl("/auth/oauth/linuxdo/callback"); }); async function setAndCopyLinuxdoRedirectUrl() { @@ -8215,19 +8217,11 @@ async function setAndCopyLinuxdoRedirectUrl() { type EmailOAuthProvider = "github" | "google"; const githubOAuthRedirectUrlSuggestion = computed(() => { - if (typeof window === "undefined") return ""; - const origin = - window.location.origin || - `${window.location.protocol}//${window.location.host}`; - return `${origin}/api/v1/auth/oauth/github/callback`; + return buildApiCallbackUrl("/auth/oauth/github/callback"); }); const googleOAuthRedirectUrlSuggestion = computed(() => { - if (typeof window === "undefined") return ""; - const origin = - window.location.origin || - `${window.location.protocol}//${window.location.host}`; - return `${origin}/api/v1/auth/oauth/google/callback`; + return buildApiCallbackUrl("/auth/oauth/google/callback"); }); async function setAndCopyEmailOAuthRedirectUrl(provider: EmailOAuthProvider) { @@ -8249,11 +8243,7 @@ async function setAndCopyEmailOAuthRedirectUrl(provider: EmailOAuthProvider) { } const wechatRedirectUrlSuggestion = computed(() => { - if (typeof window === "undefined") return ""; - const origin = - window.location.origin || - `${window.location.protocol}//${window.location.host}`; - return `${origin}/api/v1/auth/oauth/wechat/callback`; + return buildApiCallbackUrl("/auth/oauth/wechat/callback"); }); function syncWeChatConnectMode(preferredMode?: WeChatConnectMode) { @@ -8318,11 +8308,7 @@ async function setAndCopyWeChatRedirectUrl() { } const oidcRedirectUrlSuggestion = computed(() => { - if (typeof window === "undefined") return ""; - const origin = - window.location.origin || - `${window.location.protocol}//${window.location.host}`; - return `${origin}/api/v1/auth/oauth/oidc/callback`; + return buildApiCallbackUrl("/auth/oauth/oidc/callback"); }); async function setAndCopyOIDCRedirectUrl() { diff --git a/frontend/src/views/setup/SetupWizardView.vue b/frontend/src/views/setup/SetupWizardView.vue index 5774899e89..2679c4d4fc 100644 --- a/frontend/src/views/setup/SetupWizardView.vue +++ b/frontend/src/views/setup/SetupWizardView.vue @@ -493,6 +493,7 @@ import { ref, reactive, computed } from 'vue' import { useI18n } from 'vue-i18n' import { testDatabase, testRedis, install, type InstallRequest } from '@/api/setup' +import { buildGatewayUrl } from '@/api/client' import Select from '@/components/common/Select.vue' import Toggle from '@/components/common/Toggle.vue' import Icon from '@/components/icons/Icon.vue' @@ -644,7 +645,7 @@ async function waitForServiceRestart() { try { // Use setup status endpoint as it tells us the real mode // Service might return 404 or connection refused while restarting - const response = await fetch('/setup/status', { + const response = await fetch(buildGatewayUrl('/setup/status'), { method: 'GET', cache: 'no-store' }) diff --git a/frontend/src/views/user/CustomPageView.vue b/frontend/src/views/user/CustomPageView.vue index 59764c631a..4a52029537 100644 --- a/frontend/src/views/user/CustomPageView.vue +++ b/frontend/src/views/user/CustomPageView.vue @@ -124,6 +124,7 @@ import { useAuthStore } from '@/stores/auth' import { useAdminSettingsStore } from '@/stores/adminSettings' import AppLayout from '@/components/layout/AppLayout.vue' import Icon from '@/components/icons/Icon.vue' +import { buildApiUrl } from '@/api/client' import { buildEmbeddedUrl, detectTheme } from '@/utils/embedded-url' import { marked } from 'marked' import DOMPurify from 'dompurify' @@ -217,7 +218,7 @@ function buildPageImageUrl(slug: string, src: string): string { .filter((part) => part && part !== '.') .map((part) => encodeURIComponent(part)) .join('/') - return `/api/v1/pages/${encodeURIComponent(slug)}/images/${encodedPath}${suffix}` + return buildApiUrl(`/pages/${encodeURIComponent(slug)}/images/${encodedPath}${suffix}`) } async function fetchAndRenderMarkdown(slug: string) { @@ -225,7 +226,7 @@ async function fetchAndRenderMarkdown(slug: string) { tocItems.value = [] activeHeadingId.value = '' try { - const resp = await fetch(`/api/v1/pages/${encodeURIComponent(slug)}`, { + const resp = await fetch(buildApiUrl(`/pages/${encodeURIComponent(slug)}`), { headers: authStore.token ? { Authorization: `Bearer ${authStore.token}` } : {}, }) if (!resp.ok) { diff --git a/frontend/src/views/user/StripePopupView.vue b/frontend/src/views/user/StripePopupView.vue index 688ad6444d..063101cccb 100644 --- a/frontend/src/views/user/StripePopupView.vue +++ b/frontend/src/views/user/StripePopupView.vue @@ -58,6 +58,7 @@ import { useI18n } from 'vue-i18n' import { useRoute } from 'vue-router' import { extractI18nErrorMessage } from '@/utils/apiError' import { isMobileDevice } from '@/utils/device' +import { buildApiUrl } from '@/api/client' interface StripeWithWechatPay { confirmWechatPayPayment(clientSecret: string, options: Record): Promise<{ error?: { message?: string }; paymentIntent?: { status: string } }> @@ -152,7 +153,7 @@ function startPolling() { try { const token = document.cookie.split('; ').find(c => c.startsWith('token='))?.split('=')[1] || localStorage.getItem('token') || '' - const res = await fetch('/api/v1/payment/orders/' + orderId, { + const res = await fetch(buildApiUrl(`/payment/orders/${orderId}`), { headers: token ? { Authorization: 'Bearer ' + token } : {}, credentials: 'include', })