- 后端新增 GET /projects/{id}/chapters(列真实存在的章 + has_draft/accepted/reviewed_at),TDD 10 测
- gen:api 重生成 TS 客户端(ChapterListItem)
- 选章下拉去掉 hidden(手机/窄屏也可切章);选项以真实章为准 + 大纲结构,
无草稿章置灰(仅当前章例外),覆盖『写过但不在大纲』的章
- 无 ?chapter 时默认落到最近一个有草稿的章,而非硬编码第 1 章
875 lines
33 KiB
TypeScript
875 lines
33 KiB
TypeScript
"use client";
|
||
|
||
import { useEffect, useMemo, useRef, useState } from "react";
|
||
import Link from "next/link";
|
||
import { useRouter } from "next/navigation";
|
||
import {
|
||
AlertTriangle,
|
||
CheckCircle2,
|
||
ChevronDown,
|
||
ClipboardCheck,
|
||
LocateFixed,
|
||
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,
|
||
type ReviewChapterOption,
|
||
} 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,
|
||
normalizeCharacterization,
|
||
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 { CharacterizationPanel } from "./CharacterizationPanel";
|
||
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";
|
||
|
||
// 命中区闪烁高亮持续时长(朱砂脉冲)。
|
||
const FLASH_DURATION_MS = 900;
|
||
|
||
interface ReviewReportProps {
|
||
project: ProjectResponse;
|
||
chapterNo: number;
|
||
initialReview: ReviewHistoryItem | undefined;
|
||
initialDraft: string;
|
||
// 章节导航目录(大纲章节):无 chapterOptions 时回退用。
|
||
chapters?: ChapterEntry[];
|
||
// 选章选项(以真实章为准,含可审/已审标记);优先于 chapters 渲染选择器。
|
||
chapterOptions?: ReviewChapterOption[];
|
||
}
|
||
|
||
// 审稿报告页主体(UX §6.4 / §8.3 / §9)。
|
||
// 数据源:进页用历史留痕 conflicts 种入;「重新审稿」用当前终稿跑 SSE 覆盖。
|
||
// 裁决草稿随 conflicts 长度对齐;未决禁验收(gate)。
|
||
export function ReviewReport({
|
||
project,
|
||
chapterNo,
|
||
initialReview,
|
||
initialDraft,
|
||
chapters,
|
||
chapterOptions,
|
||
}: 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<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)。内容锚定位,非位置 idx。
|
||
const [refineText, setRefineText] = useState<string | null>(null);
|
||
// 伏笔「仍待处理」数:由 ForeshadowSuggestions 上抛(已登记/已回收的已扣除)。
|
||
// null=尚未上报,回退到原始建议数,避免首帧闪「无建议」。
|
||
const [foreshadowRemaining, setForeshadowRemaining] = useState<number | null>(
|
||
null,
|
||
);
|
||
const seededRef = useRef(false);
|
||
// flashMark 的收尾定时器句柄:组件卸载或重复触发时清除,避免定时器泄漏。
|
||
const flashTimerRef = useRef<number | null>(null);
|
||
useEffect(
|
||
() => () => {
|
||
if (flashTimerRef.current !== null) {
|
||
window.clearTimeout(flashTimerRef.current);
|
||
}
|
||
},
|
||
[],
|
||
);
|
||
|
||
// 进页一次性把历史留痕(冲突 + 伏笔建议 + 节奏)种入流状态(无需重审即可裁决/查看)。
|
||
const seededConflicts = useMemo(
|
||
() => normalizeConflicts(initialReview),
|
||
[initialReview],
|
||
);
|
||
const seededForeshadow = useMemo(
|
||
() => normalizeForeshadowSug(initialReview),
|
||
[initialReview],
|
||
);
|
||
const seededPace = useMemo(
|
||
() => normalizePace(initialReview),
|
||
[initialReview],
|
||
);
|
||
const seededStyle = useMemo(
|
||
() => normalizeStyleDrift(initialReview),
|
||
[initialReview],
|
||
);
|
||
const seededCharacterization = useMemo(
|
||
() => normalizeCharacterization(initialReview),
|
||
[initialReview],
|
||
);
|
||
useEffect(() => {
|
||
if (seededRef.current) return;
|
||
seededRef.current = true;
|
||
const hasSeed =
|
||
seededConflicts.length > 0 ||
|
||
seededForeshadow.length > 0 ||
|
||
seededPace !== null ||
|
||
seededStyle !== null ||
|
||
seededCharacterization !== null;
|
||
if (hasSeed) {
|
||
review.seed({
|
||
conflicts: seededConflicts,
|
||
foreshadow: seededForeshadow,
|
||
pace: seededPace,
|
||
style: seededStyle,
|
||
characterization: seededCharacterization,
|
||
});
|
||
}
|
||
}, [
|
||
review,
|
||
seededConflicts,
|
||
seededForeshadow,
|
||
seededPace,
|
||
seededStyle,
|
||
seededCharacterization,
|
||
]);
|
||
|
||
// 当前生效冲突 = 流状态(重审后即时更新,否则种入的历史)。
|
||
const conflicts: ReviewConflict[] = review.state.conflicts;
|
||
const foreshadow = review.state.foreshadow;
|
||
const pace = review.state.pace;
|
||
const style = review.state.style;
|
||
const characterization = review.state.characterization;
|
||
// 伏笔徽标/验收预览统一用「仍待处理数」(随登记/回收递减);未上报前回退原始建议数。
|
||
const foreshadowOpen = foreshadowRemaining ?? foreshadow.length;
|
||
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<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-motion(CSS 内降级为描边)。
|
||
const flashMark = (index: number): void => {
|
||
const mark = document.getElementById(`draft-mark-${index}`);
|
||
if (!mark) return;
|
||
mark.classList.add("draft-mark-flash");
|
||
if (flashTimerRef.current !== null) {
|
||
window.clearTimeout(flashTimerRef.current);
|
||
}
|
||
flashTimerRef.current = window.setTimeout(() => {
|
||
mark.classList.remove("draft-mark-flash");
|
||
flashTimerRef.current = null;
|
||
}, FLASH_DURATION_MS);
|
||
};
|
||
|
||
// 跳转/锚点:在可编辑正文里定位、选中并滚动到该冲突对应区域 + 闪烁;定位不到则提示手动查找。
|
||
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.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, characterization });
|
||
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 ||
|
||
characterization !== null;
|
||
|
||
return (
|
||
<AppShell
|
||
title={`〈${project.title}〉`}
|
||
subtitle={`第 ${chapterNo} 章 · 审稿报告`}
|
||
projectId={project.id}
|
||
activeNav="review"
|
||
>
|
||
<div className="flex min-h-[calc(100vh-var(--chrome,4rem))] flex-col lg:grid lg:h-[calc(100vh-var(--chrome,4rem))] lg:min-h-0 lg:grid-cols-[minmax(0,1fr)_24rem]">
|
||
{/* 左:终稿正文 + 就地标注 */}
|
||
<section className="flex min-h-[70vh] min-w-0 flex-col bg-bg lg:min-h-0">
|
||
<div className="flex items-center gap-3 border-b border-line bg-panel px-4 py-3 sm:px-6">
|
||
<h1 className="min-w-0 flex-1 truncate font-serif text-lg text-ink">
|
||
第 {chapterNo} 章 审稿报告
|
||
</h1>
|
||
<Select
|
||
controlSize="sm"
|
||
aria-label="切换审稿章节"
|
||
className="max-w-[12rem] shrink-0"
|
||
value={chapterNo}
|
||
onChange={(e) =>
|
||
router.push(
|
||
reviewChapterHref(project.id, Number(e.target.value)),
|
||
)
|
||
}
|
||
>
|
||
{(
|
||
chapterOptions ?? reviewChapterOptions(chapters ?? [], chapterNo)
|
||
).map((opt: ReviewChapterOption) => (
|
||
<option key={opt.no} value={opt.no} disabled={opt.disabled}>
|
||
{opt.label}
|
||
</option>
|
||
))}
|
||
</Select>
|
||
<span className="hidden shrink-0 font-mono text-xs text-ink-soft sm:inline">
|
||
{conflictCount} 冲突
|
||
</span>
|
||
<div className="shrink-0">
|
||
{reviewing ? (
|
||
<Button onClick={review.stop} variant="danger" size="sm">
|
||
<Square className="h-4 w-4" aria-hidden="true" />
|
||
停
|
||
</Button>
|
||
) : (
|
||
<Button onClick={onReReview} variant="primary" size="sm">
|
||
<RotateCcw className="h-4 w-4" aria-hidden="true" />
|
||
重新审稿
|
||
</Button>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{review.state.error ? (
|
||
<ReviewErrorNote error={review.state.error} />
|
||
) : null}
|
||
|
||
<div className="flex-1 overflow-auto px-4 py-4 sm:px-6 sm: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}
|
||
projectId={project.id}
|
||
chapterNo={chapterNo}
|
||
editorRef={editorRef}
|
||
/>
|
||
</div>
|
||
</section>
|
||
|
||
{/* 右:审稿分区 + 冲突裁决 + 验收 gate */}
|
||
<aside
|
||
className="flex flex-col border-t border-line bg-panel px-4 py-4 lg:overflow-auto lg:border-l lg:border-t-0"
|
||
aria-label="审稿报告与验收"
|
||
>
|
||
<ReviewSummaryRail
|
||
conflictCount={conflictCount}
|
||
unresolvedCount={unresolved}
|
||
reviewing={reviewing}
|
||
hasReport={hasReport}
|
||
onNextUnresolved={jumpToNextUnresolved}
|
||
/>
|
||
|
||
<div className="space-y-3">
|
||
<ReviewSectionPanel
|
||
title="一致性"
|
||
statusLabel={
|
||
reviewing
|
||
? "审稿中"
|
||
: conflictCount > 0
|
||
? `${conflictCount} 冲突`
|
||
: hasReport
|
||
? "通过"
|
||
: "待审"
|
||
}
|
||
statusVariant={
|
||
reviewing
|
||
? "info"
|
||
: conflictCount > 0
|
||
? "danger"
|
||
: hasReport
|
||
? "success"
|
||
: "neutral"
|
||
}
|
||
defaultOpen={conflictCount > 0 || !hasReport}
|
||
>
|
||
<SectionStatusLine
|
||
reviewing={reviewing}
|
||
done={review.state.phase === "done"}
|
||
conflictCount={conflictCount}
|
||
sections={review.state.sections}
|
||
/>
|
||
{conflicts.length === 0 ? (
|
||
<p className="mt-2 rounded border border-dashed border-line p-4 text-xs text-ink-soft">
|
||
{review.state.phase === "done" || seededConflicts.length === 0
|
||
? "未发现一致性冲突。"
|
||
: "进页未带审稿留痕,点「重新审稿」开始。"}
|
||
</p>
|
||
) : (
|
||
<div className="mt-3 space-y-4">
|
||
<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) => {
|
||
const unresolvedInGroup = group.items.filter(
|
||
({ index }) => !drafts[index]?.verdict,
|
||
).length;
|
||
return (
|
||
<details
|
||
key={group.type}
|
||
open={unresolvedInGroup > 0}
|
||
className="group rounded border border-line/70 bg-bg/35 p-2"
|
||
>
|
||
<summary className="flex cursor-pointer list-none items-center justify-between gap-2 text-xs font-semibold text-ink">
|
||
<span className="flex items-center gap-2">
|
||
{group.type}
|
||
<Badge
|
||
variant={
|
||
unresolvedInGroup > 0 ? "danger" : "success"
|
||
}
|
||
>
|
||
{group.items.length}
|
||
</Badge>
|
||
</span>
|
||
<span className="flex items-center gap-1.5">
|
||
<span className="font-mono text-2xs text-ink-soft">
|
||
{unresolvedInGroup > 0
|
||
? `${unresolvedInGroup} 未裁决`
|
||
: "已处理"}
|
||
</span>
|
||
<ChevronDown
|
||
className="h-4 w-4 text-ink-soft motion-safe:transition-transform group-open:rotate-180"
|
||
aria-hidden="true"
|
||
/>
|
||
</span>
|
||
</summary>
|
||
<ul className="mt-3 space-y-3">
|
||
{group.items.map(({ conflict: c, index: i }) => (
|
||
<ConflictCard
|
||
key={i}
|
||
index={i}
|
||
total={conflictCount}
|
||
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>
|
||
</details>
|
||
);
|
||
})}
|
||
</div>
|
||
)}
|
||
</ReviewSectionPanel>
|
||
|
||
<ReviewSectionPanel
|
||
title="伏笔"
|
||
statusLabel={
|
||
sectionStatus("foreshadow") === "incomplete"
|
||
? "未完成"
|
||
: foreshadowOpen > 0
|
||
? `${foreshadowOpen} 待登记`
|
||
: "无建议"
|
||
}
|
||
statusVariant={
|
||
sectionStatus("foreshadow") === "incomplete"
|
||
? "warning"
|
||
: foreshadowOpen > 0
|
||
? "accent"
|
||
: "success"
|
||
}
|
||
defaultOpen={foreshadow.length > 0}
|
||
>
|
||
<ForeshadowSuggestions
|
||
suggestions={foreshadow}
|
||
incomplete={sectionStatus("foreshadow") === "incomplete"}
|
||
projectId={project.id}
|
||
chapterNo={chapterNo}
|
||
onRemainingChange={setForeshadowRemaining}
|
||
/>
|
||
</ReviewSectionPanel>
|
||
<ReviewSectionPanel
|
||
title="节奏"
|
||
statusLabel={
|
||
sectionStatus("pace") === "incomplete"
|
||
? "未完成"
|
||
: pace
|
||
? "已完成"
|
||
: "无报告"
|
||
}
|
||
statusVariant={
|
||
sectionStatus("pace") === "incomplete"
|
||
? "warning"
|
||
: pace
|
||
? "info"
|
||
: "neutral"
|
||
}
|
||
defaultOpen={pace !== null}
|
||
>
|
||
<PacePanel
|
||
pace={pace}
|
||
incomplete={sectionStatus("pace") === "incomplete"}
|
||
/>
|
||
</ReviewSectionPanel>
|
||
<ReviewSectionPanel
|
||
title="文风"
|
||
statusLabel={
|
||
sectionStatus("style") === "incomplete"
|
||
? "未完成"
|
||
: style
|
||
? "已完成"
|
||
: "无报告"
|
||
}
|
||
statusVariant={
|
||
sectionStatus("style") === "incomplete"
|
||
? "warning"
|
||
: style
|
||
? "info"
|
||
: "neutral"
|
||
}
|
||
defaultOpen={style !== null}
|
||
>
|
||
<StylePanel
|
||
style={style}
|
||
incomplete={sectionStatus("style") === "incomplete"}
|
||
onRefine={onRefineSegment}
|
||
/>
|
||
</ReviewSectionPanel>
|
||
<ReviewSectionPanel
|
||
title="人物塑造"
|
||
statusLabel={
|
||
sectionStatus("characterization") === "incomplete"
|
||
? "未完成"
|
||
: characterization && characterization.issues.length > 0
|
||
? `${characterization.issues.length} 建议`
|
||
: characterization
|
||
? "无明显问题"
|
||
: "无报告"
|
||
}
|
||
statusVariant={
|
||
sectionStatus("characterization") === "incomplete"
|
||
? "warning"
|
||
: characterization && characterization.issues.length > 0
|
||
? "accent"
|
||
: characterization
|
||
? "success"
|
||
: "neutral"
|
||
}
|
||
defaultOpen={
|
||
characterization !== null && characterization.issues.length > 0
|
||
}
|
||
>
|
||
<CharacterizationPanel
|
||
characterization={characterization}
|
||
incomplete={sectionStatus("characterization") === "incomplete"}
|
||
/>
|
||
</ReviewSectionPanel>
|
||
{refineText !== null ? (
|
||
<RefineView
|
||
projectId={project.id}
|
||
chapterNo={chapterNo}
|
||
segment={refineText}
|
||
onAdopt={onAdopt}
|
||
onClose={() => setRefineText(null)}
|
||
/>
|
||
) : null}
|
||
</div>
|
||
|
||
<section id="accept-panel" className="mt-4 scroll-mt-4">
|
||
<AcceptPanel
|
||
projectId={project.id}
|
||
chapterNo={chapterNo}
|
||
reviewed={initialReview !== undefined || review.state.phase === "done"}
|
||
conflictCount={conflictCount}
|
||
unresolvedCount={resolved ? 0 : unresolved}
|
||
foreshadowCount={foreshadowOpen}
|
||
accepting={accept.status === "accepting"}
|
||
result={accept.result}
|
||
onAccept={() => void onAccept()}
|
||
/>
|
||
</section>
|
||
</aside>
|
||
|
||
{/* 移动端(<lg 单列)快捷条:编辑器在上、审稿/验收在下,滚一整屏才够得着——
|
||
固定底栏直接「跳到未裁决」或「去验收」,免去长滚。桌面端隐藏(右栏常驻可见)。 */}
|
||
{hasReport && !accept.result ? (
|
||
<div className="fixed inset-x-0 bottom-0 z-30 flex items-center gap-2 border-t border-line bg-panel/95 px-4 py-2 backdrop-blur supports-[backdrop-filter]:bg-panel/80 lg:hidden">
|
||
<span className="flex-1 truncate text-xs text-ink-soft">
|
||
{unresolved > 0 ? `${unresolved} 项冲突待裁决` : "可验收本章"}
|
||
</span>
|
||
{unresolved > 0 ? (
|
||
<Button
|
||
onClick={jumpToNextUnresolved}
|
||
variant="secondary"
|
||
size="sm"
|
||
>
|
||
<LocateFixed className="h-4 w-4" aria-hidden="true" />
|
||
跳到未裁决
|
||
</Button>
|
||
) : null}
|
||
<Button
|
||
onClick={() =>
|
||
document
|
||
.getElementById("accept-panel")
|
||
?.scrollIntoView({ behavior: "smooth", block: "center" })
|
||
}
|
||
variant="primary"
|
||
size="sm"
|
||
>
|
||
<ClipboardCheck className="h-4 w-4" aria-hidden="true" />
|
||
去验收
|
||
</Button>
|
||
</div>
|
||
) : null}
|
||
</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 §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 ? (
|
||
<Badge variant="danger">
|
||
<AlertTriangle className="h-3 w-3" aria-hidden="true" />
|
||
{conflictCount} 冲突
|
||
</Badge>
|
||
) : (
|
||
<Badge variant="success">
|
||
<CheckCircle2 className="h-3 w-3" aria-hidden="true" />
|
||
通过
|
||
</Badge>
|
||
)}
|
||
</p>
|
||
);
|
||
}
|
||
return null;
|
||
}
|