/** * 统一错误信息提取 * 把 axios 错误(后端 detail / FastAPI 校验错误 / HTTP 状态码)、XHR/OSS 错误、 * 网络/超时错误、普通 Error 统一转成「可直接展示给用户」的中文信息。 * * 与 api/client.ts 响应拦截器的提示口径保持一致;拦截器负责全局 toast, * 页面/队列卡片用本工具把真实原因展示在持久位置(回调页、失败卡片等)。 */ import type { AxiosError } from "axios" /** 后端错误响应体可能出现的字段(FastAPI:detail;历史接口:message/msg) */ interface ErrorBody { detail?: unknown message?: unknown msg?: unknown } /** FastAPI 422 校验错误单项 */ interface ValidationItem { loc?: (string | number)[] msg?: string } /** 从后端响应体提取人类可读信息(detail 可能是字符串、对象、422 数组) */ function extractBodyMessage(data: unknown): string { if (!data || typeof data !== "object") return "" const body = data as ErrorBody const walk = (val: unknown): string => { if (typeof val === "string") return val if (Array.isArray(val)) { // FastAPI 422: [{loc, msg, type}, ...] → 取每条 msg 拼接 const parts = val .map((item) => { if (typeof item === "string") return item if (item && typeof item === "object") { const v = item as ValidationItem if (typeof v.msg === "string") { const field = Array.isArray(v.loc) ? v.loc.filter((x) => x !== "body").join(".") : "" return field ? `${field}: ${v.msg}` : v.msg } return walk(item) } return "" }) .filter(Boolean) return parts.join(";") } if (val && typeof val === "object") { const obj = val as Record if (typeof obj.message === "string") return obj.message if (typeof obj.msg === "string") return obj.msg if (typeof obj.detail === "string") return obj.detail if (obj.message && typeof obj.message === "object") return walk(obj.message) if (obj.msg && typeof obj.msg === "object") return walk(obj.msg) try { return JSON.stringify(val) } catch { return "" } } return "" } return walk(body.detail) || walk(body.message) || walk(body.msg) } /** 无响应体时按 HTTP 状态码给出兜底提示(与 client.ts 拦截器口径一致) */ function statusFallback(status: number): string { switch (status) { case 400: return "请求参数有误(HTTP 400)" case 401: return "登录状态已失效,请重新登录(HTTP 401)" case 403: return "没有权限执行该操作(HTTP 403)" case 404: return "请求的资源不存在(HTTP 404)" case 409: return "操作冲突,资源状态已变化(HTTP 409)" case 413: return "文件过大,请缩小后重试(HTTP 413)" case 415: return "不支持的文件格式(HTTP 415)" case 429: return "操作过于频繁,请稍后再试(HTTP 429)" case 503: return "服务暂不可用,请稍后再试(HTTP 503)" default: if (status >= 500) return `服务器繁忙,请稍后再试(HTTP ${status})` return `请求失败(HTTP ${status})` } } /** * 从任意抛出值提取可展示的错误信息。 * @param fallback 全部提取失败时的兜底文案 */ export function getErrorMessage(err: unknown, fallback = "操作失败,请稍后重试"): string { if (!err) return fallback // axios 错误(后端 JSON 响应 / HTTP 错误状态) const ax = err as AxiosError if (ax.isAxiosError || (typeof ax === "object" && "response" in (ax as object))) { // 超时 if (ax.code === "ECONNABORTED" || /timeout/i.test(ax.message || "")) { return "请求超时,请检查网络后重试" } const resp = ax.response if (resp) { const bodyMsg = extractBodyMessage(resp.data) if (bodyMsg) return bodyMsg return statusFallback(resp.status) } // 请求已发出但无响应(断网/CORS/DNS) if (ax.request) return "网络连接异常,请检查网络设置" return ax.message || fallback } if (err instanceof Error) { // XHR 直传 OSS 失败等场景自带详细 message(含 HTTP 状态 + OSS Code/Message) if (err.message) return err.message } if (typeof err === "string") return err return fallback } /** client.ts 拦截器是否已对该错误弹过全局 toast(__msgShown 标记) */ export function isErrorMsgShown(err: unknown): boolean { return Boolean((err as { __msgShown?: boolean } | null)?.__msgShown) }