Files
writer-work-flow/apps/web/components/chain/ChainAdjudication.tsx
Yaojia Wang 9a623688e8 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 干净。
2026-06-24 18:01:15 +02:00

177 lines
5.4 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use client";
import { useCallback, useEffect, useState } from "react";
import { ConflictCard } from "@/components/review/ConflictCard";
import { useToast } from "@/components/Toast";
import { api } from "@/lib/api/client";
import { buildResumeRequest } from "@/lib/chain/chain";
import { friendlyFromApiError } from "@/lib/errors/messages";
import type { ReviewConflict } from "@/lib/review/sse";
import {
allResolved,
emptyDecisions,
setNote,
setVerdict,
unresolvedIndices,
type DecisionDraft,
type Verdict,
} from "@/lib/review/decisions";
import { latestReview, normalizeConflicts } from "@/lib/review/history";
interface ChainAdjudicationProps {
projectId: string;
jobId: string;
// interrupt 命中的章号(读其最近审稿冲突供裁决)。
chapterNo: number;
// 续跑发起后回调(父层重启轮询)。
onResumed: () => void;
}
type LoadState = "loading" | "ready" | "error";
// 链裁决面板(净新但复用满):读 awaiting 章最近审稿 → 渲 ConflictCard每冲突一张复用审稿页
// 组件 + decisions.ts 纯逻辑)→ 全决后 POST resumebuildResumeRequest 复用 accept 裁决组装)。
// 链无在手草稿正文 → 无段内定位snippet=null/locatable=false裁决纯靠 suggestion + 补丁对。
export function ChainAdjudication({
projectId,
jobId,
chapterNo,
onResumed,
}: ChainAdjudicationProps) {
const toast = useToast();
const [loadState, setLoadState] = useState<LoadState>("loading");
const [conflicts, setConflicts] = useState<ReviewConflict[]>([]);
const [drafts, setDrafts] = useState<DecisionDraft[]>([]);
const [resuming, setResuming] = useState(false);
useEffect(() => {
let active = true;
setLoadState("loading");
void (async () => {
try {
const { data, error } = await api.GET(
"/projects/{project_id}/chapters/{chapter_no}/reviews",
{
params: {
path: { project_id: projectId, chapter_no: chapterNo },
},
},
);
if (!active) return;
if (error || !data) {
setLoadState("error");
return;
}
const next = normalizeConflicts(latestReview(data.reviews));
setConflicts(next);
setDrafts(emptyDecisions(next.length));
setLoadState("ready");
} catch {
if (active) setLoadState("error");
}
})();
return () => {
active = false;
};
}, [projectId, chapterNo]);
const onVerdict = useCallback((index: number, verdict: Verdict): void => {
setDrafts((prev) => setVerdict(prev, index, verdict));
}, []);
const onNote = useCallback((index: number, note: string): void => {
setDrafts((prev) => setNote(prev, index, note));
}, []);
const onResume = useCallback(async (): Promise<void> => {
if (!allResolved(drafts)) {
toast("仍有未裁决的冲突,请先逐条裁决。", "error");
return;
}
setResuming(true);
try {
const { error } = await api.POST(
"/projects/{project_id}/chains/runs/{job_id}/resume",
{
params: { path: { project_id: projectId, job_id: jobId } },
body: buildResumeRequest(drafts),
},
);
if (error) {
toast(friendlyFromApiError(error).text, "error");
setResuming(false);
return;
}
onResumed();
} catch {
toast("续跑请求异常,请检查网络。", "error");
setResuming(false);
}
}, [drafts, projectId, jobId, onResumed, toast]);
if (loadState === "loading") {
return <p className="text-sm text-ink-soft"> {chapterNo} </p>;
}
if (loadState === "error") {
return (
<p className="text-sm text-conflict">
{chapterNo} 稿
</p>
);
}
const pending = unresolvedIndices(drafts);
return (
<section
className="flex flex-col gap-3 rounded border border-line bg-panel p-5"
aria-label={`${chapterNo} 章冲突裁决`}
>
<h2 className="font-serif text-lg text-ink">
{chapterNo} {conflicts.length}
</h2>
{conflicts.length === 0 ? (
<p className="text-sm text-ink-soft">
</p>
) : (
<ul className="flex flex-col gap-3">
{conflicts.map((conflict, i) => {
const draft = drafts[i] ?? { verdict: null, note: "" };
return (
<ConflictCard
key={i}
index={i}
conflict={conflict}
draft={draft}
missing={false}
snippet={null}
locatable={false}
rewriting={false}
onVerdict={onVerdict}
onNote={onNote}
onJump={() => {}}
/>
);
})}
</ul>
)}
<button
type="button"
onClick={() => void onResume()}
disabled={resuming || !allResolved(drafts)}
className="self-start rounded bg-cinnabar px-4 py-2 text-sm text-white disabled:opacity-50"
>
{resuming ? "续跑中…" : "裁决完成,续跑"}
</button>
{pending.length > 0 ? (
<p className="text-xs text-conflict">
{pending.length}
</p>
) : null}
</section>
);
}