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:
Yaojia Wang
2026-06-20 15:53:45 +02:00
parent 6f419b35ed
commit d81583d5d1
7 changed files with 329 additions and 22 deletions

View 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();
});
});

View 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;
}

View 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 第 46 段", () => {
expect(parseParagraphNo("第 46 段反复描写天气")).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);
});
});

View 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范围「第 46 段」取首个)。无则 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)}`;
}