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

@@ -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([]);
});
});