feat(web): Scope B B1 — 多章工作流链前端 UI(发起/轮询/裁决续跑)

链页 app/projects/[id]/chains(RSC fetchProject)+ ChainPage/ChainStarter/
ChainProgress/ChainAdjudication;lib/chain 纯函数(result 收窄/相位映射/resume 组装)。
复用 useJobPoll 轮询、ConflictCard + decisions.ts 裁决、normalizeConflicts/latestReview、
friendlyError;nav 加 chains 入口。awaiting(interrupt)→ 读该章冲突渲 ConflictCard →
POST .../chains/runs/{job_id}/resume 续跑。gen:api 纳入 chains run/resume。
守 #5(链暂停=等裁决,token/正文不入 job result)。
This commit is contained in:
Yaojia Wang
2026-06-23 19:21:00 +02:00
parent 416caed56b
commit a2dcd89881
11 changed files with 874 additions and 4 deletions

View File

@@ -0,0 +1,176 @@
"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 { friendlyError } 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(friendlyError(undefined).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>
);
}