Files
writer-work-flow/apps/web/components/review/ReviewReport.tsx
Yaojia Wang 125908ed83 fix(web): ReviewReport flashMark 定时器卸载清理,防泄漏
flashMark 的 setTimeout 句柄入 ref + 卸载 useEffect 清理,重复触发先清旧定时器;提 FLASH_DURATION_MS 常量。闪烁行为不变。
2026-07-08 13:27:32 +02:00

866 lines
32 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"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 } 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;
// 章节导航目录(大纲章节):用于报告头部的「切换审稿章节」选择器。
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<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);
}
}
}
};
// 无预出补丁的冲突,采纳时临时调 AIrefiner重写定位到的那段原文套入终稿并选中。
// 定位不到(多为摘要/跨章问题)→ 不调 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-motionCSS 内降级为描边)。
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="hidden max-w-[12rem] shrink-0 sm:block"
value={chapterNo}
onChange={(e) =>
router.push(
reviewChapterHref(project.id, Number(e.target.value)),
)
}
>
{reviewChapterOptions(chapters ?? [], chapterNo).map((opt) => (
<option key={opt.no} value={opt.no}>
{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 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 §9M2 仅 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;
}