Files
writer-work-flow/apps/web/components/review/ConflictCard.tsx
2026-06-28 07:31:20 +02:00

174 lines
6.2 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 { AlertTriangle, CheckCircle2, LocateFixed, Sparkles } from "lucide-react";
import { Badge } from "@/components/ui/Badge";
import { TextInput } from "@/components/ui/TextInput";
import { buttonClass } from "@/lib/ui/variants";
import type { DecisionDraft, Verdict } from "@/lib/review/decisions";
import type { ReviewConflict } from "@/lib/review/sse";
interface ConflictCardProps {
index: number;
total: number;
conflict: ReviewConflict;
draft: DecisionDraft;
missing: boolean;
// R1命中段内联预览无段号/越界时为 null回退到「跳转」按钮
snippet: string | null;
// 该冲突能否在正文里定位(决定是否显示「跳转」——定位不到就别给会失败的入口)。
locatable: boolean;
// 正在 AI 改写(采纳无补丁冲突时):禁用按钮 + 显示进度,避免重复点。
rewriting: boolean;
onVerdict: (index: number, verdict: Verdict) => void;
onNote: (index: number, note: string) => void;
onJump: (index: number) => void;
}
// R2采纳改法=朱砂主按钮primary忽略/手改=次级secondary不再三者同权。
const VERDICT_OPTIONS: { value: Verdict; label: string; primary: boolean }[] = [
{ value: "accept", label: "采纳改法", primary: true },
{ value: "ignore", label: "忽略", primary: false },
{ value: "manual", label: "手改", primary: false },
];
// 裁决按钮样式:激活=朱砂填充;未激活的主按钮=朱砂描边强调,次级=低对比描边。
function verdictClass(active: boolean, primary: boolean): string {
if (active) return buttonClass({ variant: "primary", size: "sm" });
if (primary) return buttonClass({ variant: "outline", size: "sm" });
return buttonClass({ variant: "secondary", size: "sm" });
}
// 单个冲突报告卡UX §6.4):五类徽标 + where + refs + suggestion + 裁决三态。
// 冲突=赭红;未决/缺判时加图标+文案不单靠色a11y §10
export function ConflictCard({
index,
total,
conflict,
draft,
missing,
snippet,
locatable,
rewriting,
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">
<Badge variant="danger" className="shrink-0">
<AlertTriangle className="h-3 w-3" aria-hidden="true" />
{index + 1}/{total}
</Badge>
<div className="min-w-0 flex-1">
<p className="text-xs text-conflict">{conflict.type}</p>
<p className="mt-1 text-sm text-ink">{conflict.suggestion}</p>
</div>
</div>
{conflict.original && conflict.replacement ? (
<div className="mt-2 rounded border border-pass/30 bg-bg/40 px-3 py-1.5 text-xs leading-relaxed">
<span className="mr-1 text-ink-soft"></span>
<span className="text-conflict line-through">{conflict.original}</span>
<span className="mx-1 text-ink-soft" aria-hidden="true">
</span>
<span className="text-pass">{conflict.replacement}</span>
<span className="ml-2 text-ink-soft">稿</span>
</div>
) : null}
{snippet ? (
<blockquote className="mt-2 border-l-2 border-conflict/40 bg-bg/50 py-1 pl-3 text-xs leading-relaxed text-ink-soft">
<span className="mr-1 text-conflict" aria-hidden="true">
</span>
{snippet}
</blockquote>
) : null}
<div className="mt-2 flex flex-wrap items-center gap-2 pl-1 text-xs text-ink-soft">
{conflict.where && locatable ? (
<button
type="button"
onClick={() => onJump(index)}
className={buttonClass({ variant: "secondary", size: "sm" })}
>
<LocateFixed className="h-4 w-4" aria-hidden="true" />
{conflict.where}
</button>
) : null}
{conflict.refs.map((ref) => (
<span key={ref} className="font-mono">
{ref}
</span>
))}
</div>
<div role="group" aria-labelledby={`verdict-label-${index}`} className="mt-3">
<span id={`verdict-label-${index}`} className="sr-only">
{index + 1}
</span>
<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}
disabled={rewriting}
onClick={() => onVerdict(index, opt.value)}
className={verdictClass(active, opt.primary)}
>
{opt.label}
</button>
);
})}
{rewriting ? (
<Badge variant="info">
<Sparkles className="h-3 w-3" aria-hidden="true" />
AI
</Badge>
) : resolved ? (
<Badge variant="success">
<CheckCircle2 className="h-3 w-3" aria-hidden="true" />
</Badge>
) : (
<Badge variant="danger"></Badge>
)}
</div>
{draft.verdict === "manual" ? (
<div className="mt-2">
<label htmlFor={`note-${index}`} className="sr-only">
</label>
<TextInput
id={`note-${index}`}
type="text"
value={draft.note}
onChange={(e) => onNote(index, e.target.value)}
placeholder="手改说明(可选)"
/>
<p className="mt-1 text-xs text-ink-soft">
</p>
</div>
) : null}
</div>
</li>
);
}