From 29265fa0fd5b32a389f16ac755653e0727bc316a Mon Sep 17 00:00:00 2001 From: Yaojia Wang Date: Mon, 22 Jun 2026 18:28:29 +0200 Subject: [PATCH] =?UTF-8?q?feat(web):=20=E5=AE=A1=E7=A8=BF=E6=94=B9?= =?UTF-8?q?=E7=A8=BF=E9=97=AD=E7=8E=AF=E2=80=94=E2=80=94=E4=B8=80=E9=94=AE?= =?UTF-8?q?=E9=87=87=E7=BA=B3/AI=E6=94=B9=E5=86=99/=E6=AD=A3=E6=96=87?= =?UTF-8?q?=E9=AB=98=E4=BA=AE=E5=AE=9A=E4=BD=8D=E7=BC=96=E8=BE=91/?= =?UTF-8?q?=E4=BC=8F=E7=AC=94=E7=A1=AE=E8=AE=A4/=E8=8D=89=E7=A8=BF?= =?UTF-8?q?=E8=87=AA=E5=8A=A8=E4=BF=9D=E5=AD=98/=E7=AB=A0=E8=8A=82?= =?UTF-8?q?=E7=9B=AE=E5=BD=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 冲突「采纳改法」:有补丁瞬时改入终稿并选中;无补丁但可定位则采纳时调 AI(refiner)重写该段套入;定位不到提示手改 - 正文改为行内高亮叠层编辑器(与页面同底色、随内容增高、无滚动条),点冲突卡/锚点整页滚到并选中+闪烁 - 仅对可在正文定位的冲突显示「跳转」/锚点;locate 支持从 where 引号抽原文,杜绝定位失败刷屏 - 伏笔建议卡:新埋一键登记(预填表单)/ 疑似回收一键标记 CLOSED(复用现有端点) - 审稿页草稿自动保存(防抖 PUT /draft),与「验收本章」解耦,刷新不丢 - 写作路由带章号 ?chapter=N + 验收成功面板/大纲页「写下一章」入口;写作目录从大纲列出全部章节 Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/web/app/globals.css | 23 ++ apps/web/app/projects/[id]/write/page.tsx | 33 ++- .../components/outline/OutlineChapterRow.tsx | 13 +- apps/web/components/outline/OutlineEditor.tsx | 6 +- apps/web/components/review/AcceptPanel.tsx | 21 ++ apps/web/components/review/AnnotatedText.tsx | 108 ++++++-- apps/web/components/review/ConflictCard.tsx | 27 +- .../review/ForeshadowSuggestions.tsx | 261 ++++++++++++++++-- apps/web/components/review/ReviewReport.tsx | 182 ++++++++++-- apps/web/components/workbench/ChapterList.tsx | 75 ++++- apps/web/components/workbench/Workbench.tsx | 29 +- apps/web/lib/foreshadow/board.test.ts | 14 + apps/web/lib/foreshadow/board.ts | 11 + apps/web/lib/generation/cards.test.ts | 18 +- apps/web/lib/generation/cards.ts | Bin 5668 -> 5945 bytes apps/web/lib/review/applyFix.test.ts | 44 +++ apps/web/lib/review/applyFix.ts | 42 +++ apps/web/lib/review/grouping.test.ts | 9 +- apps/web/lib/review/history.test.ts | 27 +- apps/web/lib/review/history.ts | 3 + apps/web/lib/review/locate.test.ts | 54 ++++ apps/web/lib/review/locate.ts | 66 +++++ apps/web/lib/review/sse.test.ts | 40 ++- apps/web/lib/review/sse.ts | 5 + apps/web/lib/workbench/chapter.test.ts | 55 ++++ apps/web/lib/workbench/chapter.ts | 33 +++ 26 files changed, 1093 insertions(+), 106 deletions(-) create mode 100644 apps/web/lib/review/applyFix.test.ts create mode 100644 apps/web/lib/review/applyFix.ts create mode 100644 apps/web/lib/review/locate.test.ts create mode 100644 apps/web/lib/review/locate.ts create mode 100644 apps/web/lib/workbench/chapter.test.ts diff --git a/apps/web/app/globals.css b/apps/web/app/globals.css index 0b18650..deeb5bb 100644 --- a/apps/web/app/globals.css +++ b/apps/web/app/globals.css @@ -40,6 +40,25 @@ body { transition: background-color 0.15s ease; } +/* 终稿正文:冲突原文持续高亮(朱砂淡底),点锚点定位时闪烁一下(UX §8.3)。 */ +.draft-mark { + background-color: #b5543a26; /* --color-conflict @ ~15% */ + border-radius: 2px; +} +.draft-mark-flash { + animation: draft-mark-flash 0.9s ease-out; +} + +@keyframes draft-mark-flash { + 0%, + 100% { + background-color: #b5543a26; + } + 30% { + background-color: #b5543a8c; /* --color-conflict @ ~55% */ + } +} + @media (prefers-reduced-motion: reduce) { .typewriter-cursor { animation: none; @@ -47,4 +66,8 @@ body { .conflict-anchor { transition: none; } + .draft-mark-flash { + animation: none; + outline: 2px solid var(--color-conflict); + } } diff --git a/apps/web/app/projects/[id]/write/page.tsx b/apps/web/app/projects/[id]/write/page.tsx index 6f653be..a481539 100644 --- a/apps/web/app/projects/[id]/write/page.tsx +++ b/apps/web/app/projects/[id]/write/page.tsx @@ -1,19 +1,23 @@ import { notFound } from "next/navigation"; import { Workbench } from "@/components/workbench/Workbench"; -import { fetchDraft, fetchProject } from "@/lib/api/server"; +import { fetchDraft, fetchOutline, fetchProject } from "@/lib/api/server"; import type { ProjectResponse } from "@/lib/api/types"; -import { WORKBENCH_CHAPTER_NO } from "@/lib/workbench/chapter"; +import { parseChapterParam, type ChapterEntry } from "@/lib/workbench/chapter"; interface PageProps { params: Promise<{ id: string }>; + searchParams: Promise<{ chapter?: string | string[] }>; } -// 写作工作台页(UX §6.3)。Server Component 取项目 + 当前章已存草稿(GET .../draft, +// 写作工作台页(UX §6.3)。Server Component 取项目 + 该章已存草稿(GET .../draft, // 404→null=空编辑器),把正文作为编辑器初值种入,重访时重载已写章节;Workbench -// 负责编辑/流式/保存(流式照常覆盖初值)。 -export default async function WritePage({ params }: PageProps) { +// 负责编辑/流式/保存(流式照常覆盖初值)。章号由 `?chapter=N` 指定(缺省=第 1 章), +// 支持写下一章。 +export default async function WritePage({ params, searchParams }: PageProps) { const { id } = await params; + const { chapter } = await searchParams; + const chapterNo = parseChapterParam(chapter); // 仅当项目确实取不到时才判 404;草稿/瞬时错误不应把整页变成「找不到页面」。 let project: ProjectResponse; @@ -23,14 +27,27 @@ export default async function WritePage({ params }: PageProps) { notFound(); } - // 草稿读取失败(后端瞬时错误 / 尚无草稿)→ 空编辑器初值,绝不 404。 + // 草稿读取失败(后端瞬时错误 / 新章尚无草稿)→ 空编辑器初值,绝不 404。 let initialText = ""; try { - const draft = await fetchDraft(id, WORKBENCH_CHAPTER_NO); + const draft = await fetchDraft(id, chapterNo); initialText = draft?.content ?? ""; } catch { initialText = ""; } - return ; + // 大纲章节作目录(fetchOutline 已对空/错误降级为 []);首个节拍当短标题。 + const chapters: ChapterEntry[] = (await fetchOutline(id)).map((c) => ({ + no: c.no, + title: c.beats?.[0], + })); + + return ( + + ); } diff --git a/apps/web/components/outline/OutlineChapterRow.tsx b/apps/web/components/outline/OutlineChapterRow.tsx index 675fb5d..8f992a5 100644 --- a/apps/web/components/outline/OutlineChapterRow.tsx +++ b/apps/web/components/outline/OutlineChapterRow.tsx @@ -1,5 +1,7 @@ "use client"; +import Link from "next/link"; + import type { OutlineChapterView } from "@/lib/api/types"; import { isCloseWindow, @@ -8,11 +10,12 @@ import { interface OutlineChapterRowProps { chapter: OutlineChapterView; + projectId: string; } -// 单章大纲行(UX §6.7):章号 + beats + 伏笔窗口徽标(⚑)。 +// 单章大纲行(UX §6.7):章号 + beats + 伏笔窗口徽标(⚑)+ 「写此章」入口。 // 接近回收窗口的徽标用琥珀 + 「可回收」提示(a11y:图标 + 文案,不单靠色)。 -export function OutlineChapterRow({ chapter }: OutlineChapterRowProps) { +export function OutlineChapterRow({ chapter, projectId }: OutlineChapterRowProps) { const windows = chapter.foreshadow_windows ?? []; return (
  • @@ -20,6 +23,12 @@ export function OutlineChapterRow({ chapter }: OutlineChapterRowProps) { 第 {chapter.no} 章 + + ✍ 写此章 + {windows.map((w) => { const close = isCloseWindow(chapter, w); return ( diff --git a/apps/web/components/outline/OutlineEditor.tsx b/apps/web/components/outline/OutlineEditor.tsx index c9242da..4f5f367 100644 --- a/apps/web/components/outline/OutlineEditor.tsx +++ b/apps/web/components/outline/OutlineEditor.tsx @@ -84,7 +84,11 @@ export function OutlineEditor({
      {group.chapters.map((ch) => ( - + ))}
    diff --git a/apps/web/components/review/AcceptPanel.tsx b/apps/web/components/review/AcceptPanel.tsx index ca361ea..0e5d249 100644 --- a/apps/web/components/review/AcceptPanel.tsx +++ b/apps/web/components/review/AcceptPanel.tsx @@ -1,10 +1,14 @@ "use client"; +import Link from "next/link"; + import type { AcceptResponse } from "@/lib/api/types"; import { buildAcceptPreview } from "@/lib/review/accept-preview"; import { ThinkingIndicator } from "@/components/ThinkingIndicator"; interface AcceptPanelProps { + projectId: string; + chapterNo: number; conflictCount: number; unresolvedCount: number; // R3:当前伏笔建议数(验收前清单提醒,只读不自动落库)。 @@ -16,6 +20,8 @@ interface AcceptPanelProps { // 验收 gate(UX §9 / §8.4):未决禁验收(置灰 + 文案),验收后呈现「本次将更新」清单。 export function AcceptPanel({ + projectId, + chapterNo, conflictCount, unresolvedCount, foreshadowCount, @@ -54,6 +60,21 @@ export function AcceptPanel({
  • ) : null} + {/* 验收闭环 → 写下一章入口(修「验收后无路可走」的断点)。 */} +
    + + → 写第 {chapterNo + 1} 章 + + + 返回大纲 + +
    ); } diff --git a/apps/web/components/review/AnnotatedText.tsx b/apps/web/components/review/AnnotatedText.tsx index d6178fd..7d70417 100644 --- a/apps/web/components/review/AnnotatedText.tsx +++ b/apps/web/components/review/AnnotatedText.tsx @@ -1,30 +1,79 @@ "use client"; +import { useRef, type ReactNode, type RefObject } from "react"; + +import { locateInDraft } from "@/lib/review/locate"; import type { ReviewConflict } from "@/lib/review/sse"; interface AnnotatedTextProps { - text: string; + value: string; + onChange: (next: string) => void; conflicts: ReviewConflict[]; - // 当前聚焦的冲突下标(来自报告卡「跳转」),用于锚点联动。 + // 当前聚焦的冲突下标(来自报告卡「跳转」/锚点),用于锚点高亮联动。 focusedIndex: number | null; onAnchorClick: (index: number) => void; + // 转发给可编辑 textarea,供父组件 setSelectionRange 定位/高亮问题区域。 + editorRef?: RefObject; } -// 正文就地标注(UX §8.3 / §6.1)。 -// M2 占位(R6):`where` 是文字定位(如「第4段」),M2 不做精确字符 range — -// 改为段级/锚点联动:每个冲突渲染为一枚朱砂波浪线锚点挂在正文上方,点击=回跳报告卡。 -// 精确 inline range 标注留 M3+(需后端给字符 offset)。 +// 高亮叠层编辑器:背景层渲染带 高亮的正文,前景透明 textarea 负责编辑。 +// 两层必须共享同一盒模型/排版常量,否则高亮与文字像素错位。 +const TYPO = + "box-border w-full font-serif text-[17px] leading-[1.9] tracking-normal whitespace-pre-wrap break-words"; + +// 把终稿按已定位的冲突区间切成 [纯文本 | | …]; 带 id 供父组件滚动定位。 +// 区间按 start 排序,重叠区间保留先到者(跳过后者),避免标签嵌套。 +function buildSegments(value: string, conflicts: ReviewConflict[]): ReactNode[] { + const spans = conflicts + .map((c, i) => ({ i, loc: locateInDraft(value, c) })) + .filter( + (x): x is { i: number; loc: { start: number; end: number } } => + x.loc !== null, + ) + .sort((a, b) => a.loc.start - b.loc.start); + + const nodes: ReactNode[] = []; + let cursor = 0; + for (const { i, loc } of spans) { + if (loc.start < cursor) continue; // 与已保留区间重叠 → 跳过 + if (loc.start > cursor) nodes.push(value.slice(cursor, loc.start)); + nodes.push( + + {value.slice(loc.start, loc.end)} + , + ); + cursor = loc.end; + } + if (cursor < value.length) nodes.push(value.slice(cursor)); + return nodes; +} + +// 正文就地编辑 + 冲突锚点(UX §8.3 / §6.1)。 +// 顶部一排朱砂锚点:每条冲突一枚,点击=定位到正文对应区域(父组件在 textarea 选中并滚动)。 +// 正文本体=高亮叠层编辑器:可直接改稿;冲突原文以朱砂淡底持续高亮,点锚点滚到并闪烁。 export function AnnotatedText({ - text, + value, + onChange, conflicts, focusedIndex, onAnchorClick, + editorRef, }: AnnotatedTextProps) { + const backdropRef = useRef(null); + const segments = buildSegments(value, conflicts); + // 只为能在正文里定位到的冲突渲染锚点(其余如涉及摘要/跨章的冲突不在正文,点了也定位不到)。 + const anchorable = conflicts + .map((c, i) => ({ c, i })) + .filter(({ c }) => locateInDraft(value, c) !== null); return ( -
    - {conflicts.length > 0 ? ( -
    - {conflicts.map((c, i) => ( +
    + {anchorable.length > 0 ? ( +
    + {anchorable.map(({ c, i }) => (
    ) : null} -
    - {text || ( - (无待审正文,先去写作页起草本章) - )} -
    + +
    + {/* 背景高亮层:在文档流中决定高度(撑满全文),仅展示(不可交互)。 */} + + {/* 前景编辑层:文字透明(由背景层显示),仅保留光标;无内部滚动(页面滚动)。 */} +