Merge pull request #3437 from imfangwenjie/fix/api-base-fetch

fix(frontend): use configured API base for direct requests
This commit is contained in:
Wesley Liddick
2026-06-29 09:23:13 +08:00
committed by GitHub
13 changed files with 72 additions and 45 deletions
+5 -4
View File
@@ -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
+4 -4
View File
@@ -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' } }
)
+2 -1
View File
@@ -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'
+33
View File
@@ -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
}
}
@@ -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, {
@@ -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, {
+1 -1
View File
@@ -5915,7 +5915,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',
+1 -1
View File
@@ -6067,7 +6067,7 @@ export default {
siteSubtitleHint: '显示在登录和注册页面',
siteSubtitlePlaceholder: '订阅转 API 转换平台',
apiBaseUrl: 'API 端点地址',
apiBaseUrlHint: '用于"使用密钥"和"导入到 CC Switch"功能,留空则使用当前站点地址',
apiBaseUrlHint: '用于"使用密钥"、"导入到 CC Switch"和回调地址建议,留空则使用当前站点地址',
apiBaseUrlPlaceholder: 'https://api.example.com',
tablePreferencesTitle: '通用表格设置',
tablePreferencesDescription: '设置后台与用户侧表格组件的默认分页行为',
+2 -1
View File
@@ -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 },
})
+11 -25
View File
@@ -8441,13 +8441,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() {
@@ -8464,19 +8466,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) {
@@ -8498,11 +8492,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) {
@@ -8567,11 +8557,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() {
+2 -1
View File
@@ -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'
})
+3 -2
View File
@@ -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) {
+2 -1
View File
@@ -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<string, unknown>): 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',
})