456 lines
16 KiB
TypeScript
456 lines
16 KiB
TypeScript
"use client";
|
||
|
||
import { useEffect, useMemo, useRef, useState } from "react";
|
||
|
||
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<DecisionDraft[]>(() =>
|
||
emptyDecisions(normalizeConflicts(initialReview).length),
|
||
);
|
||
const [focusedIndex, setFocusedIndex] = useState<number | null>(null);
|
||
const [missing, setMissing] = useState<Set<number>>(new Set());
|
||
// 回炉中的漂移段(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]);
|
||
|
||
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<void> => {
|
||
const outcome = await accept.accept(
|
||
project.id,
|
||
chapterNo,
|
||
finalText,
|
||
drafts,
|
||
);
|
||
if (outcome.missingIndices.length > 0) {
|
||
setMissing(new Set(outcome.missingIndices));
|
||
}
|
||
};
|
||
|
||
// 漂移段 idx → 终稿对应段正文(按空行切段;越界则空串)。
|
||
const segmentText = (idx: number): string => {
|
||
const paras = finalText.split(/\n{2,}/);
|
||
return paras[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">
|
||
<AnnotatedText
|
||
text={finalText}
|
||
conflicts={conflicts}
|
||
focusedIndex={focusedIndex}
|
||
onAnchorClick={jumpToCard}
|
||
/>
|
||
<details className="mt-4">
|
||
<summary className="cursor-pointer text-xs text-ink-soft hover:text-cinnabar">
|
||
展开/编辑终稿(裁决时可改稿 — 摘要从终稿提炼)
|
||
</summary>
|
||
<label htmlFor="final-text" className="sr-only">
|
||
终稿正文
|
||
</label>
|
||
<textarea
|
||
id="final-text"
|
||
value={finalText}
|
||
onChange={(e) => setFinalText(e.target.value)}
|
||
className="mt-2 min-h-[20vh] w-full resize-y rounded border border-line bg-panel p-3 font-serif text-[16px] leading-[1.9] text-ink focus:border-cinnabar focus:outline-none"
|
||
/>
|
||
</details>
|
||
</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)}
|
||
onVerdict={onVerdict}
|
||
onNote={onNote}
|
||
onJump={jumpToAnchor}
|
||
/>
|
||
))}
|
||
</ul>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
</section>
|
||
|
||
<div className="mt-4 border-t border-line pt-4">
|
||
<ForeshadowSuggestions
|
||
suggestions={foreshadow}
|
||
incomplete={sectionStatus("foreshadow") === "incomplete"}
|
||
/>
|
||
<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
|
||
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 ? (
|
||
<>
|
||
{" "}
|
||
<a
|
||
href={friendly.actionHref}
|
||
className="underline hover:text-cinnabar"
|
||
>
|
||
{friendly.actionLabel}
|
||
</a>
|
||
</>
|
||
) : null}
|
||
</p>
|
||
);
|
||
}
|
||
|
||
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 (
|
||
<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;
|
||
}
|