From d81583d5d17caa1ea197303c48b0fb2e2c527835 Mon Sep 17 00:00:00 2001 From: Yaojia Wang Date: Sat, 20 Jun 2026 15:53:45 +0200 Subject: [PATCH 1/5] =?UTF-8?q?feat(ux):=20R1+R2=20=E5=AE=A1=E7=A8=BF?= =?UTF-8?q?=E8=A3=81=E5=86=B3=E4=BA=BA=E4=BD=93=E5=AD=A6=20=E2=80=94=20?= =?UTF-8?q?=E5=86=B2=E7=AA=81=E5=8D=A1=E5=86=85=E8=81=94=E7=89=87=E6=AE=B5?= =?UTF-8?q?=20+=20=E5=88=86=E7=BB=84/=E6=8E=92=E5=BA=8F/=E8=B7=B3=E4=B8=8B?= =?UTF-8?q?=E4=B8=80=E6=9D=A1/=E4=B8=BB=E6=AC=A1=E6=8C=89=E9=92=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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。 --- PROGRESS.md | 1 + apps/web/components/review/ConflictCard.tsx | 37 ++++++--- apps/web/components/review/ReviewReport.tsx | 80 +++++++++++++++--- apps/web/lib/review/grouping.test.ts | 89 +++++++++++++++++++++ apps/web/lib/review/grouping.ts | 67 ++++++++++++++++ apps/web/lib/review/snippet.test.ts | 50 ++++++++++++ apps/web/lib/review/snippet.ts | 27 +++++++ 7 files changed, 329 insertions(+), 22 deletions(-) create mode 100644 apps/web/lib/review/grouping.test.ts create mode 100644 apps/web/lib/review/grouping.ts create mode 100644 apps/web/lib/review/snippet.test.ts create mode 100644 apps/web/lib/review/snippet.ts 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({ : "进页未带审稿留痕,点「重新审稿」开始。"}

) : ( - +
)} 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)}…`; +} From 6e5d0e88f6c76e28b687375e480446ac2ad98b7b Mon Sep 17 00:00:00 2001 From: Yaojia Wang Date: Sat, 20 Jun 2026 16:00:51 +0200 Subject: [PATCH 2/5] =?UTF-8?q?feat(ux):=20R3=20=E9=AA=8C=E6=94=B6?= =?UTF-8?q?=E5=89=8D=E6=B8=85=E5=8D=95=20=E2=80=94=20AcceptPanel=E3=80=8C?= =?UTF-8?q?=E6=9C=AC=E6=AC=A1=E5=B0=86=E6=9B=B4=E6=96=B0=E3=80=8D=E9=A2=84?= =?UTF-8?q?=E8=A7=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新 lib/review/accept-preview.ts (buildAcceptPreview):据现有 client state 列验收前预期项——晋升版次/提炼摘要/写回裁决/伏笔到期扫描/伏笔建议待登记。仅列后端 accept 事务确实做的事(核对 projects.py accept 端点),不声称改人物 latest_state,带「以验收回执为准」免责。 AcceptPanel 加 foreshadowCount prop + 未阻断时渲染清单;ReviewReport 传 foreshadow.length。 TDD: 4 测。前端门禁绿: lint/tsc/vitest 205/build。 --- PROGRESS.md | 1 + apps/web/components/review/AcceptPanel.tsx | 42 +++++++++++++++--- apps/web/components/review/ReviewReport.tsx | 1 + apps/web/lib/review/accept-preview.test.ts | 48 ++++++++++++++++++++ apps/web/lib/review/accept-preview.ts | 49 +++++++++++++++++++++ 5 files changed, 136 insertions(+), 5 deletions(-) create mode 100644 apps/web/lib/review/accept-preview.test.ts create mode 100644 apps/web/lib/review/accept-preview.ts diff --git a/PROGRESS.md b/PROGRESS.md index a803a16..15718a1 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -177,3 +177,4 @@ T0.1 monorepo 骨架 ✅ @devops · T0.2 16 MVP 表迁移(无漂移,users st - [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。 +- [2026-06-20] @frontend — **UX R3 完成**(验收前清单 HITL gate,UX §7.4,纯前端无后端改,续 `feat/ux-r1-r2-review-ergonomics`)。新纯函数 `lib/review/accept-preview.ts`(`buildAcceptPreview`:据现有 client state 列「本次验收将更新」——晋升版次 + 提炼章节摘要 + 写回裁决(有冲突时) + 验收后伏笔到期扫描(可能置 OVERDUE) + 伏笔建议待登记提醒(有建议时))。**只列后端 accept 事务确实做的事**(核对 `projects.py` accept 端点:promote+digest+decisions+overdue 扫描;**不**声称改人物 latest_state/自动落库伏笔建议);带「以上为预期,最终以验收回执为准」免责。`AcceptPanel` 加 `foreshadowCount` prop + 未阻断时渲染清单,`ReviewReport` 传 `foreshadow.length`。TDD:4 测。前端门禁绿:lint/tsc/**vitest 205**(+4)/build。下一步(按 plan):Tier4(T4-a 顶部 AI 工具条 / T4-b 本章指令框+风格预设)。 diff --git a/apps/web/components/review/AcceptPanel.tsx b/apps/web/components/review/AcceptPanel.tsx index 410540f..d6a75ae 100644 --- a/apps/web/components/review/AcceptPanel.tsx +++ b/apps/web/components/review/AcceptPanel.tsx @@ -1,10 +1,13 @@ "use client"; import type { AcceptResponse } from "@/lib/api/types"; +import { buildAcceptPreview } from "@/lib/review/accept-preview"; interface AcceptPanelProps { conflictCount: number; unresolvedCount: number; + // R3:当前伏笔建议数(验收前清单提醒,只读不自动落库)。 + foreshadowCount: number; accepting: boolean; result: AcceptResponse | null; onAccept: () => void; @@ -14,11 +17,16 @@ interface AcceptPanelProps { export function AcceptPanel({ conflictCount, unresolvedCount, + foreshadowCount, accepting, result, onAccept, }: AcceptPanelProps) { const blocked = unresolvedCount > 0; + const preview = buildAcceptPreview({ + conflictCount, + foreshadowSuggestions: foreshadowCount, + }); if (result) { return ( @@ -56,11 +64,35 @@ export function AcceptPanel({ 尚有 {unresolvedCount} 项冲突未裁决,处理完才能验收。

) : ( -

- {conflictCount === 0 - ? "无冲突,可直接验收。" - : "全部冲突已裁决,可验收本章。"} -

+ <> +

+ {conflictCount === 0 + ? "无冲突,可直接验收。" + : "全部冲突已裁决,可验收本章。"} +

+ {/* R3:验收前「本次将更新」清单(预期,落库口径以验收回执为准)。 */} +
+

+ 本次验收将更新 +

+
    + {preview.map((item) => ( +
  • + + + {item.label} + :{item.detail} + +
  • + ))} +
+

+ 以上为预期,最终以验收回执为准。 +

+
+ )}