"use client"; import { useEffect, useMemo, useRef, useState } from "react"; import { AppShell } from "@/components/AppShell"; import type { ProjectResponse, ReviewHistoryItem } from "@/lib/api/types"; import { allResolved, emptyDecisions, setNote, setVerdict, unresolvedIndices, type DecisionDraft, type Verdict, } from "@/lib/review/decisions"; import { normalizeConflicts, normalizeForeshadowSug, normalizePace, } from "@/lib/review/history"; import { useAccept } from "@/lib/review/useAccept"; import { useReviewStream } from "@/lib/review/useReviewStream"; import type { ReviewConflict } from "@/lib/review/sse"; import { AcceptPanel } from "./AcceptPanel"; import { AnnotatedText } from "./AnnotatedText"; import { ConflictCard } from "./ConflictCard"; import { ForeshadowSuggestions } from "./ForeshadowSuggestions"; import { PacePanel } from "./PacePanel"; 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 [finalText, setFinalText] = useState(initialDraft); const [drafts, setDrafts] = useState(() => emptyDecisions(normalizeConflicts(initialReview).length), ); const [focusedIndex, setFocusedIndex] = useState(null); const [missing, setMissing] = useState>(new Set()); const seededRef = useRef(false); // 进页一次性把历史留痕(冲突 + 伏笔建议 + 节奏)种入流状态(无需重审即可裁决/查看)。 const seededConflicts = useMemo( () => normalizeConflicts(initialReview), [initialReview], ); const seededForeshadow = useMemo( () => normalizeForeshadowSug(initialReview), [initialReview], ); const seededPace = useMemo( () => normalizePace(initialReview), [initialReview], ); useEffect(() => { if (seededRef.current) return; seededRef.current = true; const hasSeed = seededConflicts.length > 0 || seededForeshadow.length > 0 || seededPace !== null; if (hasSeed) { review.seed({ conflicts: seededConflicts, foreshadow: seededForeshadow, pace: seededPace, }); } }, [review, seededConflicts, seededForeshadow, seededPace]); // 当前生效冲突 = 流状态(重审后即时更新,否则种入的历史)。 const conflicts: ReviewConflict[] = review.state.conflicts; const foreshadow = review.state.foreshadow; const pace = review.state.pace; 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)); const jumpToCard = (index: number): void => { setFocusedIndex(index); document .getElementById(`conflict-card-${index}`) ?.scrollIntoView({ behavior: "smooth", block: "center" }); }; 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)); } }; const resolved = allResolved(drafts); const unresolved = unresolvedIndices(drafts).length; const reviewing = review.isReviewing; return (
{/* 左:终稿正文 + 就地标注 */}

第 {chapterNo} 章 审稿报告

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

审稿失败({review.state.error.code}): {review.state.error.message} {review.state.error.code === "LLM_UNAVAILABLE" ? ( <> {" "} 去设置提供商 ) : null}

) : null}
展开/编辑终稿(裁决时可改稿 — 摘要从终稿提炼)