Files
writer-work-flow/apps/web/components/review/ReviewReport.tsx
Yaojia Wang b18310bcb7 feat(ux): F3 流式进度感 + F4 友好错误文案(纯前端)
F4:新 lib/errors/messages.ts —— friendlyError(code, fallback) 把错误码映射为
中文文案 + 可选「去设置提供商」动作;覆盖后端 ErrorCode + 前端 NETWORK/
STREAM_FAILED;未知码回退原 message。写作页与审稿页错误块改用之,不再暴露
raw error code。+4 vitest。

F3:写作页工具条字数计数器流式时显示「生成中… N 字」+ 朱砂脉冲
(motion-safe:animate-pulse,尊重 prefers-reduced-motion),停后回「字数 N」。
纯 text.length,无后端改动。

前端门禁绿:lint/typecheck/vitest 171/build。无契约变更。
2026-06-20 11:02:27 +02:00

395 lines
13 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 { 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 { 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 { 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));
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<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-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>
) : (
<ul className="space-y-3">
{conflicts.map((c, i) => (
<ConflictCard
key={i}
index={i}
conflict={c}
draft={drafts[i] ?? { verdict: null, note: "" }}
missing={missing.has(i)}
onVerdict={onVerdict}
onNote={onNote}
onJump={jumpToAnchor}
/>
))}
</ul>
)}
</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}
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 §9M2 仅 continuity 一项。
function SectionStatusLine({
reviewing,
done,
conflictCount,
sections,
}: SectionStatusLineProps) {
if (reviewing) {
return (
<p className="mt-1 text-xs text-info" aria-live="polite">
稿
</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;
}