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