fix(web): 审稿面板去重标题/去英文 slug + 伏笔待登记数实时递减 + 移动端决策栏 + 对比度修复

This commit is contained in:
Yaojia Wang
2026-06-30 08:56:23 +02:00
parent 69030eacc3
commit f0f925b195
6 changed files with 114 additions and 40 deletions

View File

@@ -142,7 +142,7 @@ export function AcceptPanel({
</li>
))}
</ul>
<p className="mt-2 text-[11px] text-ink-soft/70">
<p className="mt-2 text-2xs text-ink-soft">
</p>
</div>

View File

@@ -1,8 +1,12 @@
"use client";
import { useRef, type ReactNode, type RefObject } from "react";
import Link from "next/link";
import { PenLine } from "lucide-react";
import { EmptyState } from "@/components/ui/EmptyState";
import { locateInDraft } from "@/lib/review/locate";
import { buttonClass, proseBody } from "@/lib/ui/variants";
import type { ReviewConflict } from "@/lib/review/sse";
interface AnnotatedTextProps {
@@ -12,14 +16,16 @@ interface AnnotatedTextProps {
// 当前聚焦的冲突下标(来自报告卡「跳转」/锚点),用于锚点高亮联动。
focusedIndex: number | null;
onAnchorClick: (index: number) => void;
// 空待审正文时,引导回写作页起草本章。
projectId: string;
chapterNo: number;
// 转发给可编辑 textarea供父组件 setSelectionRange 定位/高亮问题区域。
editorRef?: RefObject<HTMLTextAreaElement | null>;
}
// 高亮叠层编辑器:背景层渲染带 <mark> 高亮的正文,前景透明 textarea 负责编辑。
// 两层必须共享同一盒模型/排版常量,否则高亮与文字像素错位。
const TYPO =
"box-border w-full font-serif text-[17px] leading-[1.9] tracking-normal whitespace-pre-wrap break-words";
// 两层必须共享同一盒模型/排版常量,否则高亮与文字像素错位;正文排版与写作页 Editor 一致proseBody
const TYPO = `box-border w-full whitespace-pre-wrap break-words ${proseBody}`;
// 把终稿按已定位的冲突区间切成 [纯文本 | <mark> | …]<mark> 带 id 供父组件滚动定位。
// 区间按 start 排序,重叠区间保留先到者(跳过后者),避免标签嵌套。
@@ -61,6 +67,8 @@ export function AnnotatedText({
conflicts,
focusedIndex,
onAnchorClick,
projectId,
chapterNo,
editorRef,
}: AnnotatedTextProps) {
const backdropRef = useRef<HTMLDivElement>(null);
@@ -69,6 +77,26 @@ export function AnnotatedText({
const anchorable = conflicts
.map((c, i) => ({ c, i }))
.filter(({ c }) => locateInDraft(value, c) !== null);
// 空待审正文:不渲染高亮叠层,直接引导回写作页起草本章(避免对着空编辑器发懵)。
if (!value) {
return (
<EmptyState
icon={PenLine}
title="本章还没有待审正文"
description="先到写作页起草本章,写完回到这里审稿、裁决与验收。"
action={
<Link
href={`/projects/${projectId}/write?chapter=${chapterNo}`}
className={buttonClass({ variant: "primary" })}
>
<PenLine className="h-4 w-4" aria-hidden="true" />
</Link>
}
/>
);
}
return (
<div className="flex flex-col gap-3">
{anchorable.length > 0 ? (
@@ -104,13 +132,7 @@ export function AnnotatedText({
aria-hidden="true"
className={`${TYPO} pointer-events-none relative select-none text-ink`}
>
{value ? (
segments
) : (
<span className="text-ink-soft/60">
</span>
)}
{segments}
{"\n"}
</div>
{/* 前景编辑层:文字透明(由背景层显示),仅保留光标;无内部滚动(页面滚动)。 */}

View File

@@ -12,7 +12,7 @@ interface BeatMapProps {
export function BeatMap({ beatMap }: BeatMapProps) {
const cells = toBeatCells(beatMap);
if (cells.length === 0) {
return <span className="text-xs text-ink-soft/60"></span>;
return <span className="text-xs text-ink-soft"></span>;
}
const spark = toSparkline(beatMap);
return (

View File

@@ -22,6 +22,9 @@ interface ForeshadowSuggestionsProps {
incomplete: boolean;
projectId: string;
chapterNo: number;
// 把「仍待处理(未登记/未回收)」的建议数上抛给报告页:
// 喂 AcceptPanel 的验收预览与伏笔分区徽标,随登记/回收操作实时递减。
onRemainingChange?: (count: number) => void;
}
interface RegisterFormState {
@@ -39,6 +42,7 @@ export function ForeshadowSuggestions({
incomplete,
projectId,
chapterNo,
onRemainingChange,
}: ForeshadowSuggestionsProps) {
const fs = useForeshadow([]);
const [openIdx, setOpenIdx] = useState<number | null>(null);
@@ -145,20 +149,24 @@ export function ForeshadowSuggestions({
.map((s, i) => ({ s, i }))
.filter(({ s, i }) => !isAlreadySaved(s, i));
// 上抛「仍待处理」建议数(已登记/已回收/历史已存的都已从 visible 过滤),
// 让报告页的验收预览与伏笔徽标随作者操作递减(父传入稳定 setter
const remaining = visible.length;
useEffect(() => {
onRemainingChange?.(remaining);
}, [remaining, onRemainingChange]);
return (
<section className="mb-4">
<h2 className="text-xs font-semibold uppercase tracking-wide text-ink-soft">
(foreshadow-analyst)
</h2>
<section>
{incomplete ? (
<Badge variant="warning" className="mt-1">
<Badge variant="warning">
<CircleDashed className="h-3 w-3" aria-hidden="true" />
</Badge>
) : visible.length === 0 ? (
<p className="mt-1 text-xs text-ink-soft"></p>
<p className="text-xs text-ink-soft"></p>
) : (
<ul className="mt-2 space-y-2">
<ul className="space-y-2">
{visible.map(({ s, i }) => {
const code = s.code;
const isResolved = s.kind === "resolved";

View File

@@ -14,19 +14,16 @@ interface PacePanelProps {
// 节奏报告区UX §6.4 ④):节拍图 ▁▃▅ + 注水段列表 + 章末钩子徽标。只读建议。
export function PacePanel({ pace, incomplete }: PacePanelProps) {
return (
<section className="mb-4">
<h2 className="text-xs font-semibold uppercase tracking-wide text-ink-soft">
(pace-checker)
</h2>
<section>
{incomplete ? (
<Badge variant="warning" className="mt-1">
<Badge variant="warning">
<CircleDashed className="h-3 w-3" aria-hidden="true" />
</Badge>
) : pace === null ? (
<p className="mt-1 text-xs text-ink-soft"></p>
<p className="text-xs text-ink-soft"></p>
) : (
<div className="mt-2 space-y-2 text-xs">
<div className="space-y-2 text-xs">
<div>
<p className="mb-1 text-ink-soft"></p>
<BeatMap beatMap={pace.beat_map} />

View File

@@ -6,6 +6,9 @@ import { useRouter } from "next/navigation";
import {
AlertTriangle,
CheckCircle2,
ChevronDown,
ClipboardCheck,
LocateFixed,
RotateCcw,
Square,
} from "lucide-react";
@@ -101,6 +104,11 @@ export function ReviewReport({
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);
// 进页一次性把历史留痕(冲突 + 伏笔建议 + 节奏)种入流状态(无需重审即可裁决/查看)。
@@ -143,6 +151,8 @@ export function ReviewReport({
const foreshadow = review.state.foreshadow;
const pace = review.state.pace;
const style = review.state.style;
// 伏笔徽标/验收预览统一用「仍待处理数」(随登记/回收递减);未上报前回退原始建议数。
const foreshadowOpen = foreshadowRemaining ?? foreshadow.length;
const sectionStatus = (name: string): string | undefined =>
review.state.sections.find((s) => s.name === name)?.status;
@@ -469,6 +479,8 @@ export function ReviewReport({
conflicts={conflicts}
focusedIndex={focusedIndex}
onAnchorClick={focusRegion}
projectId={project.id}
chapterNo={chapterNo}
editorRef={editorRef}
/>
</div>
@@ -490,7 +502,6 @@ export function ReviewReport({
<div className="space-y-3">
<ReviewSectionPanel
title="一致性"
subtitle="continuity"
statusLabel={
reviewing
? "审稿中"
@@ -537,7 +548,7 @@ export function ReviewReport({
<details
key={group.type}
open={unresolvedInGroup > 0}
className="rounded border border-line/70 bg-bg/35 p-2"
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">
@@ -550,10 +561,16 @@ export function ReviewReport({
{group.items.length}
</Badge>
</span>
<span className="font-mono text-[11px] text-ink-soft">
{unresolvedInGroup > 0
? `${unresolvedInGroup} 未裁决`
: "已处理"}
<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">
@@ -583,18 +600,17 @@ export function ReviewReport({
<ReviewSectionPanel
title="伏笔"
subtitle="foreshadow-analyst"
statusLabel={
sectionStatus("foreshadow") === "incomplete"
? "未完成"
: foreshadow.length > 0
? `${foreshadow.length} 建议`
: foreshadowOpen > 0
? `${foreshadowOpen} 待登记`
: "无建议"
}
statusVariant={
sectionStatus("foreshadow") === "incomplete"
? "warning"
: foreshadow.length > 0
: foreshadowOpen > 0
? "accent"
: "success"
}
@@ -605,11 +621,11 @@ export function ReviewReport({
incomplete={sectionStatus("foreshadow") === "incomplete"}
projectId={project.id}
chapterNo={chapterNo}
onRemainingChange={setForeshadowRemaining}
/>
</ReviewSectionPanel>
<ReviewSectionPanel
title="节奏"
subtitle="pace-checker"
statusLabel={
sectionStatus("pace") === "incomplete"
? "未完成"
@@ -633,7 +649,6 @@ export function ReviewReport({
</ReviewSectionPanel>
<ReviewSectionPanel
title="文风"
subtitle="style"
statusLabel={
sectionStatus("style") === "incomplete"
? "未完成"
@@ -667,20 +682,52 @@ export function ReviewReport({
) : null}
</div>
<section className="mt-4">
<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={foreshadow.length}
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>
);