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

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 18:28:29 +02:00

596 lines
22 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use client";
import { useEffect, useMemo, useRef, useState } from "react";
import Link from "next/link";
import { AppShell } from "@/components/AppShell";
import type { ProjectResponse, ReviewHistoryItem } from "@/lib/api/types";
import { friendlyError } from "@/lib/errors/messages";
import {
allResolved,
emptyDecisions,
setNote,
setVerdict,
unresolvedIndices,
type DecisionDraft,
type Verdict,
} from "@/lib/review/decisions";
import {
normalizeConflicts,
normalizeForeshadowSug,
normalizePace,
} from "@/lib/review/history";
import {
displayOrder,
groupConflicts,
nextUnresolvedInOrder,
} from "@/lib/review/grouping";
import { applyConflictFix } from "@/lib/review/applyFix";
import { locateInDraft } from "@/lib/review/locate";
import { conflictSnippet } from "@/lib/review/snippet";
import { useAccept } from "@/lib/review/useAccept";
import { useAutosave } from "@/lib/autosave/useAutosave";
import { useRefine } from "@/lib/style/useRefine";
import { useReviewStream } from "@/lib/review/useReviewStream";
import type { ReviewConflict, StyleDriftSegment } from "@/lib/review/sse";
import { normalizeStyleDrift } from "@/lib/style/style";
import { useToast } from "@/components/Toast";
import { ThinkingIndicator } from "@/components/ThinkingIndicator";
import { AcceptPanel } from "./AcceptPanel";
import { AnnotatedText } from "./AnnotatedText";
import { ConflictCard } from "./ConflictCard";
import { ForeshadowSuggestions } from "./ForeshadowSuggestions";
import { PacePanel } from "./PacePanel";
import { StylePanel } from "@/components/style/StylePanel";
import { RefineView } from "@/components/style/RefineView";
interface ReviewReportProps {
project: ProjectResponse;
chapterNo: number;
initialReview: ReviewHistoryItem | undefined;
initialDraft: string;
}
// 审稿报告页主体UX §6.4 / §8.3 / §9
// 数据源:进页用历史留痕 conflicts 种入;「重新审稿」用当前终稿跑 SSE 覆盖。
// 裁决草稿随 conflicts 长度对齐未决禁验收gate
export function ReviewReport({
project,
chapterNo,
initialReview,
initialDraft,
}: ReviewReportProps) {
const review = useReviewStream();
const accept = useAccept();
const conflictFix = useRefine(); // 采纳无补丁冲突时,临时调 AI 重写定位段
// 审稿页草稿自动保存:采纳/手改/AI 改写后防抖 PUT /draft改动随时留存、刷新不丢
// (与「验收本章」解耦——验收前也能保存,修「采纳后没保存」)。
const autosave = useAutosave(project.id, chapterNo, initialDraft);
const toast = useToast();
const [finalText, setFinalText] = useState(initialDraft);
const [drafts, setDrafts] = useState<DecisionDraft[]>(() =>
emptyDecisions(normalizeConflicts(initialReview).length),
);
const [focusedIndex, setFocusedIndex] = useState<number | null>(null);
const [missing, setMissing] = useState<Set<number>>(new Set());
// 正在 AI 改写的冲突下标(采纳无补丁 → 调 refine 重写定位段;用于卡片 loading/禁用)。
const [rewriting, setRewriting] = useState<Set<number>>(new Set());
// 可编辑终稿 textarea 引用:供「跳转」/采纳后在正文里选中定位问题区域。
const editorRef = useRef<HTMLTextAreaElement>(null);
// 回炉中的漂移段null=未打开 RefineView
const [refineSegment, setRefineSegment] = useState<StyleDriftSegment | null>(
null,
);
const seededRef = useRef(false);
// 进页一次性把历史留痕(冲突 + 伏笔建议 + 节奏)种入流状态(无需重审即可裁决/查看)。
const seededConflicts = useMemo(
() => normalizeConflicts(initialReview),
[initialReview],
);
const seededForeshadow = useMemo(
() => normalizeForeshadowSug(initialReview),
[initialReview],
);
const seededPace = useMemo(
() => normalizePace(initialReview),
[initialReview],
);
const seededStyle = useMemo(
() => normalizeStyleDrift(initialReview),
[initialReview],
);
useEffect(() => {
if (seededRef.current) return;
seededRef.current = true;
const hasSeed =
seededConflicts.length > 0 ||
seededForeshadow.length > 0 ||
seededPace !== null ||
seededStyle !== null;
if (hasSeed) {
review.seed({
conflicts: seededConflicts,
foreshadow: seededForeshadow,
pace: seededPace,
style: seededStyle,
});
}
}, [review, seededConflicts, seededForeshadow, seededPace, seededStyle]);
// 当前生效冲突 = 流状态(重审后即时更新,否则种入的历史)。
const conflicts: ReviewConflict[] = review.state.conflicts;
const foreshadow = review.state.foreshadow;
const pace = review.state.pace;
const style = review.state.style;
const sectionStatus = (name: string): string | undefined =>
review.state.sections.find((s) => s.name === name)?.status;
// 重审完成done 边沿)→ 重置裁决草稿对齐新冲突集(冲突可能整组变化)。
const conflictCount = conflicts.length;
const wasReviewingRef = useRef(false);
useEffect(() => {
if (review.state.phase === "reviewing") {
wasReviewingRef.current = true;
return;
}
if (review.state.phase === "done" && wasReviewingRef.current) {
wasReviewingRef.current = false;
setDrafts(emptyDecisions(conflictCount));
setMissing(new Set());
}
}, [review.state.phase, conflictCount]);
// 终稿改动(采纳/手改/AI 改写/直接编辑均经 setFinalText→ 防抖自动保存草稿。
// 跳过首帧:初值=已存草稿useAutosave 基线即它,无需保存。
const autosaveOnChange = autosave.onChange;
const skipFirstAutosave = useRef(true);
useEffect(() => {
if (skipFirstAutosave.current) {
skipFirstAutosave.current = false;
return;
}
autosaveOnChange(finalText);
}, [finalText, autosaveOnChange]);
const onReReview = (): void => {
setMissing(new Set());
void review.start(project.id, chapterNo, finalText);
};
const onVerdict = (index: number, verdict: Verdict): void => {
setDrafts((prev) => setVerdict(prev, index, verdict));
setMissing((prev) => {
if (!prev.has(index)) return prev;
const next = new Set(prev);
next.delete(index);
return next;
});
// 采纳改法①带预出补丁original→replacement→ 瞬时改入终稿并选中;
// ②有补丁但原文找不到(已手改/漂移)→ 提示手改;③无补丁但能在正文定位 →
// 临时调 AI 重写该段并套入;④定位不到(摘要/跨章)→ 提示手改或忽略。
if (verdict === "accept") {
const c = conflicts[index];
if (c) {
const out = applyConflictFix(finalText, c.original, c.replacement);
if (out.status === "applied") {
const at = finalText.indexOf(c.original ?? "");
setFinalText(out.text);
toast("已采纳改入终稿,验收时请复核。", "success");
if (at !== -1 && c.replacement) {
selectRegionSoon(at, at + c.replacement.length, index);
}
} else if (out.status === "not-found") {
toast("未在终稿中找到原文(可能已手改),请在左侧正文手改。", "error");
} else {
void aiFixOnAccept(index, c);
}
}
}
};
// 无预出补丁的冲突,采纳时临时调 AIrefiner重写定位到的那段原文套入终稿并选中。
// 定位不到(多为摘要/跨章问题)→ 不调 AI提示手改/忽略。复用既有 refine 端点(不变量#3
const aiFixOnAccept = async (
index: number,
c: ReviewConflict,
): Promise<void> => {
const loc = locateInDraft(finalText, c);
if (loc === null) {
toast("这条无法在正文定位(多为摘要/跨章问题),请手改或忽略。", "info");
return;
}
const prevText = finalText;
const segment = prevText.slice(loc.start, loc.end);
setRewriting((s) => new Set(s).add(index));
const res = await conflictFix.refine(
project.id,
chapterNo,
segment,
c.suggestion,
);
setRewriting((s) => {
const next = new Set(s);
next.delete(index);
return next;
});
if (!res) return; // useRefine 已就失败 toast
const at = prevText.indexOf(segment);
if (at === -1) {
toast("正文已变动,未能自动套用,请手改。", "error");
return;
}
const next =
prevText.slice(0, at) + res.refined + prevText.slice(at + segment.length);
setFinalText(next);
toast("已用 AI 改写并套入正文,请复核。", "success");
selectRegionSoon(at, at + res.refined.length, index);
};
const onNote = (index: number, note: string): void =>
setDrafts((prev) => setNote(prev, index, note));
// R2按 type 分组 + 严重度排序(仅改显示顺序,每条仍携原始 index
const groups = useMemo(() => groupConflicts(conflicts), [conflicts]);
const order = useMemo(() => displayOrder(groups), [groups]);
// 能在当前终稿里定位的冲突下标集合——只对这些显示「跳转」/锚点,避免点了定位不到刷错误提示。
const locatable = useMemo(() => {
const s = new Set<number>();
conflicts.forEach((c, i) => {
if (locateInDraft(finalText, c) !== null) s.add(i);
});
return s;
}, [conflicts, finalText]);
const jumpToCard = (index: number): void => {
setFocusedIndex(index);
document
.getElementById(`conflict-card-${index}`)
?.scrollIntoView({ behavior: "smooth", block: "center" });
};
// R2跳到显示顺序中的下一条未裁决环回全决则提示完成。
const jumpToNextUnresolved = (): void => {
const next = nextUnresolvedInOrder(order, drafts, focusedIndex);
if (next === null) {
toast("全部冲突已裁决。", "success");
return;
}
jumpToCard(next);
};
// 把命中区滚到视野(页面级 scrollIntoView无内部滚动条有对应 <mark> 用它居中,
// 否则退回滚动编辑器本体。instant 滚动,天然尊重 prefers-reduced-motion。
const scrollRegionIntoView = (index?: number): void => {
const mark =
index !== undefined
? document.getElementById(`draft-mark-${index}`)
: null;
if (mark) {
mark.scrollIntoView({ block: "center", behavior: "auto" });
} else {
editorRef.current?.scrollIntoView({ block: "center", behavior: "auto" });
}
};
// 在正文里选中 [start,end) 并滚动到视野;下一帧执行(等 value 落定)。
const selectRegionSoon = (start: number, end: number, index?: number): void => {
requestAnimationFrame(() => {
const el = editorRef.current;
if (!el) return;
el.focus();
el.setSelectionRange(start, end);
scrollRegionIntoView(index);
});
};
// 闪烁高亮 <mark>(朱砂脉冲 0.9s);尊重 prefers-reduced-motionCSS 内降级为描边)。
const flashMark = (index: number): void => {
const mark = document.getElementById(`draft-mark-${index}`);
if (!mark) return;
mark.classList.add("draft-mark-flash");
window.setTimeout(() => mark.classList.remove("draft-mark-flash"), 900);
};
// 跳转/锚点:在可编辑正文里定位、选中并滚动到该冲突对应区域 + 闪烁;定位不到则提示手动查找。
const focusRegion = (index: number): void => {
setFocusedIndex(index);
const c = conflicts[index];
const el = editorRef.current;
if (!c || !el) return;
const loc = locateInDraft(finalText, c);
if (loc === null) {
toast("未能在正文中定位该处,可能已改动,请手动查找。", "error");
return;
}
el.focus();
el.setSelectionRange(loc.start, loc.end);
scrollRegionIntoView(index);
flashMark(index);
};
const onAccept = async (): Promise<void> => {
const outcome = await accept.accept(
project.id,
chapterNo,
finalText,
drafts,
);
if (outcome.missingIndices.length > 0) {
setMissing(new Set(outcome.missingIndices));
}
};
// 终稿按空行切段(大文本:仅在 finalText 变化时重算,不在每次 render split
const finalParas = useMemo(() => finalText.split(/\n{2,}/), [finalText]);
// 漂移段 idx → 终稿对应段正文(越界则空串)。
const segmentText = (idx: number): string => finalParas[idx]?.trim() ?? "";
// 采纳:把重写段替换终稿中的原段(首个匹配),合入终稿(经既有 draft 路径)。
const onAdopt = (original: string, refined: string): void => {
setFinalText((prev) => {
const idx = prev.indexOf(original);
if (idx === -1) {
toast("未在终稿中找到原段,请手动合入。", "error");
return prev;
}
return prev.slice(0, idx) + refined + prev.slice(idx + original.length);
});
setRefineSegment(null);
toast("已合入终稿,记得验收时复核。", "success");
};
const resolved = allResolved(drafts);
const unresolved = unresolvedIndices(drafts).length;
const reviewing = review.isReviewing;
return (
<AppShell
title={`${project.title}`}
subtitle={`${chapterNo} 章 · 审稿报告`}
projectId={project.id}
activeNav="review"
>
<div className="grid h-[calc(100vh-var(--chrome,4rem))] grid-cols-1 lg:grid-cols-[1fr_24rem]">
{/* 左:终稿正文 + 就地标注 */}
<section className="flex min-w-0 flex-col bg-bg">
<div className="flex items-center gap-3 border-b border-line bg-panel px-6 py-3">
<h1 className="font-serif text-lg text-ink">
{chapterNo} 稿
</h1>
<span className="font-mono text-xs text-ink-soft">
{conflictCount}
</span>
<div className="ml-auto">
{reviewing ? (
<button
type="button"
onClick={review.stop}
className="rounded border border-conflict px-3 py-1.5 text-sm text-conflict"
>
</button>
) : (
<button
type="button"
onClick={onReReview}
className="rounded bg-cinnabar px-3 py-1.5 text-sm text-panel hover:opacity-90"
>
稿
</button>
)}
</div>
</div>
{review.state.error ? (
<ReviewErrorNote error={review.state.error} />
) : null}
<div className="flex-1 overflow-auto px-6 py-6">
<div className="mb-2 flex items-start justify-between gap-3">
<p className="text-xs text-ink-soft">
稿 稿
</p>
<span className="shrink-0 font-mono text-xs text-ink-soft">
{autosave.status === "saving"
? "保存中…"
: autosave.status === "error"
? "保存失败"
: (autosave.savedLabel ?? "")}
</span>
</div>
<AnnotatedText
value={finalText}
onChange={setFinalText}
conflicts={conflicts}
focusedIndex={focusedIndex}
onAnchorClick={focusRegion}
editorRef={editorRef}
/>
</div>
</section>
{/* 右:审稿分区 + 冲突裁决 + 验收 gate */}
<aside className="flex flex-col overflow-auto border-l border-line bg-panel px-4 py-4">
<section className="mb-4">
<h2 className="text-xs font-semibold uppercase tracking-wide text-ink-soft">
(continuity)
</h2>
<SectionStatusLine
reviewing={reviewing}
done={review.state.phase === "done"}
conflictCount={conflictCount}
sections={review.state.sections}
/>
</section>
<section>
{conflicts.length === 0 ? (
<p className="rounded border border-dashed border-line p-4 text-xs text-ink-soft">
{review.state.phase === "done" || seededConflicts.length === 0
? "未发现一致性冲突。"
: "进页未带审稿留痕,点「重新审稿」开始。"}
</p>
) : (
<div className="space-y-4">
{/* R2未裁决进度 + 跳到下一条未裁决 */}
<div className="flex items-center justify-between text-xs text-ink-soft">
<span>
{unresolved > 0 ? (
<span className="text-conflict">
{unresolved}/{conflictCount}
</span>
) : (
<span className="text-pass"> </span>
)}
</span>
<button
type="button"
onClick={jumpToNextUnresolved}
disabled={unresolved === 0}
className="rounded border border-line px-2 py-0.5 hover:border-cinnabar hover:text-cinnabar disabled:cursor-not-allowed disabled:opacity-40"
>
</button>
</div>
{/* B讲清裁决保存模型——「采纳/裁决」仅本地暂存,验收时一并落库 */}
<p className="rounded border border-dashed border-line/70 bg-bg/40 px-3 py-1.5 text-[11px] text-ink-soft">
稿
</p>
{groups.map((group) => (
<div key={group.type}>
<h3 className="mb-2 flex items-center gap-2 text-xs font-semibold text-ink">
{group.type}
<span className="rounded-full bg-[var(--color-conflict)]/10 px-2 py-0.5 font-mono text-conflict">
{group.items.length}
</span>
</h3>
<ul className="space-y-3">
{group.items.map(({ conflict: c, index: i }) => (
<ConflictCard
key={i}
index={i}
conflict={c}
draft={drafts[i] ?? { verdict: null, note: "" }}
missing={missing.has(i)}
snippet={conflictSnippet(finalText, c.where)}
locatable={locatable.has(i)}
rewriting={rewriting.has(i)}
onVerdict={onVerdict}
onNote={onNote}
onJump={focusRegion}
/>
))}
</ul>
</div>
))}
</div>
)}
</section>
<div className="mt-4 border-t border-line pt-4">
<ForeshadowSuggestions
suggestions={foreshadow}
incomplete={sectionStatus("foreshadow") === "incomplete"}
projectId={project.id}
chapterNo={chapterNo}
/>
<PacePanel
pace={pace}
incomplete={sectionStatus("pace") === "incomplete"}
/>
<StylePanel
style={style}
incomplete={sectionStatus("style") === "incomplete"}
onRefine={setRefineSegment}
/>
{refineSegment !== null ? (
<RefineView
projectId={project.id}
chapterNo={chapterNo}
segment={segmentText(refineSegment.idx)}
onAdopt={onAdopt}
onClose={() => setRefineSegment(null)}
/>
) : null}
</div>
<section className="mt-4">
<AcceptPanel
projectId={project.id}
chapterNo={chapterNo}
conflictCount={conflictCount}
unresolvedCount={resolved ? 0 : unresolved}
foreshadowCount={foreshadow.length}
accepting={accept.status === "accepting"}
result={accept.result}
onAccept={() => void onAccept()}
/>
</section>
</aside>
</div>
</AppShell>
);
}
// 审稿失败提示:友好文案(不暴露 raw code+ 可选「去设置」动作F4
function ReviewErrorNote({
error,
}: {
error: { code: string; message: string };
}) {
const friendly = friendlyError(error.code, error.message);
return (
<p className="border-b border-line bg-panel px-6 py-2 text-sm text-conflict">
{friendly.text}
{friendly.actionHref ? (
<>
{" "}
<Link
href={friendly.actionHref}
className="underline hover:text-cinnabar"
>
{friendly.actionLabel}
</Link>
</>
) : null}
</p>
);
}
interface SectionStatusLineProps {
reviewing: boolean;
done: boolean;
conflictCount: number;
sections: { name: string; status: string }[];
}
// 四审进行中骨架逐项点亮UX §9M2 仅 continuity 一项。
function SectionStatusLine({
reviewing,
done,
conflictCount,
sections,
}: SectionStatusLineProps) {
if (reviewing) {
return (
<p className="mt-1 text-xs text-info">
<ThinkingIndicator label="审稿进行中" />
</p>
);
}
if (done || sections.length > 0) {
return (
<p className="mt-1 text-xs">
{conflictCount > 0 ? (
<span className="text-conflict"> {conflictCount} </span>
) : (
<span className="text-pass"> </span>
)}
</p>
);
}
return null;
}