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

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 18:28:29 +02:00

266 lines
8.5 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 { 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;
}
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">
(foreshadow-analyst)
</h2>
{incomplete ? (
<p className="mt-1 text-xs text-overdue"> </p>
) : suggestions.length === 0 ? (
<p className="mt-1 text-xs text-ink-soft"></p>
) : (
<ul className="mt-2 space-y-2">
{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"
>
<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>
);
}