/** HTTP 错误携带状态码与后端细节,供页面区别空数据、校验错误与网络故障。 */ export class ApiError extends Error { constructor( message: string, public readonly status: number, public readonly details?: unknown ) { super(message) this.name = 'ApiError' } } /** 请求选项;长工作流显式关闭超时,不自动重试任何 POST。 */ export interface RequestOptions { method?: 'GET' | 'POST' | 'PUT' | 'DELETE' body?: unknown signal?: AbortSignal timeoutMs?: number } /** 判断可安全访问键的 JSON 对象。 */ function isRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null && !Array.isArray(value) } /** 兼容普通 {data} 响应,以及 POST /projects 的顶层 202 响应。 */ export async function request(path: string, options: RequestOptions = {}): Promise { const base = (import.meta.env.VITE_API_BASE_URL || '/api').replace(/\/$/, '') const multipart = typeof FormData !== 'undefined' && options.body instanceof FormData const body: BodyInit | undefined = options.body === undefined ? undefined : multipart ? (options.body as FormData) : JSON.stringify(options.body) const controller = new AbortController() const timeoutMs = options.timeoutMs ?? 30_000 const timer = timeoutMs > 0 ? setTimeout(() => controller.abort(), timeoutMs) : undefined const abort = () => controller.abort() options.signal?.addEventListener('abort', abort, { once: true }) if (options.signal?.aborted) controller.abort() try { const response = await fetch(base + path, { method: options.method ?? 'GET', headers: { Accept: 'application/json', ...(options.body === undefined || multipart ? {} : { 'Content-Type': 'application/json' }) }, body, signal: controller.signal }) const text = await response.text() let data: unknown try { data = text ? JSON.parse(text) : undefined } catch { throw new ApiError('接口未返回 JSON,请检查 API 地址与代理配置。', response.status) } if (!response.ok) { const message = isRecord(data) && typeof data.message === 'string' ? data.message : `请求失败(${response.status})` throw new ApiError( message, response.status, isRecord(data) ? (data.details ?? data.issues ?? data.error) : data ) } return (isRecord(data) && 'data' in data ? data.data : data) as T } catch (error) { if (error instanceof ApiError) throw error if (options.signal?.aborted) throw error if (controller.signal.aborted) throw new ApiError('请求等待超时。后台任务可能仍在运行,请先刷新状态,不要重复提交。', 0) throw new ApiError('无法连接后端。请检查服务与代理;已提交的任务可能仍在运行。', 0) } finally { clearTimeout(timer) options.signal?.removeEventListener('abort', abort) } } /** 仅允许指定的“尚无 checkpoint”404 视为缺省,不吞掉 500 或网络错误。 */ export async function optionalResource(promise: Promise): Promise { try { return await promise } catch (error) { if (error instanceof ApiError && error.status === 404) return null throw error } } /** 页面统一显示可操作的错误信息。 */ export function errorMessage(error: unknown): string { if (error instanceof ApiError && error.details) { const details = typeof error.details === 'string' ? error.details : JSON.stringify(error.details) return `${error.message} ${details}` } return error instanceof Error ? error.message : '操作失败,请刷新后重试。' }