merge: 前端错误文案 friendlyFromApiError(QA #2/#11,去通用 toast)

This commit is contained in:
Yaojia Wang
2026-06-24 18:01:15 +02:00
6 changed files with 49 additions and 13 deletions

View File

@@ -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;
} }

View File

@@ -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);

View File

@@ -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("出错了,请稍后重试。");
});
});

View File

@@ -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);
}

View File

@@ -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→去设置 provider422→校验文案
| { 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