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:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user