Files
writer-work-flow/apps/web/components/review/ConflictCard.tsx
Yaojia Wang c6651b74b9 fix(web): stale-closure + focus trap + 去边界强转 + a11y/性能打磨 + 类型化客户端
P1-6 useStyleLearn/useKimiOauth 修 stale-closure 依赖。
P1-7 CommandPalette/Drawer 加 focus trap(新 lib/a11y/focusTrap)。
P1-8 SSE 解析与 server.ts 去 as 强转改类型守卫/运行时校验。
P2 删冗余强转、开 noUncheckedIndexedAccess、Toast 清理+稳定 key、列表 key 稳定化、
  rel=noopener、useMemo 大文本、ConflictCard role=group、useInjection 加锁、
  client.test 真断言。
codegen 重生成 schema.d.ts + 消费 DimensionEntry/ReviewConflictView 强类型。
2026-06-21 19:32:49 +02:00

141 lines
4.7 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>
<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}
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}
</div>
</li>
);
}