Files
writer-work-flow/apps/web/components/chain/ChainAdjudication.tsx

196 lines
5.9 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 { Button } from "@/components/ui/Button";
import { StatusNote } from "@/components/ui/StatusNote";
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);
// 拉取 awaiting 章最近审稿冲突。抽成可复用回调,供进场 effect 与「重新加载」按钮共用。
const load = useCallback(async (): Promise<boolean> => {
setLoadState("loading");
try {
const { data, error } = await api.GET(
"/projects/{project_id}/chapters/{chapter_no}/reviews",
{
params: {
path: { project_id: projectId, chapter_no: chapterNo },
},
},
);
if (error || !data) {
setLoadState("error");
return false;
}
const next = normalizeConflicts(latestReview(data.reviews));
setConflicts(next);
setDrafts(emptyDecisions(next.length));
setLoadState("ready");
return true;
} catch {
setLoadState("error");
return false;
}
}, [projectId, chapterNo]);
useEffect(() => {
let active = true;
void (async () => {
// 进场加载:组件已卸载则丢弃结果(避免 setState-after-unmount
if (!active) return;
await load();
})();
return () => {
active = false;
};
}, [load]);
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 (
<StatusNote variant="danger" title={`加载第 ${chapterNo} 章审稿冲突失败`}>
<p></p>
<Button
variant="outline"
size="sm"
onClick={() => void load()}
className="mt-3"
>
</Button>
</StatusNote>
);
}
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}
total={conflicts.length}
conflict={conflict}
draft={draft}
missing={false}
snippet={null}
locatable={false}
rewriting={false}
onVerdict={onVerdict}
onNote={onNote}
onJump={() => {}}
/>
);
})}
</ul>
)}
<Button
variant="primary"
onClick={() => void onResume()}
disabled={resuming || !allResolved(drafts)}
className="self-start"
>
{resuming ? "续跑中…" : "裁决完成,续跑"}
</Button>
{pending.length > 0 ? (
<p className="text-xs text-conflict">
{pending.length}
</p>
) : null}
</section>
);
}