95 lines
3.9 KiB
TypeScript
95 lines
3.9 KiB
TypeScript
/** 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<string, unknown> {
|
||
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
||
}
|
||
|
||
/** 兼容普通 {data} 响应,以及 POST /projects 的顶层 202 响应。 */
|
||
export async function request<T>(path: string, options: RequestOptions = {}): Promise<T> {
|
||
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<T>(promise: Promise<T>): Promise<T | null> {
|
||
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 : '操作失败,请刷新后重试。'
|
||
}
|