export type ApiClientOptions = { baseUrl?: string; /** * Optional auth token provider. * If present, request will add `Authorization: Bearer `. */ getToken?: () => string | null | undefined; }; export type AbpErrorPayload = { error?: { code?: string; message?: string; details?: string; validationErrors?: { message?: string; members?: string[] }[]; }; }; type WrappedResponse = { data?: T; succeeded?: boolean; statusCode?: number; errors?: unknown; extras?: unknown; timestamp?: unknown; }; export class ApiError extends Error { status: number; payload?: unknown; constructor(message: string, status: number, payload?: unknown) { super(message); this.name = "ApiError"; this.status = status; this.payload = payload; } } function joinUrl(baseUrl: string, path: string): string { if (!baseUrl) return path; const b = baseUrl.endsWith("/") ? baseUrl.slice(0, -1) : baseUrl; const p = path.startsWith("/") ? path : `/${path}`; return `${b}${p}`; } function toQueryString(params: Record): string { const qs = new URLSearchParams(); for (const [k, v] of Object.entries(params)) { if (v === undefined || v === null || v === "") continue; if (typeof v === "boolean") { qs.set(k, v ? "true" : "false"); continue; } qs.set(k, String(v)); } const s = qs.toString(); return s ? `?${s}` : ""; } function getAbpErrorMessage(payload: unknown): string | null { const p = payload as AbpErrorPayload | null | undefined; const msg = p?.error?.message?.trim(); if (msg) return msg; return null; } function normalizePagedResultShape(x: unknown): unknown { // Some APIs return the list directly in `data` (no items/totalCount). if (Array.isArray(x)) { return { items: x, totalCount: x.length }; } if (!x || typeof x !== "object") return x; const o = x as Record; // 兼容 ABP/PagedResultDto 的两种命名:TotalCount/Items vs totalCount/items const hasUpper = "TotalCount" in o || "Items" in o; const hasLower = "totalCount" in o || "items" in o; if (hasUpper && !hasLower) { return { ...o, totalCount: o.TotalCount, items: o.Items, }; } // 兼容部分接口返回:{ data: [...], totalCount: number } if (!("items" in o) && Array.isArray(o.data) && typeof o.totalCount === "number") { return { ...o, items: o.data, }; } // 兼容部分接口返回:{ Data: [...], TotalCount: number } if (!("items" in o) && Array.isArray((o as any).Data) && typeof (o as any).TotalCount === "number") { return { ...o, totalCount: (o as any).TotalCount, items: (o as any).Data, }; } // 有 items 但 totalCount 缺失时,避免分页总数恒为 0 if (Array.isArray(o.items) && typeof o.totalCount !== "number") { const tc = o.TotalCount; return { ...o, totalCount: typeof tc === "number" ? tc : o.items.length, }; } return x; } export function createApiClient(opts: ApiClientOptions = {}) { const baseUrl = opts.baseUrl ?? import.meta.env.VITE_API_BASE_URL ?? "http://localhost:19001"; const getToken = opts.getToken; async function requestJson(input: { path: string; method: "GET" | "POST" | "PUT" | "DELETE"; query?: Record; body?: unknown; signal?: AbortSignal; /** * ABP conventional controller usually uses `api/app`. * Keep it configurable per call. */ prefix?: string; }): Promise { const prefix = input.prefix ?? "/api/app"; const url = joinUrl(baseUrl, `${prefix}${input.path}${toQueryString(input.query ?? {})}`); const headers: Record = { "Content-Type": "application/json", }; const token = getToken?.(); if (token) headers.Authorization = `Bearer ${token}`; const res = await fetch(url, { method: input.method, headers, body: input.body === undefined ? undefined : JSON.stringify(input.body), signal: input.signal, }); const contentType = res.headers.get("content-type") ?? ""; const isJson = contentType.includes("application/json"); const payload = isJson ? await res.json().catch(() => null) : await res.text().catch(() => ""); if (!res.ok) { const abpMsg = getAbpErrorMessage(payload); const msg = abpMsg ?? (typeof payload === "string" && payload.trim() ? payload : "Request failed."); throw new ApiError(msg, res.status, payload); } // 部分宿主会把真实返回包在 { data, succeeded, statusCode, ... } 中(如抓包所示)。 // 为了不污染各业务 service,这里统一做一次解包:优先返回 data。 if (payload && typeof payload === "object" && "data" in (payload as Record)) { const wrapped = payload as WrappedResponse; return normalizePagedResultShape(wrapped.data ?? null) as T; } return normalizePagedResultShape(payload) as T; } return { requestJson }; }