Files
short-drama-agent-front/src/lib/http.ts
T

95 lines
3.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/** 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 : '操作失败,请刷新后重试。'
}