fix(qa): 前端错误文案——按 API 信封 surface 真实错因,去掉通用 toast
新增 friendlyFromApiError(apiError):兼容业务信封 {error:{code,message}}(ARCH §7.1)
与 FastAPI 422 {detail:[...]},422 映射为友好 VALIDATION 文案(不泄露技术细节)。
接入三处此前吞掉错因、永显通用文案的调用点:
- useOutline(QA #2):原 env.error.code 漏读 422 detail,永显「大纲生成失败」。
- ChainPage / ChainAdjudication(QA #11):原 friendlyError(undefined) 永显通用 toast,
现 surface 真实 code(如 LLM_UNAVAILABLE→去设置 provider / CONFLICT→提示裁决)。
+ messages.test.ts 加 3 例(业务码带动作 / 422→VALIDATION / 未知形兜底)。
门禁绿:vitest / tsc / eslint 干净。
This commit is contained in:
@@ -6,7 +6,7 @@ import { ConflictCard } from "@/components/review/ConflictCard";
|
|||||||
import { useToast } from "@/components/Toast";
|
import { useToast } from "@/components/Toast";
|
||||||
import { api } from "@/lib/api/client";
|
import { api } from "@/lib/api/client";
|
||||||
import { buildResumeRequest } from "@/lib/chain/chain";
|
import { buildResumeRequest } from "@/lib/chain/chain";
|
||||||
import { friendlyError } from "@/lib/errors/messages";
|
import { friendlyFromApiError } from "@/lib/errors/messages";
|
||||||
import type { ReviewConflict } from "@/lib/review/sse";
|
import type { ReviewConflict } from "@/lib/review/sse";
|
||||||
import {
|
import {
|
||||||
allResolved,
|
allResolved,
|
||||||
@@ -99,7 +99,7 @@ export function ChainAdjudication({
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
if (error) {
|
if (error) {
|
||||||
toast(friendlyError(undefined).text, "error");
|
toast(friendlyFromApiError(error).text, "error");
|
||||||
setResuming(false);
|
setResuming(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { AppShell } from "@/components/AppShell";
|
|||||||
import { useToast } from "@/components/Toast";
|
import { useToast } from "@/components/Toast";
|
||||||
import { api } from "@/lib/api/client";
|
import { api } from "@/lib/api/client";
|
||||||
import { chainPhase, chainResultView, type ChainKind } from "@/lib/chain/chain";
|
import { chainPhase, chainResultView, type ChainKind } from "@/lib/chain/chain";
|
||||||
import { friendlyError } from "@/lib/errors/messages";
|
import { friendlyFromApiError } from "@/lib/errors/messages";
|
||||||
import { useJobPoll } from "@/lib/jobs/useJobPoll";
|
import { useJobPoll } from "@/lib/jobs/useJobPoll";
|
||||||
import type { ProjectResponse } from "@/lib/api/types";
|
import type { ProjectResponse } from "@/lib/api/types";
|
||||||
import { ChainAdjudication } from "./ChainAdjudication";
|
import { ChainAdjudication } from "./ChainAdjudication";
|
||||||
@@ -58,7 +58,7 @@ export function ChainPage({ project }: ChainPageProps) {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
if (error || !data) {
|
if (error || !data) {
|
||||||
toast(friendlyError(undefined).text, "error");
|
toast(friendlyFromApiError(error).text, "error");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setJobId(data.job_id);
|
setJobId(data.job_id);
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
import { friendlyError } from "./messages";
|
import { friendlyError, friendlyFromApiError } from "./messages";
|
||||||
|
|
||||||
describe("friendlyError", () => {
|
describe("friendlyError", () => {
|
||||||
it("maps LLM_UNAVAILABLE to copy + a settings action", () => {
|
it("maps LLM_UNAVAILABLE to copy + a settings action", () => {
|
||||||
@@ -26,3 +26,21 @@ describe("friendlyError", () => {
|
|||||||
expect(friendlyError("WEIRD_CODE", " ").text).toBe("出错了,请稍后重试。");
|
expect(friendlyError("WEIRD_CODE", " ").text).toBe("出错了,请稍后重试。");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("friendlyFromApiError", () => {
|
||||||
|
it("surfaces a business error envelope code + action (QA #2/#11)", () => {
|
||||||
|
const e = friendlyFromApiError({ error: { code: "LLM_UNAVAILABLE", message: "x" } });
|
||||||
|
expect(e.text).toContain("未连接");
|
||||||
|
expect(e.actionHref).toBe("/settings/providers");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("maps a FastAPI 422 detail envelope to friendly VALIDATION copy (no leak)", () => {
|
||||||
|
const e = friendlyFromApiError({ detail: [{ msg: "Input should be >= 1", loc: ["volume"] }] });
|
||||||
|
expect(e.text).toBe("提交内容有误,请检查后重试。");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to generic copy for null/unknown shapes", () => {
|
||||||
|
expect(friendlyFromApiError(null).text).toBe("出错了,请稍后重试。");
|
||||||
|
expect(friendlyFromApiError({ weird: 1 }).text).toBe("出错了,请稍后重试。");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -44,3 +44,22 @@ export function friendlyError(
|
|||||||
const fallback = fallbackMessage?.trim();
|
const fallback = fallbackMessage?.trim();
|
||||||
return { text: fallback && fallback.length > 0 ? fallback : DEFAULT_TEXT };
|
return { text: fallback && fallback.length > 0 ? fallback : DEFAULT_TEXT };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 从原始 API 错误对象(openapi-fetch 的 `error`)提取友好文案。
|
||||||
|
// 兼容两种信封:业务错误 `{error:{code,message}}`(ARCH §7.1)与 FastAPI 校验错误 `{detail:[...]}`。
|
||||||
|
// 422 一律映射为友好的 VALIDATION 文案(不泄露技术性 detail)。未知形 → 通用兜底。
|
||||||
|
export function friendlyFromApiError(apiError: unknown): FriendlyError {
|
||||||
|
if (apiError && typeof apiError === "object") {
|
||||||
|
const env = apiError as {
|
||||||
|
error?: { code?: string; message?: string };
|
||||||
|
detail?: unknown;
|
||||||
|
};
|
||||||
|
if (env.error?.code) {
|
||||||
|
return friendlyError(env.error.code, env.error.message);
|
||||||
|
}
|
||||||
|
if (env.detail !== undefined) {
|
||||||
|
return friendlyError("VALIDATION");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return friendlyError(undefined);
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { useCallback, useState } from "react";
|
|||||||
|
|
||||||
import { api } from "@/lib/api/client";
|
import { api } from "@/lib/api/client";
|
||||||
import { useToast } from "@/components/Toast";
|
import { useToast } from "@/components/Toast";
|
||||||
|
import { friendlyFromApiError } from "@/lib/errors/messages";
|
||||||
import type { OutlineChapterView } from "@/lib/api/types";
|
import type { OutlineChapterView } from "@/lib/api/types";
|
||||||
|
|
||||||
export type OutlineStatus = "idle" | "generating" | "ready" | "error";
|
export type OutlineStatus = "idle" | "generating" | "ready" | "error";
|
||||||
@@ -42,14 +43,12 @@ export function useOutline(initial: OutlineChapterView[]): UseOutline {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
if (apiError || !data) {
|
if (apiError || !data) {
|
||||||
const env = apiError as
|
// surface 真实错因(如 LLM_UNAVAILABLE→去设置 provider;422→校验文案),
|
||||||
| { error?: { code?: string; message?: string } }
|
// 而非永远的通用「大纲生成失败」(QA #2:原 env.error.code 漏读 422 的 detail 信封)。
|
||||||
| undefined;
|
const friendly = friendlyFromApiError(apiError);
|
||||||
const code = env?.error?.code ?? "OUTLINE_FAILED";
|
setError({ code: "OUTLINE_FAILED", message: friendly.text });
|
||||||
const message = env?.error?.message ?? "大纲生成失败,请重试。";
|
|
||||||
setError({ code, message });
|
|
||||||
setStatus("error");
|
setStatus("error");
|
||||||
toast(message, "error");
|
toast(friendly.text, "error");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setChapters(data.chapters ?? []);
|
setChapters(data.chapters ?? []);
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user