feat: 实现剧本创作与拆解前端工作台
This commit is contained in:
@@ -0,0 +1,91 @@
|
||||
/** 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'
|
||||
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 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 ? {} : { 'Content-Type': 'application/json' })
|
||||
},
|
||||
body: options.body === undefined ? undefined : JSON.stringify(options.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 : '操作失败,请刷新后重试。'
|
||||
}
|
||||
Reference in New Issue
Block a user