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:
176
apps/web/components/chain/ChainAdjudication.tsx
Normal file
176
apps/web/components/chain/ChainAdjudication.tsx
Normal 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 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(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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user