323 lines
11 KiB
TypeScript
323 lines
11 KiB
TypeScript
"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));
|
||
};
|
||
|
||
// 保留原始下标 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 (
|
||
<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>
|
||
);
|
||
}
|