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