"use client"; import { useMemo, useRef, useState, type ChangeEvent } from "react"; import { BookOpen, Download, ExternalLink, ImagePlus, Images, Loader2, Plus, Trash2, Upload, X, } from "lucide-react"; import { apiAdminCreateLibraryDoc, apiAdminDeleteLibraryDoc, apiAdminDeleteLibraryFile, apiAdminImportLibraryZip, apiAdminListLibraryDocs, apiAdminPurgeLibraryDoc, apiAdminUpdateLibraryDoc, apiAdminUploadLibraryFile, apiLibraryCoverFromMedia, apiUploadPostImage, type LibraryDoc, type LibraryFileItem, type LibraryImportReport, type LibraryInput, } from "@/lib/api"; import { formatDateTime, formatRelative } from "@/lib/format"; import { libraryDocPath, userPath } from "@/lib/urlStyle"; import { useUrlStyle } from "@/components/SiteBrandProvider"; import { toast } from "@/lib/toast"; import ConfirmDialog from "@/components/ConfirmDialog"; import Avatar from "@/components/Avatar"; import MarkdownEditor from "@/components/MarkdownEditor"; import { AdminEmpty, AdminField, AdminPageHeader, AdminStatusChip, AdminSwitch } from "@/components/admin"; import LibrarySectionsPanel from "./LibrarySectionsPanel"; import LibraryCoverPicker from "./LibraryCoverPicker"; import { densitySrcSet, isOwnImage, pickVariantWidth, variantURL } from "@/lib/responsiveImage"; // 与后端 libraryAllowedExts 保持一致;前端先拦一道,后端仍会校验 const ALLOWED_EXTS = new Set([ "html", "htm", "pdf", "epub", "mobi", "azw3", "zip", "7z", "rar", "txt", "md", "doc", "docx", "ppt", "pptx", ]); const SLUG_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/; const DESCRIPTION_MAX = 20000; function extOf(name: string): string { const i = name.lastIndexOf("."); return i >= 0 ? name.slice(i + 1).toLowerCase() : ""; } function formatSize(n: number): string { if (!Number.isFinite(n) || n <= 0) return "0 B"; if (n < 1024) return `${n} B`; if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`; return `${(n / (1024 * 1024)).toFixed(1)} MB`; } interface DocForm { title: string; author: string; slug: string; description: string; cover_url: string; cover_width: number; cover_height: number; published: boolean; sort_order: number; entries_auto: boolean; } const EMPTY_FORM: DocForm = { title: "", author: "", slug: "", description: "", cover_url: "", cover_width: 0, cover_height: 0, published: false, sort_order: 0, entries_auto: false, }; function formOfDoc(d: LibraryDoc): DocForm { return { title: d.title, author: d.author ?? "", slug: d.slug, description: d.description ?? "", cover_url: d.cover_url ?? "", cover_width: d.cover_width ?? 0, cover_height: d.cover_height ?? 0, published: d.published, sort_order: d.sort_order, entries_auto: d.entries_auto ?? false, }; } /** 文字封面小块:无图时用标题首字 + accent 渐变 */ function TextThumb({ title }: { title: string }) { const ch = Array.from(title.trim())[0] || "书"; return ( {ch} ); } export default function LibraryAdmin({ initial, initError, }: { initial: LibraryDoc[]; initError: string; }) { const urlStyle = useUrlStyle(); const [list, setList] = useState(initial); const [loadError, setLoadError] = useState(initError); const [reloadBusy, setReloadBusy] = useState(false); // 编辑器("new" 表示新建,数字表示编辑对应条目) const [editingId, setEditingId] = useState(null); const [form, setForm] = useState(EMPTY_FORM); const [snapshot, setSnapshot] = useState(JSON.stringify(EMPTY_FORM)); const [saving, setSaving] = useState(false); const [formError, setFormError] = useState(""); const [titleError, setTitleError] = useState(""); const [slugError, setSlugError] = useState(""); // dirty 时切换/关闭需确认;pendingNav 是确认后要执行的动作 const [pendingNav, setPendingNav] = useState<(() => void) | null>(null); // 编辑器 tab:书籍信息 / 章节与正文;章节面板的 dirty 单独上报,参与关闭确认 const [tab, setTab] = useState<"info" | "sections">("info"); const [sectionsDirty, setSectionsDirty] = useState(false); // 封面上传 const coverInputRef = useRef(null); const [coverBusy, setCoverBusy] = useState(false); const [coverPickerOpen, setCoverPickerOpen] = useState(false); // 文件管理 const fileInputRef = useRef(null); const [uploading, setUploading] = useState(false); const [uploadMsg, setUploadMsg] = useState(""); const [fileDeleteTarget, setFileDeleteTarget] = useState(null); const [fileDeleteBusy, setFileDeleteBusy] = useState(false); // 书库导入恢复(上传导出 ZIP) const importInputRef = useRef(null); const [importing, setImporting] = useState(false); const [importReport, setImportReport] = useState(null); // create 模式遇到 conflict 时保留 File,供确认后 overwrite 重传 const [importOverwrite, setImportOverwrite] = useState<{ file: File; count: number } | null>(null); // 条目删除(软删)与彻底删除 const [docDeleteTarget, setDocDeleteTarget] = useState(null); const [docDeleteBusy, setDocDeleteBusy] = useState(false); const [docPurgeTarget, setDocPurgeTarget] = useState(null); const editingDoc = useMemo( () => (typeof editingId === "number" ? list.find((d) => d.id === editingId) ?? null : null), [list, editingId] ); const dirty = JSON.stringify(form) !== snapshot; const editorOpen = editingId !== null; async function reload() { setReloadBusy(true); try { const { docs } = await apiAdminListLibraryDocs(); setList(docs); setLoadError(""); } catch (e) { setLoadError(e instanceof Error ? e.message : "加载书库失败"); } finally { setReloadBusy(false); } } async function runImport(file: File, mode: "create" | "overwrite") { setImporting(true); setImportReport(null); try { const rep = await apiAdminImportLibraryZip(file, mode); setImportReport(rep); const okN = rep.imported.length; const failN = rep.failed.length; if (okN > 0) toast(`成功导入 ${okN} 本书${failN ? `,${failN} 本未导入` : ""}`, "ok"); else toast("没有书籍被导入,请查看失败明细", "error"); await reload(); if (mode === "create") { const conflicts = rep.failed.filter((f) => f.reason === "conflict").length; if (conflicts > 0) setImportOverwrite({ file, count: conflicts }); } } catch (e) { toast(e instanceof Error ? e.message : "导入失败"); } finally { setImporting(false); } } function onPickImportFile(e: ChangeEvent) { const file = e.target.files?.[0]; e.target.value = ""; // 允许重选同一文件 if (file) void runImport(file, "create"); } function openNew() { setForm(EMPTY_FORM); setSnapshot(JSON.stringify(EMPTY_FORM)); setFormError(""); setTitleError(""); setSlugError(""); setUploadMsg(""); setTab("info"); setEditingId("new"); } function openEdit(d: LibraryDoc) { const f = formOfDoc(d); setForm(f); setSnapshot(JSON.stringify(f)); setFormError(""); setTitleError(""); setSlugError(""); setUploadMsg(""); setEditingId(d.id); } /** dirty 时先弹确认,再执行动作(关闭编辑器 / 切换条目 / 新建 / 切 tab) */ function guardNav(action: () => void) { if ((dirty || sectionsDirty) && !saving) setPendingNav(() => action); else action(); } function patchForm(p: Partial) { setFormError(""); if (p.title !== undefined) setTitleError(""); if (p.slug !== undefined) setSlugError(""); setForm((f) => ({ ...f, ...p })); } async function save() { const title = form.title.trim(); const slug = form.slug.trim().toLowerCase(); const tErr = title ? "" : "请填写标题"; const sErr = SLUG_RE.test(slug) ? "" : "仅限小写字母、数字和连字符,且不以连字符开头或结尾"; setTitleError(tErr); setSlugError(sErr); if (tErr || sErr) return; setSaving(true); setFormError(""); const editing = editingId; if (editing === null) return; try { const input: LibraryInput = { slug, title, author: form.author.trim(), description: form.description, cover_url: form.cover_url.trim(), cover_width: form.cover_width, cover_height: form.cover_height, published: form.published, sort_order: form.sort_order, entries_auto: form.entries_auto, }; if (editing === "new") { const { doc } = await apiAdminCreateLibraryDoc(input); toast("条目已创建,开始编写章节内容", "ok"); // 不离开编辑器:切到新条目并进入「章节与正文」 openEdit(doc); setTab("sections"); } else { await apiAdminUpdateLibraryDoc(editing, input); toast("条目已保存", "ok"); setSnapshot(JSON.stringify(form)); } await reload(); } catch (e) { setFormError(e instanceof Error ? e.message : "保存失败"); } finally { setSaving(false); } } async function pickCover(file: File | undefined) { if (!file) return; setCoverBusy(true); try { const res = await apiUploadPostImage(file, undefined, "library_cover"); if (res.url) { patchForm({ cover_url: res.url, cover_width: res.attachment?.width ?? 0, cover_height: res.attachment?.height ?? 0, }); } else { setFormError(res.error || "封面上传失败"); } } catch { setFormError("封面上传失败"); } finally { setCoverBusy(false); if (coverInputRef.current) coverInputRef.current.value = ""; } } async function pickCoverFromMedia(attachmentId: number) { setCoverBusy(true); try { const { url, width, height } = await apiLibraryCoverFromMedia(attachmentId); patchForm({ cover_url: url, cover_width: width, cover_height: height }); } catch (e) { setFormError(e instanceof Error ? e.message : "选用图库图片失败"); } finally { setCoverBusy(false); } } async function pickFiles(files: FileList | null) { if (!files || files.length === 0 || typeof editingId !== "number") return; const accepted: File[] = []; const rejected: string[] = []; for (const f of Array.from(files)) { if (ALLOWED_EXTS.has(extOf(f.name))) accepted.push(f); else rejected.push(f.name); } const msgs: string[] = []; if (rejected.length) msgs.push(`已跳过不支持的格式:${rejected.join("、")}`); setUploading(true); setUploadMsg(msgs.join(" ")); try { const errors: string[] = []; for (const f of accepted) { const res = await apiAdminUploadLibraryFile(editingId, f); if (res.error) errors.push(`${f.name}:${res.error}`); } if (errors.length) msgs.push(...errors); if (msgs.length) setUploadMsg(msgs.join(";")); else if (accepted.length) setUploadMsg(""); if (accepted.length) toast(`已上传 ${accepted.length} 个文件`, "ok"); await reload(); } finally { setUploading(false); if (fileInputRef.current) fileInputRef.current.value = ""; } } async function removeFile() { if (!fileDeleteTarget) return; setFileDeleteBusy(true); try { await apiAdminDeleteLibraryFile(fileDeleteTarget.id); toast("文件已删除", "ok"); setFileDeleteTarget(null); await reload(); } catch (e) { toast(e instanceof Error ? e.message : "删除文件失败"); } finally { setFileDeleteBusy(false); } } async function removeDoc() { if (!docDeleteTarget) return; setDocDeleteBusy(true); const targetId = docDeleteTarget.id; try { await apiAdminDeleteLibraryDoc(targetId); setList((l) => l.map((x) => (x.id === targetId ? { ...x, deleted: true } : x))); toast("条目已删除,可在列表中彻底删除以释放地址", "ok"); setDocDeleteTarget(null); if (editingId === targetId) setEditingId(null); } catch (e) { toast(e instanceof Error ? e.message : "删除条目失败"); } finally { setDocDeleteBusy(false); } } async function purgeDoc() { if (!docPurgeTarget) return; setDocDeleteBusy(true); const targetId = docPurgeTarget.id; try { await apiAdminPurgeLibraryDoc(targetId); setList((l) => l.filter((x) => x.id !== targetId)); toast("条目已彻底删除,地址可重新使用", "ok"); setDocPurgeTarget(null); } catch (e) { toast(e instanceof Error ? e.message : "彻底删除失败"); } finally { setDocDeleteBusy(false); } } const publishedChip = editingId === "new" ? null : ( {form.published ? "已发布" : "草稿"} ); const publicHref = typeof editingId === "number" && form.slug.trim() && form.published ? libraryDocPath(urlStyle, form.slug.trim().toLowerCase()) : null; const editorTitle = editingId === "new" ? "新建书库条目" : editingDoc ? editingDoc.title : "条目不存在"; return (
导出全部 } /> {importReport ? (
导入完成:成功 {importReport.imported.length} 本 (新建 {importReport.imported.filter((i) => i.action === "created").length} / 覆盖{" "} {importReport.imported.filter((i) => i.action === "overwritten").length}),失败{" "} {importReport.failed.length} 本
{importReport.failed.length > 0 ? (
    {importReport.failed.map((f, i) => (
  • {f.title || f.slug} {f.slug ? `(${f.slug})` : ""} {f.reason === "conflict" ? "同名书籍已存在,可覆盖导入" : f.reason === "deleted" ? "地址被已删除条目占用,需先彻底删除" : f.reason === "invalid" ? "备份数据不受支持" : "导入失败"} {f.error}
  • ))}
) : null}
) : null}
{/* 左:条目列表 */}

条目列表

共 {list.length} 条{reloadBusy ? " · 刷新中…" : ""}
{loadError ? (

{loadError}

) : list.length === 0 ? ( } title="书库还没有条目" description="点右上角「新建条目」开始" /> ) : (
    {list.map((d) => { const active = d.id === editingId; return (
  • ); })}
)}
{/* 右:编辑器 */}

{editorTitle}

{publishedChip} {editorOpen ? ( ) : null}
{editorOpen ? ( <> {/* tab 条:书籍信息 / 章节与正文 */}
{typeof editingId === "number" && editingDoc ? `${editingDoc.sections?.length ?? 0} 章节 · ${editingDoc.files?.length ?? 0} 附件` : ""} {typeof editingId === "number" ? ( 导出备份 ) : null}
{/* 书籍信息 tab(两个 tab 均保持挂载,避免章节编辑状态丢失)。 lg 双列:左=编辑区(标题/访问地址/介绍),右=操作区+附件区;两列各自内滚,避免整页滚动 */}
{typeof editingId === "number" && !editingDoc ? ( } title="条目不存在或已删除" description="从左侧选择其他条目" /> ) : (
{/* 左:编辑区 */}
patchForm({ title })} placeholder="条目标题" className="admin-doc-title" /> patchForm({ author })} placeholder="作者或编著者,多位可用逗号分隔(可选)" />
/library/ patchForm({ slug: e.target.value })} />
{slugError ? (

{slugError}

) : (

小写字母、数字和连字符,最多 64 字符。

)}
{/* flex-1 撑满左列剩余高度:编辑框最大化,提示行贴列底 */}
patchForm({ description: v.slice(0, DESCRIPTION_MAX) })} maxChars={DESCRIPTION_MAX} allowHide={false} placeholder="条目介绍,支持 Markdown…" rows={12} ariaLabel="条目介绍" uploadSource="library_content" />
{/* 右:操作区 + 附件区 */}
)}
{/* 章节与正文 tab:key=doc.id,切换条目时重置面板内部状态 */} {typeof editingId === "number" && editingDoc ? (
) : null} {/* 底部操作条:info 的保存/关闭已移至右列,此处仅服务章节 tab */} {tab === "sections" ? (
{publicHref ? ( 线上页面 ) : null}
) : null} ) : (
} title="未选择条目" description="从左侧选择条目编辑,或新建条目" />
)}
{/* 导入遇到同名书籍:确认后覆盖重传(保留同一 File) */} !importing && setImportOverwrite(null)} onConfirm={() => { const pending = importOverwrite; setImportOverwrite(null); if (pending) void runImport(pending.file, "overwrite"); }} /> {/* 未保存关闭/切换确认 */} setPendingNav(null)} onConfirm={() => { const action = pendingNav; setPendingNav(null); action?.(); }} /> {/* 从图库选择封面 */} setCoverPickerOpen(false)} onPick={(item) => void pickCoverFromMedia(item.id)} /> {/* 删除文件确认 */} !fileDeleteBusy && setFileDeleteTarget(null)} onConfirm={() => void removeFile()} /> {/* 删除条目确认(软删) */} !docDeleteBusy && setDocDeleteTarget(null)} onConfirm={() => void removeDoc()} /> {/* 彻底删除条目确认 */} !docDeleteBusy && setDocPurgeTarget(null)} onConfirm={() => void purgeDoc()} />
); }