"use client"; import { CheckCircle2, CircleDashed, Flag, Plus } from "lucide-react"; import { useEffect, useState } from "react"; import { api } from "@/lib/api/client"; import { Badge } from "@/components/ui/Badge"; import { Button } from "@/components/ui/Button"; import { Field } from "@/components/ui/Field"; import { TextArea } from "@/components/ui/TextArea"; import { TextInput } from "@/components/ui/TextInput"; import { nextFreeForeshadowCode, 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; // 把「仍待处理(未登记/未回收)」的建议数上抛给报告页: // 喂 AcceptPanel 的验收预览与伏笔分区徽标,随登记/回收操作实时递减。 onRemainingChange?: (count: number) => void; } 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, onRemainingChange, }: ForeshadowSuggestionsProps) { const fs = useForeshadow([]); const [openIdx, setOpenIdx] = useState(null); const [form, setForm] = useState(null); const [registered, setRegistered] = useState>(new Set()); const [resolved, setResolved] = useState>(new Set()); const [pending, setPending] = useState>(new Set()); // 项目已存在的伏笔(用于隐藏「之前会话就已保存过」的建议): // savedTitles = 已登记伏笔的标题;savedClosedCodes = 已 CLOSED(已回收)的 code。 const [savedTitles, setSavedTitles] = useState>(new Set()); const [savedClosedCodes, setSavedClosedCodes] = useState>( new Set(), ); // 看板已占用的全部代号——预填登记代号时跳过它们,避免撞码 422。 const [savedCodes, setSavedCodes] = useState>(new Set()); useEffect(() => { let active = true; void (async () => { const { data } = await api.GET("/projects/{project_id}/foreshadow", { params: { path: { project_id: projectId } }, }); if (!active || !data?.foreshadow) return; setSavedTitles(new Set(data.foreshadow.map((f) => f.title.trim()))); setSavedClosedCodes( new Set( data.foreshadow .filter((f) => f.status === "CLOSED") .map((f) => f.code), ), ); setSavedCodes( new Set(data.foreshadow.map((f) => f.code).filter((c) => c)), ); })(); return () => { active = false; }; }, [projectId]); // 该建议是否「已保存过」→ 从列表隐藏: // - 本次会话已登记/回收(registered/resolved) // - 新埋建议:标题已存在于看板(之前已登记) // - 疑似回收建议:对应 code 已 CLOSED(之前已回收) const isAlreadySaved = (s: ForeshadowSuggestion, i: number): boolean => { if (s.kind === "resolved") { return resolved.has(i) || (s.code ? savedClosedCodes.has(s.code) : false); } return registered.has(i) || savedTitles.has(s.title.trim()); }; const openRegister = (i: number, s: ForeshadowSuggestion): void => { setOpenIdx(i); setForm({ code: nextFreeForeshadowCode(suggestForeshadowCode(s.code, i), savedCodes), 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 => { 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)); // 并入已存集合:连续登记时下一条不再取到同一空闲码,且该标题立即隐藏。 setSavedCodes((prev) => new Set(prev).add(form.code)); setSavedTitles((prev) => new Set(prev).add(form.title.trim())); closeForm(); } }; const markResolved = async (i: number, code: string): Promise => { markPending(i, true); const ok = await fs.transition(code, { projectId, toStatus: "CLOSED" }); markPending(i, false); if (ok) setResolved((prev) => new Set(prev).add(i)); }; // 保留原始下标 i(registered/resolved/pending/openIdx 均按 i 索引),仅过滤已保存项。 const visible = suggestions .map((s, i) => ({ s, i })) .filter(({ s, i }) => !isAlreadySaved(s, i)); // 上抛「仍待处理」建议数(已登记/已回收/历史已存的都已从 visible 过滤), // 让报告页的验收预览与伏笔徽标随作者操作递减(父传入稳定 setter)。 const remaining = visible.length; useEffect(() => { onRemainingChange?.(remaining); }, [remaining, onRemainingChange]); return (
{incomplete ? ( ) : visible.length === 0 ? (

无伏笔建议。

) : (
    {visible.map(({ s, i }) => { const code = s.code; const isResolved = s.kind === "resolved"; const busy = pending.has(i); return (
  • {isResolved ? ( {code ? ( {code} ) : null}

    {s.title}

    {s.where ? ( {s.where} ) : null} {s.note ?

    {s.note}

    : null} {/* 作者确认动作区(已保存的建议已从列表过滤,故无需「已登记」态) */} {isResolved ? ( code ? ( ) : (

    未关联编码,去伏笔看板处理。

    ) ) : openIdx === i && form ? ( void submitRegister(i)} onCancel={closeForm} /> ) : ( )}
  • ); })}
)}
); } 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; return (
onChange({ ...form, code: e.target.value })} controlSize="sm" /> onChange({ ...form, title: e.target.value })} controlSize="sm" /> onChange({ ...form, plantedAt: Number(e.target.value) || 1 }) } controlSize="sm" />