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。
This commit is contained in:
@@ -8,17 +8,27 @@ interface ConflictCardProps {
|
||||
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;
|
||||
}
|
||||
|
||||
const VERDICT_OPTIONS: { value: Verdict; label: string }[] = [
|
||||
{ value: "accept", label: "采纳改法" },
|
||||
{ value: "ignore", label: "忽略" },
|
||||
{ value: "manual", label: "手改" },
|
||||
// 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({
|
||||
@@ -26,6 +36,7 @@ export function ConflictCard({
|
||||
conflict,
|
||||
draft,
|
||||
missing,
|
||||
snippet,
|
||||
onVerdict,
|
||||
onNote,
|
||||
onJump,
|
||||
@@ -52,6 +63,15 @@ export function ConflictCard({
|
||||
<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
|
||||
@@ -80,11 +100,10 @@ export function ConflictCard({
|
||||
type="button"
|
||||
aria-pressed={active}
|
||||
onClick={() => onVerdict(index, opt.value)}
|
||||
className={`rounded px-3 py-1 text-xs ${
|
||||
active
|
||||
? "bg-cinnabar text-panel"
|
||||
: "border border-line text-ink hover:border-cinnabar"
|
||||
}`}
|
||||
className={`rounded px-3 py-1 text-xs ${verdictClass(
|
||||
active,
|
||||
opt.primary,
|
||||
)}`}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
|
||||
@@ -19,6 +19,12 @@ import {
|
||||
normalizeForeshadowSug,
|
||||
normalizePace,
|
||||
} from "@/lib/review/history";
|
||||
import {
|
||||
displayOrder,
|
||||
groupConflicts,
|
||||
nextUnresolvedInOrder,
|
||||
} from "@/lib/review/grouping";
|
||||
import { conflictSnippet } from "@/lib/review/snippet";
|
||||
import { useAccept } from "@/lib/review/useAccept";
|
||||
import { useReviewStream } from "@/lib/review/useReviewStream";
|
||||
import type { ReviewConflict, StyleDriftSegment } from "@/lib/review/sse";
|
||||
@@ -139,12 +145,26 @@ export function ReviewReport({
|
||||
const onNote = (index: number, note: string): void =>
|
||||
setDrafts((prev) => setNote(prev, index, note));
|
||||
|
||||
// R2:按 type 分组 + 严重度排序(仅改显示顺序,每条仍携原始 index)。
|
||||
const groups = useMemo(() => groupConflicts(conflicts), [conflicts]);
|
||||
const order = useMemo(() => displayOrder(groups), [groups]);
|
||||
|
||||
const jumpToCard = (index: number): void => {
|
||||
setFocusedIndex(index);
|
||||
document
|
||||
.getElementById(`conflict-card-${index}`)
|
||||
?.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||
};
|
||||
|
||||
// R2:跳到显示顺序中的下一条未裁决(环回);全决则提示完成。
|
||||
const jumpToNextUnresolved = (): void => {
|
||||
const next = nextUnresolvedInOrder(order, drafts, focusedIndex);
|
||||
if (next === null) {
|
||||
toast("全部冲突已裁决。", "success");
|
||||
return;
|
||||
}
|
||||
jumpToCard(next);
|
||||
};
|
||||
const jumpToAnchor = (index: number): void => {
|
||||
setFocusedIndex(index);
|
||||
document
|
||||
@@ -276,20 +296,54 @@ export function ReviewReport({
|
||||
: "进页未带审稿留痕,点「重新审稿」开始。"}
|
||||
</p>
|
||||
) : (
|
||||
<ul className="space-y-3">
|
||||
{conflicts.map((c, i) => (
|
||||
<ConflictCard
|
||||
key={i}
|
||||
index={i}
|
||||
conflict={c}
|
||||
draft={drafts[i] ?? { verdict: null, note: "" }}
|
||||
missing={missing.has(i)}
|
||||
onVerdict={onVerdict}
|
||||
onNote={onNote}
|
||||
onJump={jumpToAnchor}
|
||||
/>
|
||||
<div className="space-y-4">
|
||||
{/* R2:未裁决进度 + 跳到下一条未裁决 */}
|
||||
<div className="flex items-center justify-between text-xs text-ink-soft">
|
||||
<span>
|
||||
{unresolved > 0 ? (
|
||||
<span className="text-conflict">
|
||||
{unresolved}/{conflictCount} 待裁决
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-pass">✓ 全部已裁决</span>
|
||||
)}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={jumpToNextUnresolved}
|
||||
disabled={unresolved === 0}
|
||||
className="rounded border border-line px-2 py-0.5 hover:border-cinnabar hover:text-cinnabar disabled:cursor-not-allowed disabled:opacity-40"
|
||||
>
|
||||
跳到下一条未裁决 ▾
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{groups.map((group) => (
|
||||
<div key={group.type}>
|
||||
<h3 className="mb-2 flex items-center gap-2 text-xs font-semibold text-ink">
|
||||
{group.type}
|
||||
<span className="rounded-full bg-[var(--color-conflict)]/10 px-2 py-0.5 font-mono text-conflict">
|
||||
{group.items.length}
|
||||
</span>
|
||||
</h3>
|
||||
<ul className="space-y-3">
|
||||
{group.items.map(({ conflict: c, index: i }) => (
|
||||
<ConflictCard
|
||||
key={i}
|
||||
index={i}
|
||||
conflict={c}
|
||||
draft={drafts[i] ?? { verdict: null, note: "" }}
|
||||
missing={missing.has(i)}
|
||||
snippet={conflictSnippet(finalText, c.where)}
|
||||
onVerdict={onVerdict}
|
||||
onNote={onNote}
|
||||
onJump={jumpToAnchor}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
|
||||
89
apps/web/lib/review/grouping.test.ts
Normal file
89
apps/web/lib/review/grouping.test.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { emptyDecisions, setVerdict } from "./decisions";
|
||||
import {
|
||||
displayOrder,
|
||||
groupConflicts,
|
||||
nextUnresolvedInOrder,
|
||||
} from "./grouping";
|
||||
import type { ReviewConflict } from "./sse";
|
||||
|
||||
function conflict(type: string, suggestion = "x"): ReviewConflict {
|
||||
return { type, where: "", refs: [], suggestion };
|
||||
}
|
||||
|
||||
describe("groupConflicts", () => {
|
||||
it("groups by type and carries the original index", () => {
|
||||
const conflicts = [
|
||||
conflict("性格漂移", "a"),
|
||||
conflict("设定违例", "b"),
|
||||
conflict("性格漂移", "c"),
|
||||
];
|
||||
const groups = groupConflicts(conflicts);
|
||||
const drift = groups.find((g) => g.type === "性格漂移");
|
||||
expect(drift?.items.map((it) => it.index)).toEqual([0, 2]);
|
||||
expect(drift?.items.map((it) => it.conflict.suggestion)).toEqual(["a", "c"]);
|
||||
});
|
||||
|
||||
it("orders groups by severity (设定违例 before 性格漂移)", () => {
|
||||
const groups = groupConflicts([
|
||||
conflict("性格漂移"),
|
||||
conflict("设定违例"),
|
||||
]);
|
||||
expect(groups.map((g) => g.type)).toEqual(["设定违例", "性格漂移"]);
|
||||
});
|
||||
|
||||
it("places unknown types after the known five", () => {
|
||||
const groups = groupConflicts([conflict("排版错误"), conflict("能力不符")]);
|
||||
expect(groups.map((g) => g.type)).toEqual(["能力不符", "排版错误"]);
|
||||
});
|
||||
|
||||
it("returns an empty array for no conflicts", () => {
|
||||
expect(groupConflicts([])).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("displayOrder", () => {
|
||||
it("flattens grouped indices in display order", () => {
|
||||
const groups = groupConflicts([
|
||||
conflict("性格漂移"),
|
||||
conflict("设定违例"),
|
||||
conflict("性格漂移"),
|
||||
]);
|
||||
// 设定违例(index 1) 先,再 性格漂移(0,2)
|
||||
expect(displayOrder(groups)).toEqual([1, 0, 2]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("nextUnresolvedInOrder", () => {
|
||||
const order = [1, 0, 2];
|
||||
|
||||
it("finds the first unresolved when nothing is focused", () => {
|
||||
const drafts = emptyDecisions(3);
|
||||
expect(nextUnresolvedInOrder(order, drafts, null)).toBe(1);
|
||||
});
|
||||
|
||||
it("advances to the next unresolved after the focused index", () => {
|
||||
let drafts = emptyDecisions(3);
|
||||
drafts = setVerdict(drafts, 1, "accept");
|
||||
expect(nextUnresolvedInOrder(order, drafts, 1)).toBe(0);
|
||||
});
|
||||
|
||||
it("wraps around to earlier unresolved items", () => {
|
||||
let drafts = emptyDecisions(3);
|
||||
drafts = setVerdict(drafts, 2, "ignore");
|
||||
expect(nextUnresolvedInOrder(order, drafts, 2)).toBe(1);
|
||||
});
|
||||
|
||||
it("returns null when all are resolved", () => {
|
||||
let drafts = emptyDecisions(3);
|
||||
drafts = setVerdict(drafts, 0, "accept");
|
||||
drafts = setVerdict(drafts, 1, "accept");
|
||||
drafts = setVerdict(drafts, 2, "accept");
|
||||
expect(nextUnresolvedInOrder(order, drafts, null)).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for an empty order", () => {
|
||||
expect(nextUnresolvedInOrder([], [], null)).toBeNull();
|
||||
});
|
||||
});
|
||||
67
apps/web/lib/review/grouping.ts
Normal file
67
apps/web/lib/review/grouping.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
// R2 · 冲突分组/排序 + 跳到下一条未裁决(UX §6.4)。
|
||||
// 分组仅按 type;严重度排序:已知五类给定优先级,未知殿后。
|
||||
// 关键:分组/排序只改「显示顺序」,每条仍携原始 index——裁决草稿与 accept 的
|
||||
// conflict_index 一律以原始下标为准(不变量:conflict_index 覆盖 range(len(conflicts)))。
|
||||
// 纯逻辑,便于 node 环境单测。
|
||||
|
||||
import type { DecisionDraft } from "./decisions";
|
||||
import type { ReviewConflict } from "./sse";
|
||||
|
||||
// 严重度优先级(数字越小越靠前)。对齐 C6 五类 ConflictType。
|
||||
const SEVERITY_RANK: Record<string, number> = {
|
||||
设定违例: 0,
|
||||
能力不符: 1,
|
||||
时间线倒错: 2,
|
||||
地理矛盾: 3,
|
||||
性格漂移: 4,
|
||||
};
|
||||
const UNKNOWN_RANK = 99;
|
||||
|
||||
export interface ConflictItem {
|
||||
conflict: ReviewConflict;
|
||||
index: number;
|
||||
}
|
||||
|
||||
export interface ConflictGroup {
|
||||
type: string;
|
||||
items: ConflictItem[];
|
||||
}
|
||||
|
||||
function severityRank(type: string): number {
|
||||
return SEVERITY_RANK[type] ?? UNKNOWN_RANK;
|
||||
}
|
||||
|
||||
// 按 type 分组并按严重度排序;组内保留原始出现顺序。
|
||||
export function groupConflicts(
|
||||
conflicts: readonly ReviewConflict[],
|
||||
): ConflictGroup[] {
|
||||
const byType = new Map<string, ConflictItem[]>();
|
||||
conflicts.forEach((conflict, index) => {
|
||||
const items = byType.get(conflict.type) ?? [];
|
||||
items.push({ conflict, index });
|
||||
byType.set(conflict.type, items);
|
||||
});
|
||||
return [...byType.entries()]
|
||||
.map(([type, items]) => ({ type, items }))
|
||||
.sort((a, b) => severityRank(a.type) - severityRank(b.type));
|
||||
}
|
||||
|
||||
// 显示顺序下的原始下标序列(用于「跳到下一条未裁决」按显示顺序推进)。
|
||||
export function displayOrder(groups: readonly ConflictGroup[]): number[] {
|
||||
return groups.flatMap((group) => group.items.map((item) => item.index));
|
||||
}
|
||||
|
||||
// 显示顺序中、当前聚焦之后的下一条未裁决下标;到末尾环回;无未裁决 → null。
|
||||
export function nextUnresolvedInOrder(
|
||||
order: readonly number[],
|
||||
drafts: readonly DecisionDraft[],
|
||||
fromIndex: number | null,
|
||||
): number | null {
|
||||
if (order.length === 0) return null;
|
||||
const pos = fromIndex === null ? -1 : order.indexOf(fromIndex);
|
||||
for (let step = 1; step <= order.length; step++) {
|
||||
const idx = order[(pos + step + order.length) % order.length];
|
||||
if (idx !== undefined && drafts[idx]?.verdict == null) return idx;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
50
apps/web/lib/review/snippet.test.ts
Normal file
50
apps/web/lib/review/snippet.test.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { conflictSnippet, parseParagraphNo } from "./snippet";
|
||||
|
||||
describe("parseParagraphNo", () => {
|
||||
it("extracts 1-based paragraph number from 第 N 段 wording", () => {
|
||||
expect(parseParagraphNo("第 3 段,主角忽然示弱")).toBe(3);
|
||||
expect(parseParagraphNo("第9段老者道破来历")).toBe(9);
|
||||
});
|
||||
|
||||
it("returns null when no paragraph number present", () => {
|
||||
expect(parseParagraphNo("战斗场景")).toBeNull();
|
||||
expect(parseParagraphNo("开篇提到三日后")).toBeNull();
|
||||
expect(parseParagraphNo("")).toBeNull();
|
||||
});
|
||||
|
||||
it("takes the first number for a range like 第 4–6 段", () => {
|
||||
expect(parseParagraphNo("第 4–6 段反复描写天气")).toBe(4);
|
||||
});
|
||||
});
|
||||
|
||||
describe("conflictSnippet", () => {
|
||||
const text = "第一段正文。\n\n第二段正文。\n\n第三段正文,命中冲突的句子。";
|
||||
|
||||
it("returns the trimmed paragraph for a parseable where", () => {
|
||||
expect(conflictSnippet(text, "第 3 段,主角忽然示弱")).toBe(
|
||||
"第三段正文,命中冲突的句子。",
|
||||
);
|
||||
});
|
||||
|
||||
it("returns null when paragraph number is unparseable", () => {
|
||||
expect(conflictSnippet(text, "战斗场景")).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null when paragraph index is out of range", () => {
|
||||
expect(conflictSnippet(text, "第 9 段")).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null when the located paragraph is empty", () => {
|
||||
expect(conflictSnippet("\n\n\n\n正文", "第 1 段")).toBeNull();
|
||||
});
|
||||
|
||||
it("truncates long paragraphs with an ellipsis", () => {
|
||||
const long = "甲".repeat(200);
|
||||
const snippet = conflictSnippet(long, "第 1 段");
|
||||
expect(snippet).not.toBeNull();
|
||||
expect(snippet!.length).toBeLessThanOrEqual(81);
|
||||
expect(snippet!.endsWith("…")).toBe(true);
|
||||
});
|
||||
});
|
||||
27
apps/web/lib/review/snippet.ts
Normal file
27
apps/web/lib/review/snippet.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
// R1 · 冲突卡内联上下文片段(UX §6.4)。
|
||||
// `where` 是 LLM 给的文字定位(如「第 3 段,主角忽然示弱」),M2 无精确字符 offset。
|
||||
// 这里按段级回放:从 where 解析 1-based 段号 → 取终稿对应段 → 截断成预览。
|
||||
// 纯逻辑,与 ReviewReport.segmentText 同口径(空行切段),便于 node 环境单测。
|
||||
|
||||
// 预览窗口最大字符数(命中段超长时截断,避免撑爆卡片)。
|
||||
const PREVIEW_MAX = 80;
|
||||
|
||||
// 从 where 文案解析 1-based 段号(「第 3 段」→ 3;范围「第 4–6 段」取首个)。无则 null。
|
||||
export function parseParagraphNo(where: string): number | null {
|
||||
const match = where.match(/第\s*(\d+)/);
|
||||
if (!match) return null;
|
||||
const n = Number(match[1]);
|
||||
return Number.isInteger(n) && n > 0 ? n : null;
|
||||
}
|
||||
|
||||
// 取终稿第 N 段(1-based)正文预览;无段号/越界/空段 → null(卡片回退到「跳转」按钮)。
|
||||
export function conflictSnippet(finalText: string, where: string): string | null {
|
||||
const no = parseParagraphNo(where);
|
||||
if (no === null) return null;
|
||||
const paragraphs = finalText.split(/\n{2,}/);
|
||||
const paragraph = paragraphs[no - 1]?.trim();
|
||||
if (!paragraph) return null;
|
||||
return paragraph.length <= PREVIEW_MAX
|
||||
? paragraph
|
||||
: `${paragraph.slice(0, PREVIEW_MAX)}…`;
|
||||
}
|
||||
Reference in New Issue
Block a user