- 续审 Agent 声明(AgentSpec) + 结构化输出契约(ContinuityReview/Conflict 五类) - LangGraph 并行审子图(可扩四审) + collect 落 chapter_reviews 留痕 + review SSE(section/conflict) - 验收-side Repository:章节 accepted 版本晋升 + digest append-only + 审稿留痕/裁决 - API:review(SSE) + reviews 历史 + accept(单原子事务:晋升 version + 终稿 digest + 裁决留痕) - 冲突 gate:未决裁决拦截(CONFLICT_UNRESOLVED);digest 从终稿提炼(不变量#4) - 前端:审稿报告页 + 冲突就地标注 + 裁决(采纳/忽略/手改) + 未决禁验收 + 「本次将更新」清单 - M2 E2E:真实 DB + 多档位 mock 网关零 token 走通 写→审→裁决→验收→摘要入库 - 多 agent 协同台账(PROGRESS.md) + 共享记忆(memory/contracts·decisions·gotchas)
120 lines
3.7 KiB
TypeScript
120 lines
3.7 KiB
TypeScript
"use client";
|
||
|
||
import type { ReviewConflict } from "@/lib/review/sse";
|
||
import type { DecisionDraft, Verdict } from "@/lib/review/decisions";
|
||
|
||
interface ConflictCardProps {
|
||
index: number;
|
||
conflict: ReviewConflict;
|
||
draft: DecisionDraft;
|
||
missing: boolean;
|
||
onVerdict: (index: number, verdict: Verdict) => void;
|
||
onNote: (index: number, note: string) => void;
|
||
onJump: (index: number) => void;
|
||
}
|
||
|
||
const VERDICT_OPTIONS: { value: Verdict; label: string }[] = [
|
||
{ value: "accept", label: "采纳改法" },
|
||
{ value: "ignore", label: "忽略" },
|
||
{ value: "manual", label: "手改" },
|
||
];
|
||
|
||
// 单个冲突报告卡(UX §6.4):五类徽标 + where + refs + suggestion + 裁决三态。
|
||
// 冲突=赭红;未决/缺判时加图标+文案(不单靠色,a11y §10)。
|
||
export function ConflictCard({
|
||
index,
|
||
conflict,
|
||
draft,
|
||
missing,
|
||
onVerdict,
|
||
onNote,
|
||
onJump,
|
||
}: ConflictCardProps) {
|
||
const resolved = draft.verdict !== null;
|
||
return (
|
||
<li
|
||
id={`conflict-card-${index}`}
|
||
className={`rounded border bg-panel p-4 ${
|
||
missing
|
||
? "border-conflict ring-1 ring-conflict"
|
||
: resolved
|
||
? "border-pass/40"
|
||
: "border-line"
|
||
}`}
|
||
>
|
||
<div className="flex items-start gap-3">
|
||
<span
|
||
className="shrink-0 rounded bg-[var(--color-conflict)]/10 px-2 py-0.5 text-xs text-conflict"
|
||
aria-hidden="true"
|
||
>
|
||
⚠ {conflict.type}
|
||
</span>
|
||
<p className="flex-1 text-sm text-ink">{conflict.suggestion}</p>
|
||
</div>
|
||
|
||
<div className="mt-2 flex flex-wrap items-center gap-2 pl-1 text-xs text-ink-soft">
|
||
{conflict.where ? (
|
||
<button
|
||
type="button"
|
||
onClick={() => onJump(index)}
|
||
className="rounded border border-line px-2 py-0.5 hover:border-cinnabar hover:text-cinnabar"
|
||
>
|
||
▸ 跳转 {conflict.where}
|
||
</button>
|
||
) : null}
|
||
{conflict.refs.map((ref) => (
|
||
<span key={ref} className="font-mono">
|
||
▸ {ref}
|
||
</span>
|
||
))}
|
||
</div>
|
||
|
||
<fieldset className="mt-3">
|
||
<legend className="sr-only">冲突 {index + 1} 裁决</legend>
|
||
<div className="flex flex-wrap items-center gap-2">
|
||
{VERDICT_OPTIONS.map((opt) => {
|
||
const active = draft.verdict === opt.value;
|
||
return (
|
||
<button
|
||
key={opt.value}
|
||
type="button"
|
||
aria-pressed={active}
|
||
onClick={() => onVerdict(index, opt.value)}
|
||
className={`rounded px-3 py-1 text-xs ${
|
||
active
|
||
? "bg-cinnabar text-panel"
|
||
: "border border-line text-ink hover:border-cinnabar"
|
||
}`}
|
||
>
|
||
{opt.label}
|
||
</button>
|
||
);
|
||
})}
|
||
{resolved ? (
|
||
<span className="text-xs text-pass" aria-hidden="true">
|
||
✓ 已裁决
|
||
</span>
|
||
) : (
|
||
<span className="text-xs text-conflict">未裁决</span>
|
||
)}
|
||
</div>
|
||
{draft.verdict === "manual" ? (
|
||
<div className="mt-2">
|
||
<label htmlFor={`note-${index}`} className="sr-only">
|
||
手改说明
|
||
</label>
|
||
<input
|
||
id={`note-${index}`}
|
||
type="text"
|
||
value={draft.note}
|
||
onChange={(e) => onNote(index, e.target.value)}
|
||
placeholder="手改说明(可选)"
|
||
className="w-full rounded border border-line bg-bg px-3 py-1.5 text-sm text-ink focus:border-cinnabar focus:outline-none"
|
||
/>
|
||
</div>
|
||
) : null}
|
||
</fieldset>
|
||
</li>
|
||
);
|
||
}
|