feat(web): 审稿改稿闭环——一键采纳/AI改写/正文高亮定位编辑/伏笔确认/草稿自动保存/章节目录

- 冲突「采纳改法」:有补丁瞬时改入终稿并选中;无补丁但可定位则采纳时调 AI(refiner)重写该段套入;定位不到提示手改
- 正文改为行内高亮叠层编辑器(与页面同底色、随内容增高、无滚动条),点冲突卡/锚点整页滚到并选中+闪烁
- 仅对可在正文定位的冲突显示「跳转」/锚点;locate 支持从 where 引号抽原文,杜绝定位失败刷屏
- 伏笔建议卡:新埋一键登记(预填表单)/ 疑似回收一键标记 CLOSED(复用现有端点)
- 审稿页草稿自动保存(防抖 PUT /draft),与「验收本章」解耦,刷新不丢
- 写作路由带章号 ?chapter=N + 验收成功面板/大纲页「写下一章」入口;写作目录从大纲列出全部章节

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Yaojia Wang
2026-06-22 18:28:29 +02:00
parent d028c790c9
commit 29265fa0fd
26 changed files with 1093 additions and 106 deletions

View File

@@ -1,19 +1,89 @@
"use client";
import { useState } from "react";
import { suggestForeshadowCode } from "@/lib/foreshadow/board";
import { useForeshadow } from "@/lib/foreshadow/useForeshadow";
import type { ForeshadowSuggestion } from "@/lib/review/sse";
interface ForeshadowSuggestionsProps {
suggestions: ForeshadowSuggestion[];
// 审项是否未完成incomplete
incomplete: boolean;
projectId: string;
chapterNo: number;
}
// 伏笔建议区UX §6.4 ②planted「新埋待确认」/ resolved「疑似回收」只读建议。
// 不静默写库(不变量#3仅展示登记/回收经伏笔看板显式确认。
interface RegisterFormState {
code: string;
title: string;
plantedAt: number;
content: string;
}
// 伏笔建议区UX §6.4 ②planted「新埋待确认」/ resolved「疑似回收」。
// 四审只读(不变量#3但作者可在此**显式确认**新埋→登记POST疑似回收→标记回收PATCH→CLOSED
// 这是作者确认动作,非 AI 静默写库复用伏笔看板端点lib/foreshadow
export function ForeshadowSuggestions({
suggestions,
incomplete,
projectId,
chapterNo,
}: ForeshadowSuggestionsProps) {
const fs = useForeshadow([]);
const [openIdx, setOpenIdx] = useState<number | null>(null);
const [form, setForm] = useState<RegisterFormState | null>(null);
const [registered, setRegistered] = useState<Set<number>>(new Set());
const [resolved, setResolved] = useState<Set<number>>(new Set());
const [pending, setPending] = useState<Set<number>>(new Set());
const openRegister = (i: number, s: ForeshadowSuggestion): void => {
setOpenIdx(i);
setForm({
code: suggestForeshadowCode(s.code, i),
title: s.title,
plantedAt: chapterNo,
content: s.note ?? s.where ?? "",
});
};
const closeForm = (): void => {
setOpenIdx(null);
setForm(null);
};
const markPending = (i: number, on: boolean): void =>
setPending((prev) => {
const next = new Set(prev);
if (on) next.add(i);
else next.delete(i);
return next;
});
const submitRegister = async (i: number): Promise<void> => {
if (!form || !form.code.trim() || !form.title.trim()) return;
markPending(i, true);
const ok = await fs.register({
projectId,
code: form.code,
title: form.title,
plantedAt: form.plantedAt,
content: form.content,
});
markPending(i, false);
if (ok) {
setRegistered((prev) => new Set(prev).add(i));
closeForm();
}
};
const markResolved = async (i: number, code: string): Promise<void> => {
markPending(i, true);
const ok = await fs.transition(code, { projectId, toStatus: "CLOSED" });
markPending(i, false);
if (ok) setResolved((prev) => new Set(prev).add(i));
};
return (
<section className="mb-4">
<h2 className="text-xs font-semibold uppercase tracking-wide text-ink-soft">
@@ -25,34 +95,171 @@ export function ForeshadowSuggestions({
<p className="mt-1 text-xs text-ink-soft"></p>
) : (
<ul className="mt-2 space-y-2">
{suggestions.map((s, i) => (
<li
key={i}
className="rounded border border-line bg-panel p-2 text-xs"
>
<span
className={`mr-1 rounded px-1.5 py-0.5 ${
s.kind === "resolved"
? "bg-pass/15 text-pass"
: "bg-[var(--color-cinnabar-wash)] text-cinnabar"
}`}
{suggestions.map((s, i) => {
const code = s.code;
const isResolved = s.kind === "resolved";
const done = isResolved ? resolved.has(i) : registered.has(i);
const busy = pending.has(i);
return (
<li
key={i}
className="rounded border border-line bg-panel p-2 text-xs"
>
{s.kind === "resolved" ? "⚑ 疑似回收" : " 新埋待确认"}
</span>
{s.code ? (
<span className="font-mono text-ink-soft"> {s.code}</span>
) : null}
<p className="mt-1 text-ink">{s.title}</p>
{s.where ? (
<span className="text-ink-soft">{s.where}</span>
) : null}
{s.note ? (
<p className="mt-0.5 text-ink-soft">{s.note}</p>
) : null}
</li>
))}
<span
className={`mr-1 rounded px-1.5 py-0.5 ${
isResolved
? "bg-pass/15 text-pass"
: "bg-[var(--color-cinnabar-wash)] text-cinnabar"
}`}
>
{isResolved ? "⚑ 疑似回收" : " 新埋待确认"}
</span>
{code ? (
<span className="font-mono text-ink-soft"> {code}</span>
) : null}
<p className="mt-1 text-ink">{s.title}</p>
{s.where ? (
<span className="text-ink-soft">{s.where}</span>
) : null}
{s.note ? <p className="mt-0.5 text-ink-soft">{s.note}</p> : null}
{/* 作者确认动作区 */}
{done ? (
<p className="mt-1.5 text-pass">
{isResolved ? "已标记回收" : "已登记"}
</p>
) : isResolved ? (
code ? (
<button
type="button"
disabled={busy}
onClick={() => void markResolved(i, code)}
className="mt-1.5 rounded border border-pass px-2 py-0.5 text-pass hover:bg-pass/10 disabled:cursor-not-allowed disabled:opacity-40"
>
{busy ? "处理中…" : "✓ 标记回收"}
</button>
) : (
<p className="mt-1.5 text-ink-soft">
</p>
)
) : openIdx === i && form ? (
<RegisterFormInline
form={form}
busy={busy}
onChange={setForm}
onSubmit={() => void submitRegister(i)}
onCancel={closeForm}
/>
) : (
<button
type="button"
onClick={() => openRegister(i, s)}
className="mt-1.5 rounded border border-cinnabar px-2 py-0.5 text-cinnabar hover:bg-cinnabar/10"
>
</button>
)}
</li>
);
})}
</ul>
)}
</section>
);
}
interface RegisterFormInlineProps {
form: RegisterFormState;
busy: boolean;
onChange: (form: RegisterFormState) => void;
onSubmit: () => void;
onCancel: () => void;
}
// 新埋伏笔登记表单:预填标题/埋设章/线索code 给建议值可改code/title 必填)。
function RegisterFormInline({
form,
busy,
onChange,
onSubmit,
onCancel,
}: RegisterFormInlineProps) {
const canSubmit =
!busy && form.code.trim().length > 0 && form.title.trim().length > 0;
const field =
"w-full rounded border border-line bg-bg px-2 py-1 text-xs text-ink focus:border-cinnabar focus:outline-none";
return (
<div className="mt-2 space-y-1.5 border-t border-line pt-2">
<div>
<label htmlFor="fs-reg-code" className="text-ink-soft">
</label>
<input
id="fs-reg-code"
type="text"
value={form.code}
onChange={(e) => onChange({ ...form, code: e.target.value })}
className={field}
/>
</div>
<div>
<label htmlFor="fs-reg-title" className="text-ink-soft">
</label>
<input
id="fs-reg-title"
type="text"
value={form.title}
onChange={(e) => onChange({ ...form, title: e.target.value })}
className={field}
/>
</div>
<div>
<label htmlFor="fs-reg-planted" className="text-ink-soft">
</label>
<input
id="fs-reg-planted"
type="number"
min={1}
value={form.plantedAt}
onChange={(e) =>
onChange({ ...form, plantedAt: Number(e.target.value) || 1 })
}
className={field}
/>
</div>
<div>
<label htmlFor="fs-reg-content" className="text-ink-soft">
线/
</label>
<textarea
id="fs-reg-content"
value={form.content}
onChange={(e) => onChange({ ...form, content: e.target.value })}
rows={2}
className={`${field} resize-y`}
/>
</div>
<div className="flex gap-2 pt-0.5">
<button
type="button"
disabled={!canSubmit}
onClick={onSubmit}
className="rounded bg-cinnabar px-2 py-0.5 text-panel hover:opacity-90 disabled:cursor-not-allowed disabled:opacity-40"
>
{busy ? "登记中…" : "确认登记"}
</button>
<button
type="button"
disabled={busy}
onClick={onCancel}
className="rounded border border-line px-2 py-0.5 text-ink-soft hover:border-cinnabar hover:text-ink disabled:opacity-40"
>
</button>
</div>
</div>
);
}