Files
writer-work-flow/apps/web/components/review/ForeshadowSuggestions.tsx

323 lines
11 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 { 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<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());
// 项目已存在的伏笔(用于隐藏「之前会话就已保存过」的建议):
// savedTitles = 已登记伏笔的标题savedClosedCodes = 已 CLOSED已回收的 code。
const [savedTitles, setSavedTitles] = useState<Set<string>>(new Set());
const [savedClosedCodes, setSavedClosedCodes] = useState<Set<string>>(
new Set(),
);
// 看板已占用的全部代号——预填登记代号时跳过它们,避免撞码 422。
const [savedCodes, setSavedCodes] = useState<Set<string>>(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<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));
// 并入已存集合:连续登记时下一条不再取到同一空闲码,且该标题立即隐藏。
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<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));
};
// 保留原始下标 iregistered/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 (
<section>
{incomplete ? (
<Badge variant="warning">
<CircleDashed className="h-3 w-3" aria-hidden="true" />
</Badge>
) : visible.length === 0 ? (
<p className="text-xs text-ink-soft"></p>
) : (
<ul className="space-y-2">
{visible.map(({ s, i }) => {
const code = s.code;
const isResolved = s.kind === "resolved";
const busy = pending.has(i);
return (
<li
key={i}
className="rounded border border-line bg-panel p-2 text-xs"
>
<Badge variant={isResolved ? "success" : "accent"}>
{isResolved ? (
<Flag className="h-3 w-3" aria-hidden="true" />
) : (
<Plus className="h-3 w-3" aria-hidden="true" />
)}
{isResolved ? "疑似回收" : "新埋待确认"}
</Badge>
{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}
{/* 作者确认动作区(已保存的建议已从列表过滤,故无需「已登记」态) */}
{isResolved ? (
code ? (
<Button
disabled={busy}
onClick={() => void markResolved(i, code)}
className="mt-1.5"
variant="secondary"
size="sm"
>
<CheckCircle2 className="h-4 w-4" aria-hidden="true" />
{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
onClick={() => openRegister(i, s)}
className="mt-1.5"
variant="outline"
size="sm"
>
<Plus className="h-4 w-4" aria-hidden="true" />
</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;
return (
<div className="mt-2 space-y-1.5 border-t border-line pt-2">
<Field label="编码" htmlFor="fs-reg-code">
<TextInput
id="fs-reg-code"
type="text"
value={form.code}
onChange={(e) => onChange({ ...form, code: e.target.value })}
controlSize="sm"
/>
</Field>
<Field label="标题" htmlFor="fs-reg-title">
<TextInput
id="fs-reg-title"
type="text"
value={form.title}
onChange={(e) => onChange({ ...form, title: e.target.value })}
controlSize="sm"
/>
</Field>
<Field label="埋设章号" htmlFor="fs-reg-planted">
<TextInput
id="fs-reg-planted"
type="number"
min={1}
value={form.plantedAt}
onChange={(e) =>
onChange({ ...form, plantedAt: Number(e.target.value) || 1 })
}
controlSize="sm"
/>
</Field>
<Field label="线索/正文(可选)" htmlFor="fs-reg-content">
<TextArea
id="fs-reg-content"
value={form.content}
onChange={(e) => onChange({ ...form, content: e.target.value })}
rows={2}
controlSize="sm"
/>
</Field>
<div className="flex gap-2 pt-0.5">
<Button
disabled={!canSubmit}
onClick={onSubmit}
variant="primary"
size="sm"
>
<CheckCircle2 className="h-4 w-4" aria-hidden="true" />
{busy ? "登记中…" : "确认登记"}
</Button>
<Button
disabled={busy}
onClick={onCancel}
variant="secondary"
size="sm"
>
</Button>
</div>
</div>
);
}