178 lines
5.4 KiB
TypeScript
178 lines
5.4 KiB
TypeScript
"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 resume(buildResumeRequest 复用 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}
|
||
total={conflicts.length}
|
||
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>
|
||
);
|
||
}
|