- P4-1 对比度:muted-soft 提到 WCAG AA 小字(≥4.5:1)于两主题各面 - P4-3 ConflictCard 原文/建议加 Minus/Plus 图标 + sr-only 标签(不靠颜色) - P4-4 动效:3 处 chevron transition-transform 加 motion-safe 门控 - P4-5 命令面板 ⌘K 键帽提示 + 键盘操作读数 + 空态引导 + mono 快捷键 - P2-8 SegmentedControl 升级 radiogroup(role=radio/aria-checked + 方向键 roving)
186 lines
6.7 KiB
TypeScript
186 lines
6.7 KiB
TypeScript
"use client";
|
||
|
||
import { AlertTriangle, CheckCircle2, LocateFixed, Minus, Plus, 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>
|
||
{/* 非颜色线索:Minus/「原文」标签,色盲也可辨(不单靠红色 + 删除线) */}
|
||
<Minus
|
||
className="mr-0.5 inline-block h-3 w-3 align-[-1px] text-conflict"
|
||
aria-hidden="true"
|
||
/>
|
||
<span className="sr-only">原文:</span>
|
||
<span className="text-conflict line-through">{conflict.original}</span>
|
||
<span className="mx-1 text-ink-soft" aria-hidden="true">
|
||
→
|
||
</span>
|
||
{/* 非颜色线索:Plus/「建议」标签,色盲也可辨(不单靠绿色) */}
|
||
<Plus
|
||
className="mr-0.5 inline-block h-3 w-3 align-[-1px] text-pass"
|
||
aria-hidden="true"
|
||
/>
|
||
<span className="sr-only">建议:</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>
|
||
);
|
||
}
|