Files
writer-work-flow/apps/web/components/review/ConflictCard.tsx
Yaojia Wang d81583d5d1 feat(ux): R1+R2 审稿裁决人体学 — 冲突卡内联片段 + 分组/排序/跳下一条/主次按钮
R1: 新 lib/review/snippet.ts 从 where 解析段号取终稿命中段预览,ConflictCard 渲染引用块。
R2: 新 lib/review/grouping.ts 按 type 分组+严重度排序(仅改显示顺序,保留原始 index 守 conflict_index 不变量),组计数徽标、跳到下一条未裁决(环回)、采纳改法主按钮/忽略手改次级。
TDD: snippet 8 测 + grouping 10 测。前端门禁绿: lint/tsc/vitest 201/build。
2026-06-20 15:53:45 +02:00

139 lines
4.6 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 type { ReviewConflict } from "@/lib/review/sse";
import type { DecisionDraft, Verdict } from "@/lib/review/decisions";
interface ConflictCardProps {
index: number;
conflict: ReviewConflict;
draft: DecisionDraft;
missing: boolean;
// R1命中段内联预览无段号/越界时为 null回退到「跳转」按钮
snippet: string | null;
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 "bg-cinnabar text-panel";
if (primary) return "border border-cinnabar text-cinnabar hover:bg-cinnabar/10";
return "border border-line text-ink-soft hover:border-cinnabar hover:text-ink";
}
// 单个冲突报告卡UX §6.4):五类徽标 + where + refs + suggestion + 裁决三态。
// 冲突=赭红;未决/缺判时加图标+文案不单靠色a11y §10
export function ConflictCard({
index,
conflict,
draft,
missing,
snippet,
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>
{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 ? (
<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 ${verdictClass(
active,
opt.primary,
)}`}
>
{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>
);
}