"use client"; import { useEffect, useMemo, useRef, useState } from "react"; import Link from "next/link"; import { useRouter } from "next/navigation"; import { AlertTriangle, CheckCircle2, RotateCcw, Square, } from "lucide-react"; import { AppShell } from "@/components/AppShell"; import { Badge } from "@/components/ui/Badge"; import { Button } from "@/components/ui/Button"; import { Select } from "@/components/ui/Select"; import { reviewChapterHref, reviewChapterOptions } from "@/lib/review/chapterNav"; import type { ChapterEntry } from "@/lib/workbench/chapter"; 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 { latestReview, normalizeConflicts, normalizeForeshadowSug, normalizePace, } from "@/lib/review/history"; import { api } from "@/lib/api/client"; 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 { locateDriftSegment } from "@/lib/style/locateSegment"; 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 { ReviewSectionPanel } from "./ReviewSectionPanel"; import { ReviewSummaryRail } from "./ReviewSummaryRail"; import { StylePanel } from "@/components/style/StylePanel"; import { RefineView } from "@/components/style/RefineView"; interface ReviewReportProps { project: ProjectResponse; chapterNo: number; initialReview: ReviewHistoryItem | undefined; initialDraft: string; // 章节导航目录(大纲章节):用于报告头部的「切换审稿章节」选择器。 chapters?: ChapterEntry[]; } // 审稿报告页主体(UX §6.4 / §8.3 / §9)。 // 数据源:进页用历史留痕 conflicts 种入;「重新审稿」用当前终稿跑 SSE 覆盖。 // 裁决草稿随 conflicts 长度对齐;未决禁验收(gate)。 export function ReviewReport({ project, chapterNo, initialReview, initialDraft, chapters, }: ReviewReportProps) { const router = useRouter(); 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(() => emptyDecisions(normalizeConflicts(initialReview).length), ); const [focusedIndex, setFocusedIndex] = useState(null); const [missing, setMissing] = useState>(new Set()); // 正在 AI 改写的冲突下标(采纳无补丁 → 调 refine 重写定位段;用于卡片 loading/禁用)。 const [rewriting, setRewriting] = useState>(new Set()); // 可编辑终稿 textarea 引用:供「跳转」/采纳后在正文里选中定位问题区域。 const editorRef = useRef(null); // 回炉中漂移段在终稿里定位到的原文(null=未打开 RefineView)。内容锚定位,非位置 idx。 const [refineText, setRefineText] = useState(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); } } } }; // 无预出补丁的冲突,采纳时临时调 AI(refiner)重写定位到的那段原文,套入终稿并选中。 // 定位不到(多为摘要/跨章问题)→ 不调 AI,提示手改/忽略。复用既有 refine 端点(不变量#3)。 const aiFixOnAccept = async ( index: number, c: ReviewConflict, ): Promise => { 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(); 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,无内部滚动条);有对应 用它居中, // 否则退回滚动编辑器本体。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); }); }; // 闪烁高亮 (朱砂脉冲 0.9s);尊重 prefers-reduced-motion(CSS 内降级为描边)。 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 => { const outcome = await accept.accept( project.id, chapterNo, finalText, drafts, ); if (!outcome.conflictUnresolved) { if (outcome.missingIndices.length > 0) { setMissing(new Set(outcome.missingIndices)); } return; } // 验收闸读的是「持久化的最新审稿」;本页冲突可能为空/过期(显示 0 冲突却被拦)。 // 回拉最新审稿留痕 → 回灌冲突 + 重置裁决草稿,把「看不见的冲突」显式呈现供裁决。 const { data } = await api.GET( "/projects/{project_id}/chapters/{chapter_no}/reviews", { params: { path: { project_id: project.id, chapter_no: chapterNo } } }, ); const fresh = normalizeConflicts(latestReview(data?.reviews)); setMissing(new Set(outcome.missingIndices)); if (fresh.length > 0) { review.seed({ conflicts: fresh, foreshadow, pace, style }); setDrafts(emptyDecisions(fresh.length)); toast("审稿报告已刷新:检测到未裁决冲突,请裁决后再验收", "error"); } else { toast("尚有冲突未裁决,请先「重新审稿」查看", "error"); } }; // 终稿按空行切段(大文本:仅在 finalText 变化时重算,不在每次 render split)。 const finalParas = useMemo(() => finalText.split(/\n{2,}/), [finalText]); // 一键回炉:用漂移段自带原文在终稿里做内容匹配定位(内容锚),定位不到则就 #9 // 给出明确提示而非静默 no-op;定位到才打开 RefineView。 const onRefineSegment = (segment: StyleDriftSegment): void => { const located = locateDriftSegment(finalParas, segment); if (located === null) { toast("无法定位该段(原文可能已改动),请手动选择要回炉的段落。", "error"); return; } setRefineText(located); }; // 采纳:把重写段替换终稿中的原段(首个匹配),合入终稿(经既有 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); }); setRefineText(null); toast("已合入终稿,记得验收时复核。", "success"); }; const resolved = allResolved(drafts); const unresolved = unresolvedIndices(drafts).length; const reviewing = review.isReviewing; const hasReport = initialReview !== undefined || review.state.phase === "done" || review.state.sections.length > 0 || seededConflicts.length > 0 || foreshadow.length > 0 || pace !== null || style !== null; return (
{/* 左:终稿正文 + 就地标注 */}

第 {chapterNo} 章 审稿报告

{conflictCount} 冲突
{reviewing ? ( ) : ( )}
{review.state.error ? ( ) : null}

可直接在下方正文修改(改动自动保存草稿 — 摘要从终稿提炼);点冲突的「跳转」或顶部锚点定位到对应文字。

{autosave.status === "saving" ? "保存中…" : autosave.status === "error" ? "保存失败" : (autosave.savedLabel ?? "")}
{/* 右:审稿分区 + 冲突裁决 + 验收 gate */}
); } // 审稿失败提示:友好文案(不暴露 raw code)+ 可选「去设置」动作(F4)。 function ReviewErrorNote({ error, }: { error: { code: string; message: string }; }) { const friendly = friendlyError(error.code, error.message); return (

{friendly.text} {friendly.actionHref ? ( <> {" "} {friendly.actionLabel} ) : null}

); } interface SectionStatusLineProps { reviewing: boolean; done: boolean; conflictCount: number; sections: { name: string; status: string }[]; } // 四审进行中骨架逐项点亮(UX §9);M2 仅 continuity 一项。 function SectionStatusLine({ reviewing, done, conflictCount, sections, }: SectionStatusLineProps) { if (reviewing) { return (

); } if (done || sections.length > 0) { return (

{conflictCount > 0 ? ( ) : ( )}

); } return null; }