"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 { conflictSnippet } from "@/lib/review/snippet"; import { useAccept } from "@/lib/review/useAccept"; 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 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()); // 回炉中的漂移段(null=未打开 RefineView)。 const [refineSegment, setRefineSegment] = 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]); 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; }); }; 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 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); }; const jumpToAnchor = (index: number): void => { setFocusedIndex(index); document .getElementById(`anchor-${index}`) ?.scrollIntoView({ behavior: "smooth", block: "center" }); }; const onAccept = async (): Promise => { 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 (
{/* 左:终稿正文 + 就地标注 */}

第 {chapterNo} 章 审稿报告

{conflictCount} 冲突
{reviewing ? ( ) : ( )}
{review.state.error ? ( ) : null}
展开/编辑终稿(裁决时可改稿 — 摘要从终稿提炼)