feat(web): 审稿改稿闭环——一键采纳/AI改写/正文高亮定位编辑/伏笔确认/草稿自动保存/章节目录

- 冲突「采纳改法」:有补丁瞬时改入终稿并选中;无补丁但可定位则采纳时调 AI(refiner)重写该段套入;定位不到提示手改
- 正文改为行内高亮叠层编辑器(与页面同底色、随内容增高、无滚动条),点冲突卡/锚点整页滚到并选中+闪烁
- 仅对可在正文定位的冲突显示「跳转」/锚点;locate 支持从 where 引号抽原文,杜绝定位失败刷屏
- 伏笔建议卡:新埋一键登记(预填表单)/ 疑似回收一键标记 CLOSED(复用现有端点)
- 审稿页草稿自动保存(防抖 PUT /draft),与「验收本章」解耦,刷新不丢
- 写作路由带章号 ?chapter=N + 验收成功面板/大纲页「写下一章」入口;写作目录从大纲列出全部章节

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Yaojia Wang
2026-06-22 18:28:29 +02:00
parent d028c790c9
commit 29265fa0fd
26 changed files with 1093 additions and 106 deletions

View File

@@ -7,9 +7,23 @@ import {
extractReason,
groupByStatus,
isWindowApproaching,
suggestForeshadowCode,
validationReasonMessage,
} from "./board";
describe("suggestForeshadowCode", () => {
it("prefers the suggestion's own code when present", () => {
expect(suggestForeshadowCode("F-007", 3)).toBe("F-007");
expect(suggestForeshadowCode(" F-008 ", 3)).toBe("F-008");
});
it("falls back to FS-<2-digit index> when code is empty/null", () => {
expect(suggestForeshadowCode(null, 0)).toBe("FS-01");
expect(suggestForeshadowCode("", 4)).toBe("FS-05");
expect(suggestForeshadowCode(" ", 11)).toBe("FS-12");
});
});
const view = (over: Partial<ForeshadowView>): ForeshadowView => ({
code: "F-001",
title: "线索",

View File

@@ -59,6 +59,17 @@ export function isWindowApproaching(
return currentChapter >= from && currentChapter <= to;
}
// 为「新埋待确认」建议预填一个建议代号:优先用建议自带 code否则 FS-<两位序号>。
// 纯函数(可单测);作者可在登记表单里改。
export function suggestForeshadowCode(
code: string | null | undefined,
index: number,
): string {
const trimmed = code?.trim();
if (trimmed) return trimmed;
return `FS-${String(index + 1).padStart(2, "0")}`;
}
export interface RegisterInput {
projectId: string;
code: string;

View File

@@ -105,8 +105,22 @@ describe("extractIngestConflicts", () => {
expect(out).toEqual({
conflictCount: 2,
conflicts: [
{ type: "性格漂移", where: "第3章", refs: ["甲"], suggestion: "改" },
{ type: "未分类", where: "", refs: [], suggestion: "" },
{
type: "性格漂移",
where: "第3章",
refs: ["甲"],
suggestion: "改",
original: null,
replacement: null,
},
{
type: "未分类",
where: "",
refs: [],
suggestion: "",
original: null,
replacement: null,
},
],
});
});

Binary file not shown.

View File

@@ -0,0 +1,44 @@
import { describe, expect, it } from "vitest";
import { applyConflictFix, hasApplicableFix } from "./applyFix";
describe("hasApplicableFix", () => {
it("requires non-empty original and string replacement", () => {
expect(hasApplicableFix("迈巴赫", "奔驰")).toBe(true);
expect(hasApplicableFix("删我", "")).toBe(true); // 空串=删除,仍可应用
expect(hasApplicableFix(null, "奔驰")).toBe(false);
expect(hasApplicableFix("", "奔驰")).toBe(false);
expect(hasApplicableFix("迈巴赫", null)).toBe(false);
expect(hasApplicableFix(undefined, undefined)).toBe(false);
});
});
describe("applyConflictFix", () => {
it("replaces the first occurrence of original with replacement", () => {
const out = applyConflictFix(
"他乘坐迈巴赫扬长而去,迈巴赫的尾灯。",
"迈巴赫",
"奔驰",
);
expect(out.status).toBe("applied");
expect(out.text).toBe("他乘坐奔驰扬长而去,迈巴赫的尾灯。");
});
it("returns not-found and leaves text untouched when original is absent (drift)", () => {
const out = applyConflictFix("作者已经手改过的终稿。", "迈巴赫", "奔驰");
expect(out.status).toBe("not-found");
expect(out.text).toBe("作者已经手改过的终稿。");
});
it("returns no-patch when conflict carries no applicable patch", () => {
const out = applyConflictFix("正文", null, null);
expect(out.status).toBe("no-patch");
expect(out.text).toBe("正文");
});
it("does not mutate the input string", () => {
const original = "迈巴赫尾灯";
applyConflictFix(original, "迈巴赫", "奔驰");
expect(original).toBe("迈巴赫尾灯");
});
});

View File

@@ -0,0 +1,42 @@
// 一键采纳改法把审稿提议的补丁original→replacement应用到终稿。
// 纯逻辑(可单测,不依赖 DOM。粒度=最小句段;找不到原文则不动正文(作者手改)。
export type FixOutcome =
| { status: "applied"; text: string } // 命中原文并替换text=改后终稿
| { status: "not-found"; text: string } // 有补丁但终稿里找不到原文(作者已改稿/漂移text 原样
| { status: "no-patch"; text: string }; // 该冲突无可自动应用的补丁text 原样
// 是否带可应用补丁original 非空 + replacement 为字符串(容许空串=删除)。
export function hasApplicableFix(
original: string | null | undefined,
replacement: string | null | undefined,
): boolean {
return (
typeof original === "string" &&
original.length > 0 &&
typeof replacement === "string"
);
}
// 把首个匹配的 original 替换为 replacement不可变返回新串不改入参
// 无补丁 → no-patch有补丁但未命中 → not-found终稿不变命中 → applied。
export function applyConflictFix(
text: string,
original: string | null | undefined,
replacement: string | null | undefined,
): FixOutcome {
if (!hasApplicableFix(original, replacement)) {
return { status: "no-patch", text };
}
// hasApplicableFix 已收窄为 string。
const from = original as string;
const to = replacement as string;
const idx = text.indexOf(from);
if (idx === -1) {
return { status: "not-found", text };
}
return {
status: "applied",
text: text.slice(0, idx) + to + text.slice(idx + from.length),
};
}

View File

@@ -9,7 +9,14 @@ import {
import type { ReviewConflict } from "./sse";
function conflict(type: string, suggestion = "x"): ReviewConflict {
return { type, where: "", refs: [], suggestion };
return {
type,
where: "",
refs: [],
suggestion,
original: null,
replacement: null,
};
}
describe("groupConflicts", () => {

View File

@@ -31,8 +31,22 @@ describe("normalizeConflicts", () => {
]),
);
expect(out).toEqual([
{ type: "设定违例", where: "第4段", refs: ["第30章"], suggestion: "统一血脉" },
{ type: "时间线倒错", where: "第8段", refs: [], suggestion: "调整" },
{
type: "设定违例",
where: "第4段",
refs: ["第30章"],
suggestion: "统一血脉",
original: null,
replacement: null,
},
{
type: "时间线倒错",
where: "第8段",
refs: [],
suggestion: "调整",
original: null,
replacement: null,
},
]);
});
@@ -41,7 +55,14 @@ describe("normalizeConflicts", () => {
item([{ type: "", where: "", suggestion: "" }]),
);
expect(out).toEqual([
{ type: "未分类", where: "", refs: [], suggestion: "" },
{
type: "未分类",
where: "",
refs: [],
suggestion: "",
original: null,
replacement: null,
},
]);
});

View File

@@ -34,6 +34,9 @@ export function normalizeConflicts(
where: c.where,
refs: c.refs ?? [],
suggestion: c.suggestion,
// 一键采纳补丁对留痕里带则透传供「采纳改法」find-replace 进终稿(缺省 null
original: c.original ?? null,
replacement: c.replacement ?? null,
}));
}

View File

@@ -0,0 +1,54 @@
import { describe, expect, it } from "vitest";
import { extractDraftQuotes, locateInDraft } from "./locate";
const TEXT = "第一段没问题。\n\n他乘坐迈巴赫扬长而去留下一地尘土。\n\n结尾。";
describe("locateInDraft", () => {
it("locates the exact original fragment", () => {
const loc = locateInDraft(TEXT, { original: "迈巴赫", where: "第2段" });
expect(loc).not.toBeNull();
expect(TEXT.slice(loc!.start, loc!.end)).toBe("迈巴赫");
});
it("falls back to the paragraph snippet when no original", () => {
const loc = locateInDraft(TEXT, { original: null, where: "第2段" });
expect(loc).not.toBeNull();
expect(TEXT.slice(loc!.start, loc!.end)).toBe(
"他乘坐迈巴赫扬长而去,留下一地尘土。",
);
});
it("returns null when original is absent from text and no paragraph hint", () => {
expect(
locateInDraft(TEXT, { original: "保时捷", where: "无段号" }),
).toBeNull();
});
it("returns null when the snippet paragraph is out of range", () => {
expect(locateInDraft(TEXT, { original: null, where: "第9段" })).toBeNull();
});
it("locates via quoted draft text inside where (no original, no 段号)", () => {
// 真实形态where = 草稿中写"<草稿原文>",引号内即正文精确子串。
const loc = locateInDraft(TEXT, {
original: null,
where: '草稿中写"迈巴赫扬长而去"',
});
expect(loc).not.toBeNull();
expect(TEXT.slice(loc!.start, loc!.end)).toBe("迈巴赫扬长而去");
});
});
describe("extractDraftQuotes", () => {
it("extracts quoted spans (multiple quote styles) longest-first, deduped", () => {
expect(extractDraftQuotes('草稿中写"长一些的原文",又如「短的」')).toEqual([
"长一些的原文",
"短的",
]);
});
it("returns empty when there are no quotes", () => {
expect(extractDraftQuotes("第3段主角忽然示弱")).toEqual([]);
});
});

View File

@@ -0,0 +1,66 @@
// 在终稿里定位某条冲突对应的正文区间(供编辑器 setSelectionRange 高亮)。
// 纯逻辑(可单测,不依赖 DOM。定位优先级精确 original → where 引号内原文 → 段级 snippet。
import type { ReviewConflict } from "./sse";
import { conflictSnippet } from "./snippet";
export interface DraftRange {
start: number;
end: number;
}
// 抽取一段文本里所有成对引号内的内容(「」/“”// 直双引号 / 直单引号)。
// where 多形如 草稿中写"<草稿原文>" —— 引号里就是正文精确子串,是最可靠的定位锚。
// 返回去重后按长度降序(长串更具体、误命中概率低)。
const QUOTE_PATTERNS: RegExp[] = [
/「([^」]{2,})」/g,
/“([^”]{2,})”/g,
/"([^"]{2,})"/g,
/([^]{2,})/g,
/'([^']{2,})'/g,
];
export function extractDraftQuotes(where: string): string[] {
const found = new Set<string>();
for (const re of QUOTE_PATTERNS) {
for (const m of where.matchAll(re)) {
const inner = m[1]?.trim();
if (inner && inner.length >= 2) found.add(inner);
}
}
return [...found].sort((a, b) => b.length - a.length);
}
// 返回冲突在 text 中的字符区间;定位不到(无 original/引号/段号,或已被改动)→ null。
export function locateInDraft(
text: string,
conflict: Pick<ReviewConflict, "original" | "where">,
): DraftRange | null {
// 1) 精确原文片段(审稿提议的 original逐字匹配——最可靠。
if (conflict.original) {
const idx = text.indexOf(conflict.original);
if (idx !== -1) {
return { start: idx, end: idx + conflict.original.length };
}
}
// 2) 从 where 的引号里抽出草稿原文逐字匹配(覆盖 `草稿中写"…"` 这类定位)。
for (const quote of extractDraftQuotes(conflict.where)) {
const idx = text.indexOf(quote);
if (idx !== -1) {
return { start: idx, end: idx + quote.length };
}
}
// 3) 回退:按 where 段号取段级预览定位该段。snippet 超长会带省略号,
// 去掉末尾「…」后用其前缀(仍是该段真实前缀)做 indexOf。
const snippet = conflictSnippet(text, conflict.where);
if (snippet) {
const needle = snippet.endsWith("…") ? snippet.slice(0, -1) : snippet;
if (needle.length > 0) {
const idx = text.indexOf(needle);
if (idx !== -1) {
return { start: idx, end: idx + needle.length };
}
}
}
return null;
}

View File

@@ -30,6 +30,8 @@ describe("parseReviewBlock", () => {
where: "第4段",
refs: ["第30章"],
suggestion: "统一血脉设定",
original: null,
replacement: null,
},
});
});
@@ -108,6 +110,8 @@ describe("parseReviewBlock", () => {
where: "第1段",
refs: ["第3章"],
suggestion: "s",
original: null,
replacement: null,
},
});
expect(
@@ -142,7 +146,14 @@ describe("ReviewFrameBuffer", () => {
expect(second).toEqual([
{
event: "conflict",
data: { type: "性格漂移", where: "第2段", refs: [], suggestion: "x" },
data: {
type: "性格漂移",
where: "第2段",
refs: [],
suggestion: "x",
original: null,
replacement: null,
},
},
]);
});
@@ -165,11 +176,25 @@ describe("reduceReview", () => {
const events: ReviewSseEvent[] = [
{
event: "conflict",
data: { type: "设定违例", where: "a", refs: [], suggestion: "s1" },
data: {
type: "设定违例",
where: "a",
refs: [],
suggestion: "s1",
original: null,
replacement: null,
},
},
{
event: "conflict",
data: { type: "时间线倒错", where: "b", refs: ["第3章"], suggestion: "s2" },
data: {
type: "时间线倒错",
where: "b",
refs: ["第3章"],
suggestion: "s2",
original: null,
replacement: null,
},
},
];
const state = events.reduce(reduceReview, initialReviewState);
@@ -222,7 +247,14 @@ describe("reduceReview", () => {
let e = reduceReview(initialReviewState, {
event: "conflict",
data: { type: "能力不符", where: "x", refs: [], suggestion: "y" },
data: {
type: "能力不符",
where: "x",
refs: [],
suggestion: "y",
original: null,
replacement: null,
},
});
e = reduceReview(e, {
event: "error",

View File

@@ -15,6 +15,9 @@ export interface ReviewConflict {
where: string;
refs: string[];
suggestion: string;
// 一键采纳补丁对(可缺):可局部修复时审稿提议,前端「采纳改法」据此 find-replace 进终稿。
original: string | null;
replacement: string | null;
}
export type SectionStatus = "started" | "done" | "incomplete";
@@ -197,6 +200,8 @@ function narrowReviewEvent(event: string, data: unknown): ReviewSseEvent | null
where: data.where,
refs: asStringArray(data.refs),
suggestion: data.suggestion,
original: nullableString(data.original),
replacement: nullableString(data.replacement),
},
}
: null;

View File

@@ -0,0 +1,55 @@
import { describe, expect, it } from "vitest";
import {
WORKBENCH_CHAPTER_NO,
buildChapterEntries,
parseChapterParam,
} from "./chapter";
describe("parseChapterParam", () => {
it("parses a valid positive chapter number", () => {
expect(parseChapterParam("2")).toBe(2);
expect(parseChapterParam("17")).toBe(17);
});
it("takes the first value when given an array", () => {
expect(parseChapterParam(["3", "9"])).toBe(3);
});
it("falls back to chapter 1 for missing/invalid/<1 values", () => {
expect(parseChapterParam(undefined)).toBe(WORKBENCH_CHAPTER_NO);
expect(parseChapterParam("")).toBe(WORKBENCH_CHAPTER_NO);
expect(parseChapterParam("abc")).toBe(WORKBENCH_CHAPTER_NO);
expect(parseChapterParam("0")).toBe(WORKBENCH_CHAPTER_NO);
expect(parseChapterParam("-4")).toBe(WORKBENCH_CHAPTER_NO);
});
});
describe("buildChapterEntries", () => {
it("dedupes by no and sorts ascending", () => {
const out = buildChapterEntries(
[
{ no: 3, title: "c3" },
{ no: 1, title: "c1" },
{ no: 3, title: "dup" },
],
1,
);
expect(out.map((e) => e.no)).toEqual([1, 3]);
expect(out[1]?.title).toBe("c3"); // first wins on duplicate
});
it("always includes the current chapter even if beyond the outline", () => {
const out = buildChapterEntries([{ no: 1 }, { no: 2 }], 5);
expect(out.map((e) => e.no)).toEqual([1, 2, 5]);
});
it("returns just the current chapter when the outline is empty", () => {
expect(buildChapterEntries([], 1)).toEqual([{ no: 1 }]);
});
it("drops invalid chapter numbers from the outline", () => {
const out = buildChapterEntries([{ no: 0 }, { no: -2 }, { no: 2 }], 1);
expect(out.map((e) => e.no)).toEqual([1, 2]);
});
});

View File

@@ -3,3 +3,36 @@
// 若从客户端组件Workbench.tsx"use client"导入Next 会把它替换成客户端引用代理,
// 服务端使用时变成抛错的函数 → draft URL 变成 `/chapters/function(){…}/draft`422
export const WORKBENCH_CHAPTER_NO = 1;
// 从写作路由的 `?chapter=N` 解析章号:取正整数,非法/缺省/<1 → 默认第 1 章。
// 纯函数(可单测),供 write/page.tsxServer Component解析 searchParams。
export function parseChapterParam(raw: string | string[] | undefined): number {
const value = Array.isArray(raw) ? raw[0] : raw;
if (value === undefined) return WORKBENCH_CHAPTER_NO;
const n = Number.parseInt(value, 10);
return Number.isInteger(n) && n >= 1 ? n : WORKBENCH_CHAPTER_NO;
}
// 目录条目:章号 + 可选短标题(大纲无 title用首个节拍当摘要
export interface ChapterEntry {
no: number;
title?: string;
}
// 合并大纲章节与「当前章」成目录:按章号去重 + 升序;当前章即使超出大纲也必含
// (否则在写大纲外的新章时会从目录里消失,作者迷路)。纯函数(可单测)。
export function buildChapterEntries(
chapters: readonly ChapterEntry[],
currentChapterNo: number,
): ChapterEntry[] {
const byNo = new Map<number, ChapterEntry>();
for (const c of chapters) {
if (Number.isInteger(c.no) && c.no >= 1 && !byNo.has(c.no)) {
byNo.set(c.no, { no: c.no, title: c.title });
}
}
if (!byNo.has(currentChapterNo)) {
byNo.set(currentChapterNo, { no: currentChapterNo });
}
return [...byNo.values()].sort((a, b) => a.no - b.no);
}