diff --git a/PROGRESS.md b/PROGRESS.md
index aed5880..a803a16 100644
--- a/PROGRESS.md
+++ b/PROGRESS.md
@@ -176,3 +176,4 @@ T0.1 monorepo 骨架 ✅ @devops · T0.2 16 MVP 表迁移(无漂移,users st
- [2026-06-17] @docs — 四份规格 + CLAUDE.md + 协同系统(PROGRESS/memory)就绪;可进 Phase 0 / T0.1。
- [2026-06-19] @llm — **K1.2 修头**:Kimi Code 适配器 `kimi_code_headers()` 改为**仅** `{"User-Agent": "KimiCLI/1.5"}`,删全部 X-Msh-* 头 + `kimi_device_id`/`KIMI_DEVICE_ID`/确定性 device-id 机制(K1.5 联网实测:有效 token 下 X-Msh-* 疑似触发 `401 Invalid Authentication`;对齐 pi-kimi-coder = UA-only)。改 `adapters/kimi_code.py` + `__init__` 导出 + 两测(adapter 6 测/factory 3 测,断 default_headers 仅 UA、无 X-Msh-*)。更 contracts C1扩(头集 UA-only)+ gotcha。门禁绿:ruff/format/mypy/pytest(56 passed)。
- [2026-06-19] @backend — **bugfix 空写章 prompt**:`assemble`(C5)从不含项目 premise/logline/theme/title 且无写章指令 → 全新项目(仅 premise)产空 `stable_core`+`volatile`,写章触发 LLM `400 message must not be empty`。修复:`MemoryRepos` 加第 8 repo `project:ProjectSpecRepo`(`SqlProjectSpecRepo` 按 project_id 读 `projects`);`assemble` 把「作品蓝本」放入 `stable_core` 首段(书级 spec→缓存前缀,守 #9)+ `volatile` 始终含「请创作第 N 章的正文。」。改 `domain/repositories.py`(+ProjectSpecView/Repo) · `memory/sql_repositories.py`(+SqlProjectSpecRepo, sql_memory_repos 装配) · `memory/assemble.py`(_build_spec_section + 注入);测试 `test_memory.py`(+bare-project/chapter-directive 两测+fake) · `test_projects.py`/`test_generation.py`(补 project= fake)。无 DDL/迁移(只读既有列)。门禁全绿:ruff/format/mypy(154 文件)/alembic check(无漂移)/pytest **438 passed**。更 contracts C5 + gotcha。消费方注意:手搓 `MemoryRepos(...)` 须补 `project=`。
+- [2026-06-20] @frontend — **UX R1+R2 完成**(审稿裁决人体学,纯前端无后端改)。R1:冲突卡内联命中段预览——新纯函数 `lib/review/snippet.ts`(从 `where`「第 N 段」解析 1-based 段号 → 取终稿对应段截断预览,无段号/越界/空段→null 回退「跳转」),`ConflictCard` 加 `snippet` prop 渲染朱砂引用块。R2:冲突按 type 分组 + 严重度排序(设定违例>能力不符>时间线倒错>地理矛盾>性格漂移,未知殿后)+ 组计数徽标 + 「跳到下一条未裁决」(按显示顺序环回)+ 主次裁决按钮(采纳改法=朱砂主、忽略/手改=次级)——新纯函数 `lib/review/grouping.ts`(`groupConflicts`/`displayOrder`/`nextUnresolvedInOrder`,**分组只改显示顺序、每条携原始 index,守 conflict_index 覆盖 range 不变量**),`ReviewReport` 改分组渲染。TDD:snippet 8 测 + grouping 10 测。前端门禁绿:lint/tsc/**vitest 201**(+18)/build。分支 `feat/ux-r1-r2-review-ergonomics`。下一步(按 plan):R3 验收前清单 → Tier4。
diff --git a/apps/web/components/review/ConflictCard.tsx b/apps/web/components/review/ConflictCard.tsx
index 0b28b27..33fb6b3 100644
--- a/apps/web/components/review/ConflictCard.tsx
+++ b/apps/web/components/review/ConflictCard.tsx
@@ -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({
{conflict.suggestion}
+ {snippet ? (
+
+
+ ❝
+
+ {snippet}
+
+ ) : null}
+
{conflict.where ? (
diff --git a/apps/web/components/review/ReviewReport.tsx b/apps/web/components/review/ReviewReport.tsx
index 1e9b632..11e02d1 100644
--- a/apps/web/components/review/ReviewReport.tsx
+++ b/apps/web/components/review/ReviewReport.tsx
@@ -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({
: "进页未带审稿留痕,点「重新审稿」开始。"}
) : (
-
- {conflicts.map((c, i) => (
-
+
+ {/* R2:未裁决进度 + 跳到下一条未裁决 */}
+
+
+ {unresolved > 0 ? (
+
+ {unresolved}/{conflictCount} 待裁决
+
+ ) : (
+ ✓ 全部已裁决
+ )}
+
+
+
+
+ {groups.map((group) => (
+
+
+ {group.type}
+
+ {group.items.length}
+
+
+
+ {group.items.map(({ conflict: c, index: i }) => (
+
+ ))}
+
+
))}
-
+
)}
diff --git a/apps/web/lib/review/grouping.test.ts b/apps/web/lib/review/grouping.test.ts
new file mode 100644
index 0000000..8575d4f
--- /dev/null
+++ b/apps/web/lib/review/grouping.test.ts
@@ -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();
+ });
+});
diff --git a/apps/web/lib/review/grouping.ts b/apps/web/lib/review/grouping.ts
new file mode 100644
index 0000000..142310e
--- /dev/null
+++ b/apps/web/lib/review/grouping.ts
@@ -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 = {
+ 设定违例: 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();
+ 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;
+}
diff --git a/apps/web/lib/review/snippet.test.ts b/apps/web/lib/review/snippet.test.ts
new file mode 100644
index 0000000..0adfe89
--- /dev/null
+++ b/apps/web/lib/review/snippet.test.ts
@@ -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);
+ });
+});
diff --git a/apps/web/lib/review/snippet.ts b/apps/web/lib/review/snippet.ts
new file mode 100644
index 0000000..e3ac490
--- /dev/null
+++ b/apps/web/lib/review/snippet.ts
@@ -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)}…`;
+}