feat: 实现剧本创作与拆解前端工作台

This commit is contained in:
GouJ
2026-08-27 19:33:51 +08:00
parent edc003443f
commit 2ee6f049f6
56 changed files with 7597 additions and 2 deletions
+91
View File
@@ -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 : '操作失败,请刷新后重试。'
}