export type ApiSession = { baseUrl: string token: string } export type CurrentUser = { email: string displayName: string } type RequestOptions = { method?: string body?: unknown token?: string responseType?: 'json' | 'void' signal?: AbortSignal } type ErrorEnvelope = { error: { code: string message: string } } export class ApiError extends Error { status: number code: string constructor(status: number, code: string, message: string) { super(message) this.name = 'ApiError' this.status = status this.code = code } } let configuredBaseUrl = normalizeBaseUrl(import.meta.env.VITE_API_BASE_URL ?? 'http://localhost:9150') export function setApiBaseUrl(baseUrl: string) { configuredBaseUrl = normalizeBaseUrl(baseUrl) } export function getApiBaseUrl() { return configuredBaseUrl } export async function login(email: string, password: string): Promise { const response = await apiRequest<{ token: string; user: CurrentUser }>('/api/v1/auth/login', { method: 'POST', body: { email, password }, }) return { baseUrl: configuredBaseUrl, token: response.token, user: response.user } } export function updateCurrentUser( session: ApiSession, input: { displayName: string; currentPassword?: string; newPassword?: string }, ) { return apiRequest('/api/v1/auth/me', { method: 'PATCH', token: session.token, body: input }) } export async function apiRequest(path: string, options: RequestOptions = {}): Promise { const isFormData = typeof FormData !== 'undefined' && options.body instanceof FormData const requestBody = options.body === undefined ? undefined : isFormData ? options.body : JSON.stringify(options.body) let response: Response try { response = await fetch(`${configuredBaseUrl}${path}`, { method: options.method ?? 'GET', headers: { ...(options.body !== undefined && !isFormData ? { 'Content-Type': 'application/json' } : {}), ...(options.token ? { Authorization: `Bearer ${options.token}` } : {}), }, body: requestBody as BodyInit | undefined, signal: options.signal, }) } catch { throw new ApiError(0, 'network_error', '无法连接服务器,请检查地址和网络后重试') } if (!response.ok) { let envelope: ErrorEnvelope | null = null try { const payload: unknown = await response.json() if (isErrorEnvelope(payload)) envelope = payload } catch { // 非 JSON 响应统一使用状态码文案,避免把服务端内部信息展示给用户。 } if (envelope) throw new ApiError(response.status, envelope.error.code, envelope.error.message) throw new ApiError(response.status, fallbackErrorCode(response.status), fallbackErrorMessage(response.status)) } if (response.status === 204 || options.responseType === 'void') { return undefined as T } let text: string try { text = await response.text() } catch { throw invalidResponse(response.status) } if (text.trim() === '') { throw invalidResponse(response.status) } try { return JSON.parse(text) as T } catch { throw invalidResponse(response.status) } } function invalidResponse(status: number) { return new ApiError(status, 'invalid_response', '服务器返回了无法识别的数据') } function isErrorEnvelope(value: unknown): value is ErrorEnvelope { if (!value || typeof value !== 'object' || !('error' in value)) return false const error = value.error return ( !!error && typeof error === 'object' && 'code' in error && typeof error.code === 'string' && error.code.trim() !== '' && 'message' in error && typeof error.message === 'string' && error.message.trim() !== '' ) } function fallbackErrorCode(status: number) { if (status === 401) return 'unauthorized' if (status === 403) return 'forbidden' if (status === 404) return 'not_found' if (status === 409) return 'conflict' if (status >= 500) return 'internal_error' return 'request_failed' } function fallbackErrorMessage(status: number) { if (status === 401) return '登录已失效,请重新登录' if (status === 403) return '没有权限执行此操作' if (status === 404) return '请求的内容不存在' if (status === 409) return '操作冲突,请刷新后重试' if (status >= 500) return '服务器暂时无法处理请求,请稍后重试' return '请求失败,请稍后重试' } export function normalizeBaseUrl(value: string) { const normalized = value.trim().replace(/\/+$/, '') if (!normalized || /^https?:\/\//i.test(normalized)) return normalized return `http://${normalized}` }