export interface ApiErrorShape { code: string; message: string; details?: unknown; } export interface ApiErrorBody { error: ApiErrorShape; } export class ApiError extends Error { status: number; code: string; details?: unknown; constructor(status: number, code: string, message: string, details?: unknown) { super(message); this.name = 'ApiError'; this.status = status; this.code = code; this.details = details; } } interface Options { method?: string; body?: unknown; signal?: AbortSignal; } export async function apiFetch(url: string, options: Options = {}): Promise { const headers: Record = { accept: 'application/json' }; let bodyStr: string | undefined; if (options.body !== undefined) { headers['content-type'] = 'application/json'; bodyStr = JSON.stringify(options.body); } let res: Response; try { res = await fetch(url, { method: options.method ?? 'GET', headers, body: bodyStr, credentials: 'same-origin', signal: options.signal }); } catch (e) { if (e instanceof DOMException && e.name === 'AbortError') throw e; throw new ApiError(0, 'network', '网络请求失败,请确认服务已启动后重试'); } if (res.status === 204) { return undefined as R; } const contentType = res.headers.get('content-type') ?? ''; if (!contentType.includes('application/json')) { if (!res.ok) { throw new ApiError(res.status, 'server_error', `服务返回错误(HTTP ${res.status})`); } throw new ApiError(res.status, 'bad_response', '服务返回了非预期的非 JSON 响应(接口约定为 JSON)'); } const parsed: unknown = await res.json().catch(() => null); if (!res.ok) { const errBody = parsed as ApiErrorBody | null; if (errBody?.error) { throw new ApiError(res.status, errBody.error.code, errBody.error.message, errBody.error.details); } throw new ApiError(res.status, 'server_error', `服务返回错误(HTTP ${res.status})`); } return parsed as R; }