"use client"; import { useEffect, useMemo, useState, type ReactNode } from "react"; import { ChevronDown, ChevronUp, CornerDownRight, ListTree, Loader2, Plus, Save, Trash2, Upload, } from "lucide-react"; import { apiAdminCreateLibrarySection, apiAdminDeleteLibrarySection, apiAdminImportLibraryMarkdownBatch, apiAdminMoveLibrarySection, apiAdminUpdateLibrarySection, type LibraryDoc, type LibraryImportMdInput, type LibrarySectionItem, } from "@/lib/api"; import { toast } from "@/lib/toast"; import ConfirmDialog from "@/components/ConfirmDialog"; import MarkdownEditor from "@/components/MarkdownEditor"; import { AdminEmpty } from "@/components/admin"; import ImportMdDialog from "./ImportMdDialog"; const MAX_TITLE = 200; const MAX_CONTENT = 100_000; // 与后端 MaxSectionContent 保持一致 const EMPTY_SNAPSHOT = JSON.stringify({ title: "", content: "" }); /** * 「章节与正文」面板:左章节树(章 → 节 两级),右章节编辑器。 * 数据从 doc.sections 派生(后端 detail 已带全量含 content); * 所有变更后调 onChanged() 让父组件刷新列表,doc.sections 随之更新。 */ export default function LibrarySectionsPanel({ doc, onChanged, onDirtyChange, }: { doc: LibraryDoc; /** 数据变更后由父组件刷新(doc.sections 随之更新) */ onChanged: () => Promise; /** dirty 状态上报,供父组件在关闭编辑器 / 切换条目前确认 */ onDirtyChange: (dirty: boolean) => void; }) { const sections = doc.sections ?? []; // 平铺 → 章分组(顺序沿用后端 sort_order) const groups = useMemo(() => { const gs: { chapter: LibrarySectionItem; subs: LibrarySectionItem[] }[] = []; for (const s of sections) { if (s.parent_id == null) gs.push({ chapter: s, subs: [] }); else gs[gs.length - 1]?.subs.push(s); } return gs; }, [sections]); const [selectedId, setSelectedId] = useState(null); const [title, setTitle] = useState(""); const [content, setContent] = useState(""); const [snapshot, setSnapshot] = useState(EMPTY_SNAPSHOT); const [busy, setBusy] = useState(false); const [error, setError] = useState(""); const [pendingNav, setPendingNav] = useState<(() => void) | null>(null); const [deleteTarget, setDeleteTarget] = useState(null); const [deleteBusy, setDeleteBusy] = useState(false); const [importOpen, setImportOpen] = useState(false); // 新建 / 导入后要选中的章节 id:等 doc.sections 里出现再应用,避免被旧数据清掉 const [pendingSelectId, setPendingSelectId] = useState(null); const selected = useMemo( () => sections.find((s) => s.id === selectedId) ?? null, [sections, selectedId] ); const dirty = JSON.stringify({ title, content }) !== snapshot; // dirty 上报给父组件;卸载时归零,避免残留的 dirty 影响父组件的关闭确认 useEffect(() => { onDirtyChange(dirty); return () => onDirtyChange(false); }, [dirty, onDirtyChange]); // 选中项被删除后清空选中 useEffect(() => { if (selectedId != null && !sections.some((s) => s.id === selectedId)) { setSelectedId(null); } }, [sections, selectedId]); // 应用待选章节(新建 / 导入完成后) useEffect(() => { if (pendingSelectId == null) return; const s = sections.find((x) => x.id === pendingSelectId); if (!s) return; setPendingSelectId(null); setSelectedId(s.id); setTitle(s.title); setContent(s.content); setSnapshot(JSON.stringify({ title: s.title, content: s.content })); setError(""); }, [sections, pendingSelectId]); function loadSection(s: LibrarySectionItem) { setSelectedId(s.id); setTitle(s.title); setContent(s.content); setSnapshot(JSON.stringify({ title: s.title, content: s.content })); setError(""); } /** dirty 时先弹确认,再执行动作(切换章节 / 打开导入 / 新增章节) */ function guardNav(action: () => void) { if (dirty && !busy) setPendingNav(() => action); else action(); } function selectSection(s: LibrarySectionItem) { if (s.id === selectedId) return; guardNav(() => loadSection(s)); } async function addSection(parent: LibrarySectionItem | null) { setBusy(true); setError(""); try { const { section } = await apiAdminCreateLibrarySection(doc.id, { parent_id: parent?.id, title: parent ? "新节" : "新章", }); await onChanged(); setPendingSelectId(section.id); toast(parent ? "小节已添加" : "章已添加", "ok"); } catch (e) { setError(e instanceof Error ? e.message : "添加章节失败"); } finally { setBusy(false); } } async function save() { if (selectedId == null) return; const t = title.trim(); if (!t) { setError("章节标题不能为空"); return; } setBusy(true); setError(""); try { const { section } = await apiAdminUpdateLibrarySection(selectedId, { title: t, content, }); await onChanged(); // 以服务端返回为准(后端会 trim 标题),保证 dirty 归零 setTitle(section.title); setContent(section.content); setSnapshot(JSON.stringify({ title: section.title, content: section.content })); toast("章节已保存", "ok"); } catch (e) { setError(e instanceof Error ? e.message : "保存章节失败"); } finally { setBusy(false); } } async function move(s: LibrarySectionItem, direction: "up" | "down") { setBusy(true); setError(""); try { await apiAdminMoveLibrarySection(s.id, direction); await onChanged(); // 选中 id 不变,原地刷新顺序 } catch (e) { setError(e instanceof Error ? e.message : "移动章节失败"); } finally { setBusy(false); } } async function removeSection() { if (!deleteTarget) return; const target = deleteTarget; setDeleteBusy(true); try { await apiAdminDeleteLibrarySection(target.id); toast(target.parent_id == null ? "章已删除" : "小节已删除", "ok"); setDeleteTarget(null); await onChanged(); if (target.id === selectedId) { setSelectedId(null); setTitle(""); setContent(""); setSnapshot(EMPTY_SNAPSHOT); setError(""); } } catch (e) { toast(e instanceof Error ? e.message : "删除章节失败"); } finally { setDeleteBusy(false); } } async function handleImport(input: LibraryImportMdInput) { const res = await apiAdminImportLibraryMarkdownBatch(doc.id, input); await onChanged(); return res.sections; } function closeImport(created?: LibrarySectionItem[]) { setImportOpen(false); if (created && created.length > 0) setPendingSelectId(created[0].id); } /** 该章节在同级兄弟中的位置(供上移/下移禁用判断) */ function siblingPos(s: LibrarySectionItem): { first: boolean; last: boolean } { if (s.parent_id == null) { const i = groups.findIndex((g) => g.chapter.id === s.id); return { first: i <= 0, last: i < 0 || i >= groups.length - 1 }; } const g = groups.find((x) => x.subs.some((y) => y.id === s.id)); const subs = g?.subs ?? []; const i = subs.findIndex((y) => y.id === s.id); return { first: i <= 0, last: i < 0 || i >= subs.length - 1 }; } function RowAction({ label, onClick, disabled, children, }: { label: string; onClick: () => void; disabled?: boolean; children: ReactNode; }) { return ( ); } function row(s: LibrarySectionItem, depth: 0 | 1, isFirst: boolean, isLast: boolean) { const active = s.id === selectedId; const pos = siblingPos(s); return (
{active ? ( <> void move(s, "up")}> void move(s, "down")}> {depth === 0 ? ( void addSection(s)} > ) : null} setDeleteTarget(s)}> ) : null}
); } return (
{/* 左:章节树 */} {/* 右:章节编辑器 */}
{selected ? ( <>
{ setError(""); setTitle(e.target.value); }} placeholder="例如:第一章 …" disabled={busy} />
{/* flex-1 撑满滚动区剩余高度:编辑框最大化、提示行紧贴底部操作条上方 */}
{ setError(""); setContent(v.slice(0, MAX_CONTENT)); }} maxChars={MAX_CONTENT} allowHide={false} entriesAuto={doc.entries_auto ?? false} placeholder="本章正文,支持 Markdown…" rows={16} ariaLabel="章节正文" />
{/* 底部操作条:不随正文滚动 */}
{error ? (

{error}

) : dirty ? ( 有未保存的修改 ) : null}
) : (
} title="未选择章节" description="从左侧选择章节编辑,或添加章 / 导入 Markdown" />
)}
{/* 未保存切换章节确认 */} setPendingNav(null)} onConfirm={() => { const action = pendingNav; setPendingNav(null); action?.(); }} /> {/* 删除章节确认 */} !deleteBusy && setDeleteTarget(null)} onConfirm={() => void removeSection()} /> {/* 导入 Markdown 拆章 */} {importOpen ? : null}
); }