import { useUserStore } from '@/store'; import { getToken, clearToken } from './storage'; import i18n from '@/i18n'; const API_PREFIX = '/api/v1'; // 后端统一响应格式 export interface ApiResponse { code: number; message: string; data: T; } // 列表响应格式 export interface ListResponse { list: T[]; total: number; } type Json = unknown; /** 统一 API 根路径,避免 get/post 与 download/upload 行为不一致 // Logan.Li */ export function getApiBase(): string { const envBase = import.meta.env.VITE_API_BASE_URL; if (envBase) { const normalized = String(envBase).replace(/\/$/, ''); return normalized.endsWith(API_PREFIX) ? normalized : `${normalized}${API_PREFIX}`; } return API_PREFIX; } /** 对外展示的 API 根路径(curl/CI 文档),无环境变量时拼接当前浏览器域名 // Logan.Li */ export function getPublicApiBase(): string { const base = getApiBase(); if (/^https?:\/\//i.test(base)) return base; return `${window.location.origin}${base}`; } /** 拼接 REST API 完整路径(含 /api/v1 前缀) // Logan.Li */ export function buildApiUrl(path: string, params?: URLSearchParams): string { const p = path.startsWith('/') ? path : `/${path}`; let url = `${getApiBase()}${p}`; const qs = params?.toString(); if (qs) url += `?${qs}`; return url; } function resolveApiUrl(path: string): string { return buildApiUrl(path); } /** 获取 WebSocket 根路径(不含 /api/v1) // Logan.Li */ export function getWsBase(): string { const envBase = import.meta.env.VITE_API_BASE_URL; if (envBase) { const origin = String(envBase).replace(/\/$/, '').replace(/\/api\/v1$/, ''); return origin.replace(/^http/, 'ws'); } const proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; return `${proto}//${window.location.host}`; } export function getAuthToken(): string { return getToken(); } // eslint-disable-next-line @typescript-eslint/no-explicit-any async function request( method: string, url: string, body?: Json, params?: Record, ): Promise { let fullUrl = resolveApiUrl(url); if (params) { const searchParams = new URLSearchParams(); Object.entries(params).forEach(([key, value]) => { if (value !== undefined && value !== null && value !== '') { searchParams.append(key, String(value)); } }); const qs = searchParams.toString(); if (qs) fullUrl += '?' + qs; } const token = getAuthToken(); const headers: Record = { 'Content-Type': 'application/json', }; if (token) { headers.Authorization = 'Bearer ' + token; } const response = await fetch(fullUrl, { method, headers, body: body ? JSON.stringify(body) : undefined, }); if (response.status === 401) { clearToken(); useUserStore.getState().logout(); window.location.href = '/login'; throw new Error(i18n.t('common.request.tokenExpired')); } let json: ApiResponse; try { json = await response.json(); } catch { throw new Error(`HTTP ${response.status}: ${i18n.t('common.responseParseError')}`); } if (!response.ok || (json.code && json.code !== 200)) { const msg = json.message || `HTTP ${response.status}`; throw new Error(msg); } return (json.data !== undefined ? json.data : json) as T; } export const http = { // eslint-disable-next-line @typescript-eslint/no-explicit-any get: (url: string, params?: Record) => request('GET', url, undefined, params), // eslint-disable-next-line @typescript-eslint/no-explicit-any post: (url: string, body?: Json) => request('POST', url, body), // eslint-disable-next-line @typescript-eslint/no-explicit-any put: (url: string, body?: Json) => request('PUT', url, body), // eslint-disable-next-line @typescript-eslint/no-explicit-any delete: (url: string, body?: Json) => request('DELETE', url, body), getRaw: async (url: string): Promise => { const resp = await fetch(resolveApiUrl(url), { headers: { Authorization: 'Bearer ' + getAuthToken() }, }); if (!resp.ok) throw new Error('HTTP ' + resp.status); return resp.text(); }, putRaw: async (url: string, body: string, contentType: string): Promise => { const resp = await fetch(resolveApiUrl(url), { method: 'PUT', headers: { Authorization: 'Bearer ' + getAuthToken(), 'Content-Type': contentType, }, body, }); if (!resp.ok) throw new Error('HTTP ' + resp.status); return resp.json(); }, download: async (url: string, filename?: string): Promise => { const resp = await fetch(resolveApiUrl(url), { headers: { Authorization: 'Bearer ' + getAuthToken() }, }); if (!resp.ok) { const ct = resp.headers.get('Content-Type') || ''; if (ct.includes('json')) { try { const json = await resp.json() as { message?: string; Message?: string }; throw new Error(json.message || json.Message || `HTTP ${resp.status}`); } catch (e) { if (e instanceof Error && !e.message.startsWith('HTTP ')) throw e; } } throw new Error(`HTTP ${resp.status}`); } const blob = await resp.blob(); const a = document.createElement('a'); a.href = URL.createObjectURL(blob); const disp = resp.headers.get('Content-Disposition') || ''; const m = disp.match(/filename="?(.+?)"?$/); a.download = filename || (m ? m[1] : 'download'); document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(a.href); }, // eslint-disable-next-line @typescript-eslint/no-explicit-any upload: async (url: string, formData: FormData): Promise => { const resp = await fetch(resolveApiUrl(url), { method: 'POST', headers: { Authorization: 'Bearer ' + getAuthToken() }, body: formData, }); if (!resp.ok) throw new Error('HTTP ' + resp.status); return resp.json(); }, }; export default http;