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 强类型。
141 lines
4.7 KiB
TypeScript
141 lines
4.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;
|
||
// 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>
|
||
);
|
||
}
|