Files
jiang13-bbs/frontend/app/admin/library/LibraryAdmin.tsx
freefire ff2ab286fb feat: 书库导入导出/图片变体/书籍搜索/小组件运行时等
新增:
- 书库导入导出(library_import/library_export)及测试
- 图片变体生成(image_variants)与响应式图片(responsiveImage)
- 书籍搜索(bookSearch)+ BookSearch/LibrarySearchGrid 组件
- 小组件运行时(widgetRuntime)与静态检查(widgetLint)
- 上传缓存中间件(upload_cache)与字体 CSS 提取脚本

其它:
- 后端 handlers/services 全量调整
- 前端页面、组件、库函数与配置更新
2026-10-01 03:06:05 +08:00

1242 lines
51 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 { 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 (
<span
className="w-9 h-9 rounded-lg shrink-0 flex items-center justify-center text-[15px] font-extrabold select-none"
style={{
color: "var(--accent)",
background:
"radial-gradient(120% 140% at 20% -10%, color-mix(in srgb, var(--accent) 22%, transparent), transparent 60%), var(--accent-soft)",
}}
aria-hidden
>
{ch}
</span>
);
}
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<number | "new" | null>(null);
const [form, setForm] = useState<DocForm>(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<HTMLInputElement>(null);
const [coverBusy, setCoverBusy] = useState(false);
const [coverPickerOpen, setCoverPickerOpen] = useState(false);
// 文件管理
const fileInputRef = useRef<HTMLInputElement>(null);
const [uploading, setUploading] = useState(false);
const [uploadMsg, setUploadMsg] = useState("");
const [fileDeleteTarget, setFileDeleteTarget] = useState<LibraryFileItem | null>(null);
const [fileDeleteBusy, setFileDeleteBusy] = useState(false);
// 书库导入恢复(上传导出 ZIP)
const importInputRef = useRef<HTMLInputElement>(null);
const [importing, setImporting] = useState(false);
const [importReport, setImportReport] = useState<LibraryImportReport | null>(null);
// create 模式遇到 conflict 时保留 File,供确认后 overwrite 重传
const [importOverwrite, setImportOverwrite] = useState<{ file: File; count: number } | null>(null);
// 条目删除(软删)与彻底删除
const [docDeleteTarget, setDocDeleteTarget] = useState<LibraryDoc | null>(null);
const [docDeleteBusy, setDocDeleteBusy] = useState(false);
const [docPurgeTarget, setDocPurgeTarget] = useState<LibraryDoc | null>(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<HTMLInputElement>) {
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<DocForm>) {
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 : (
<AdminStatusChip tone={form.published ? "ok" : "warn"}>
{form.published ? "已发布" : "草稿"}
</AdminStatusChip>
);
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 (
<div className="admin-content-page">
<AdminPageHeader
icon={BookOpen}
title="书库管理"
description="书籍、文档与页面作品。书籍由介绍、目录与章节正文组成,像书一样阅读;附件仅提供下载分发。"
actions={
<>
<a
href="/api/admin/library/export"
className="btn btn-ghost"
title="把全部书籍打包为 ZIP(介绍、章节、附件与封面),用于迁移或备份"
>
<Download size={15} aria-hidden />
导出全部
</a>
<button
type="button"
className="btn btn-ghost"
onClick={() => importInputRef.current?.click()}
disabled={importing}
title="上传导出的 ZIP 备份包,恢复书籍(介绍、章节、附件与封面)"
>
{importing ? (
<Loader2 size={15} className="animate-spin" aria-hidden />
) : (
<Upload size={15} aria-hidden />
)}
{importing ? "导入中…" : "导入恢复"}
</button>
<input
ref={importInputRef}
type="file"
accept=".zip,application/zip"
className="hidden"
onChange={onPickImportFile}
/>
<button
type="button"
className="btn btn-primary"
onClick={() => guardNav(openNew)}
disabled={saving}
>
<Plus size={15} /> 新建条目
</button>
</>
}
/>
{importReport ? (
<div
className="panel mx-3.5 sm:mx-4 my-3 px-4 py-3 text-[13px]"
role="status"
style={{ borderColor: "var(--line)" }}
>
<div className="flex items-center gap-2">
<strong style={{ color: "var(--ink)" }}>
导入完成:成功 {importReport.imported.length} 本
(新建 {importReport.imported.filter((i) => i.action === "created").length} / 覆盖{" "}
{importReport.imported.filter((i) => i.action === "overwritten").length}),失败{" "}
{importReport.failed.length} 本
</strong>
<button
type="button"
className="ml-auto btn btn-ghost"
style={{ padding: "2px 6px" }}
onClick={() => setImportReport(null)}
aria-label="关闭导入结果"
>
<X size={14} aria-hidden />
</button>
</div>
{importReport.failed.length > 0 ? (
<ul className="mt-2 flex flex-col gap-1">
{importReport.failed.map((f, i) => (
<li key={`${f.slug}-${i}`} className="meta flex flex-wrap items-baseline gap-x-2">
<span style={{ color: "var(--ink-2)" }}>
{f.title || f.slug}
{f.slug ? `(${f.slug})` : ""}
</span>
<span
style={{
color:
f.reason === "conflict" || f.reason === "deleted"
? "var(--warn, #b45309)"
: "var(--danger, #b91c1c)",
}}
>
{f.reason === "conflict"
? "同名书籍已存在,可覆盖导入"
: f.reason === "deleted"
? "地址被已删除条目占用,需先彻底删除"
: f.reason === "invalid"
? "备份数据不受支持"
: "导入失败"}
</span>
<span>{f.error}</span>
</li>
))}
</ul>
) : null}
</div>
) : null}
<div
className="admin-library-split"
data-view={editorOpen ? "editor" : "list"}
>
{/* 左:条目列表 */}
<section className="panel admin-content-col admin-library-list" aria-label="条目列表">
<header
className="admin-content-col-head flex items-center gap-2 px-3.5 sm:px-4 py-3"
style={{ borderBottom: "1px solid var(--line)", background: "var(--panel-2)" }}
>
<BookOpen size={15} style={{ color: "var(--accent)" }} aria-hidden />
<h2 className="text-[14px] font-semibold" style={{ color: "var(--ink)" }}>
条目列表
</h2>
<span className="meta text-[12px] tabular-nums ml-auto">
共 {list.length} 条{reloadBusy ? " · 刷新中…" : ""}
</span>
</header>
<div className="admin-content-col-body">
{loadError ? (
<p className="alert-error m-4">{loadError}</p>
) : list.length === 0 ? (
<AdminEmpty
icon={<BookOpen size={22} />}
title="书库还没有条目"
description="点右上角「新建条目」开始"
/>
) : (
<ul>
{list.map((d) => {
const active = d.id === editingId;
return (
<li key={d.id}>
<button
type="button"
onClick={() => {
if (d.deleted) return; // 已删除条目不可编辑,仅可彻底删除
guardNav(() => openEdit(d));
}}
aria-current={active ? "true" : undefined}
className="w-full text-left px-3.5 sm:px-4 py-3 flex items-center gap-3 transition-colors hover:bg-[var(--panel-2)]"
style={{
borderTop: "1px solid var(--line)",
...(active
? { background: "var(--accent-soft)", boxShadow: "inset 2px 0 0 var(--accent)" }
: null),
...(d.deleted ? { opacity: 0.65 } : null),
}}
>
{d.cover_url ? (
// 36px 封面位:本站图走 48/96 变体
// eslint-disable-next-line @next/next/no-img-element
<img
src={isOwnImage(d.cover_url) ? variantURL(d.cover_url, pickVariantWidth(36)) : d.cover_url}
srcSet={isOwnImage(d.cover_url) ? densitySrcSet(d.cover_url, 36) : undefined}
alt=""
className="w-9 h-9 rounded-lg object-cover shrink-0"
style={{ border: "1px solid var(--line)" }}
/>
) : (
<TextThumb title={d.title} />
)}
<span className="min-w-0 flex-1">
<span className="flex items-center gap-1.5 min-w-0">
<span
className="text-[13.5px] font-semibold truncate"
style={{ color: "var(--ink)" }}
title={d.title}
>
{d.title}
</span>
</span>
<span className="meta text-[11.5px] block truncate">
/library/{d.slug} · {d.files?.length ?? 0} 个文件 ·{" "}
{d.updated_at ? formatRelative(d.updated_at) : "—"}
</span>
</span>
<span className="shrink-0 flex items-center gap-1.5">
{d.deleted ? (
<AdminStatusChip tone="danger">已删除</AdminStatusChip>
) : (
<AdminStatusChip tone={d.published ? "ok" : "warn"}>
{d.published ? "已发布" : "草稿"}
</AdminStatusChip>
)}
<span
role="button"
tabIndex={0}
className="w-6 h-6 shrink-0 rounded-md flex items-center justify-center hover:bg-[var(--panel)] transition-colors"
style={{ color: d.deleted ? "var(--danger)" : "var(--ink-3)" }}
title={d.deleted ? "彻底删除" : "删除条目"}
aria-label={d.deleted ? `彻底删除「${d.title}」` : `删除条目「${d.title}」`}
onClick={(e) => {
e.stopPropagation();
if (d.deleted) setDocPurgeTarget(d);
else setDocDeleteTarget(d);
}}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.stopPropagation();
if (d.deleted) setDocPurgeTarget(d);
else setDocDeleteTarget(d);
}
}}
>
<Trash2 size={13} aria-hidden />
</span>
</span>
</button>
</li>
);
})}
</ul>
)}
</div>
</section>
{/* 右:编辑器 */}
<section
className="panel admin-content-col admin-library-editor"
aria-label="条目编辑"
>
<header
className="admin-content-col-head flex items-center gap-2 px-3.5 sm:px-4 py-3"
style={{ borderBottom: "1px solid var(--line)", background: "var(--panel-2)" }}
>
<BookOpen size={15} style={{ color: "var(--accent)" }} aria-hidden />
<h2
className="text-[14px] font-semibold truncate min-w-0"
style={{ color: "var(--ink)" }}
title={editorTitle}
>
{editorTitle}
</h2>
{publishedChip}
{editorOpen ? (
<button
type="button"
className="ml-auto w-7 h-7 shrink-0 flex items-center justify-center rounded-full hover:bg-[var(--panel)] transition-colors"
style={{ color: "var(--ink-3)" }}
aria-label="关闭编辑器"
disabled={saving}
onClick={() => guardNav(() => setEditingId(null))}
>
<X size={15} />
</button>
) : null}
</header>
{editorOpen ? (
<>
{/* tab 条:书籍信息 / 章节与正文 */}
<div
className="shrink-0 flex items-center gap-1.5 px-3.5 sm:px-4 py-2"
style={{ borderBottom: "1px solid var(--line)" }}
>
<button
type="button"
className={tab === "info" ? "btn btn-primary btn-sm" : "btn btn-line btn-sm"}
disabled={saving}
onClick={() => guardNav(() => setTab("info"))}
>
书籍信息
</button>
<button
type="button"
className={tab === "sections" ? "btn btn-primary btn-sm" : "btn btn-line btn-sm"}
disabled={saving || typeof editingId !== "number"}
onClick={() => guardNav(() => setTab("sections"))}
>
章节与正文
</button>
<span className="meta text-[12px] tabular-nums ml-auto">
{typeof editingId === "number" && editingDoc
? `${editingDoc.sections?.length ?? 0} 章节 · ${editingDoc.files?.length ?? 0} 附件`
: ""}
</span>
{typeof editingId === "number" ? (
<a
href={`/api/admin/library/docs/${editingId}/export`}
className="btn btn-line btn-sm shrink-0"
title="导出本书为 ZIP(介绍、章节、附件与本地封面),用于迁移或备份"
>
<Download size={13} aria-hidden />
导出备份
</a>
) : null}
</div>
{/* 书籍信息 tab(两个 tab 均保持挂载,避免章节编辑状态丢失)。
lg 双列:左=编辑区(标题/访问地址/介绍),右=操作区+附件区;两列各自内滚,避免整页滚动 */}
<div className={`admin-content-col-body p-4 sm:p-5 ${tab === "info" ? "" : "hidden"} lg:overflow-hidden`}>
{typeof editingId === "number" && !editingDoc ? (
<AdminEmpty
icon={<BookOpen size={22} />}
title="条目不存在或已删除"
description="从左侧选择其他条目"
/>
) : (
<div className="flex flex-col gap-5 lg:grid lg:h-full lg:min-h-0 lg:grid-cols-[minmax(0,1fr)_300px] lg:gap-x-5 lg:gap-y-0">
{/* 左:编辑区 */}
<div className="flex flex-col gap-5 lg:min-h-0 lg:overflow-y-auto lg:pr-1 j13-rail-scroll">
<AdminField
id="lib-title"
label="标题"
maxLength={200}
value={form.title}
error={titleError}
onChange={(title) => patchForm({ title })}
placeholder="条目标题"
className="admin-doc-title"
/>
<AdminField
id="lib-author"
label="作者"
maxLength={100}
value={form.author}
onChange={(author) => patchForm({ author })}
placeholder="作者或编著者,多位可用逗号分隔(可选)"
/>
<div className="admin-doc-field">
<label className="admin-doc-label" htmlFor="lib-slug">
访问地址
</label>
<div className={`admin-doc-slug${slugError ? " is-invalid" : ""}`}>
<span className="admin-doc-slug-prefix">/library/</span>
<input
id="lib-slug"
className="admin-doc-slug-input"
maxLength={64}
value={form.slug}
autoCapitalize="none"
autoCorrect="off"
spellCheck={false}
placeholder="how-to-live-better"
aria-invalid={slugError ? true : undefined}
onChange={(e) => patchForm({ slug: e.target.value })}
/>
</div>
{slugError ? (
<p className="admin-settings-row-error" role="alert">
{slugError}
</p>
) : (
<p className="admin-doc-hint">小写字母、数字和连字符,最多 64 字符。</p>
)}
</div>
{/* flex-1 撑满左列剩余高度:编辑框最大化,提示行贴列底 */}
<div className="admin-doc-md flex flex-col flex-1 min-h-0">
<label className="admin-doc-label" htmlFor="lib-description">
介绍
</label>
<MarkdownEditor
id="lib-description"
value={form.description}
onChange={(v) => patchForm({ description: v.slice(0, DESCRIPTION_MAX) })}
maxChars={DESCRIPTION_MAX}
allowHide={false}
placeholder="条目介绍,支持 Markdown…"
rows={12}
ariaLabel="条目介绍"
uploadSource="library_content"
/>
</div>
</div>
{/* 右:操作区 + 附件区 */}
<aside className="flex flex-col gap-4 lg:min-h-0">
<div className="admin-doc-field">
<p className="admin-doc-label">封面(可选)</p>
<div className="flex flex-col gap-2">
{form.cover_url ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={isOwnImage(form.cover_url) ? variantURL(form.cover_url, 256) : form.cover_url}
alt="封面预览"
className="w-full h-32 rounded-xl object-cover"
style={{ border: "1px solid var(--line)" }}
/>
) : (
<button
type="button"
className="w-full h-32 rounded-xl flex flex-col items-center justify-center gap-1.5"
style={{ border: "1px dashed var(--control-border)" }}
aria-label="上传封面"
onClick={() => coverInputRef.current?.click()}
>
<ImagePlus size={20} style={{ color: "var(--ink-3)" }} aria-hidden />
<span className="text-[11.5px]" style={{ color: "var(--ink-3)" }}>
点击上传封面
</span>
</button>
)}
<div className="flex items-center gap-1.5">
<button
type="button"
className="btn btn-line btn-sm"
disabled={coverBusy}
onClick={() => coverInputRef.current?.click()}
>
{coverBusy ? (
<Loader2 size={13} className="animate-spin" aria-hidden />
) : (
<ImagePlus size={13} aria-hidden />
)}
{form.cover_url ? "更换" : "上传"}
</button>
<button
type="button"
className="btn btn-line btn-sm"
disabled={coverBusy}
onClick={() => setCoverPickerOpen(true)}
>
<Images size={13} aria-hidden />
图库
</button>
{form.cover_url ? (
<button
type="button"
className="admin-doc-textbtn ml-auto"
style={{ minHeight: 0, minWidth: 0, padding: "1px 6px", fontSize: 12 }}
onClick={() => patchForm({ cover_url: "", cover_width: 0, cover_height: 0 })}
>
移除
</button>
) : null}
</div>
</div>
<input
ref={coverInputRef}
type="file"
accept="image/*"
className="sr-only"
onChange={(e) => void pickCover(e.target.files?.[0])}
/>
<p className="admin-doc-hint">留空时前台用标题首字生成文字封面。</p>
</div>
<div className="admin-doc-field">
<p className="admin-doc-label">发布状态</p>
<div className="admin-doc-switchline">
<AdminSwitch
checked={form.published}
onChange={() => patchForm({ published: !form.published })}
label="发布到前台书库"
/>
<span>{form.published ? "已发布" : "草稿"}</span>
</div>
<p className="admin-doc-hint">草稿仅后台可见,文件也无法公开访问。</p>
</div>
<div className="admin-doc-field">
<p className="admin-doc-label">全章节条目卡模式</p>
<div className="admin-doc-switchline">
<AdminSwitch
checked={form.entries_auto}
onChange={() => patchForm({ entries_auto: !form.entries_auto })}
label="全部章节按条目卡渲染"
/>
<span>{form.entries_auto ? "已开启" : "关闭"}</span>
</div>
<p className="admin-doc-hint">
开启后,章节里写成「## 小节标题 + 字段 :: 内容」的小节自动渲染为条目卡,字段名随意、无需任何配置;
@tags 标签 / @summary 摘要 / @source 来源可选,编辑器工具栏「条目卡」可一键插入模板;
个别章节用 [entries:off]…[/entries] 恢复普通排版。
</p>
</div>
<div className="admin-doc-field">
<label className="admin-doc-label" htmlFor="lib-sort">
排序
</label>
<input
id="lib-sort"
type="number"
className="admin-doc-number w-28"
value={form.sort_order}
onChange={(e) => {
const n = Number.parseInt(e.target.value, 10);
patchForm({ sort_order: Number.isNaN(n) ? 0 : n });
}}
/>
<p className="admin-doc-hint">数字越小越靠前。</p>
</div>
{typeof editingId === "number" && editingDoc?.creator ? (
<div className="admin-doc-field">
<p className="admin-doc-label">创建者</p>
<a
href={userPath(urlStyle, editingDoc.creator.id)}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-2 text-[13px] font-medium max-w-full"
style={{ color: "var(--ink)" }}
title={`@${editingDoc.creator.username}`}
>
<Avatar
size={22}
name={editingDoc.creator.nickname || editingDoc.creator.username}
src={editingDoc.creator.avatar}
/>
<span className="truncate">
{editingDoc.creator.nickname || editingDoc.creator.username}
</span>
</a>
<p className="admin-doc-hint">条目创建后归属不变。</p>
</div>
) : null}
{/* 附件管理:长列表列内滚动 */}
<div className="admin-doc-field flex flex-col lg:flex-1 lg:min-h-0">
<p className="admin-doc-label shrink-0">
文件{editingDoc ? `(${editingDoc.files?.length ?? 0}/20)` : ""}
</p>
{typeof editingId === "number" && editingDoc ? (
<div className="mt-1 lg:flex-1 lg:min-h-0 lg:overflow-y-auto j13-rail-scroll">
<button
type="button"
className="btn btn-line btn-sm"
disabled={uploading || (editingDoc.files?.length ?? 0) >= 20}
onClick={() => fileInputRef.current?.click()}
>
{uploading ? (
<Loader2 size={14} className="animate-spin" aria-hidden />
) : (
<Upload size={14} aria-hidden />
)}
上传文件
</button>
<input
ref={fileInputRef}
type="file"
multiple
className="sr-only"
onChange={(e) => void pickFiles(e.target.files)}
/>
<p className="admin-doc-hint">
支持 html / pdf / epub / mobi / azw3 / zip / 7z / rar / txt / md / doc / docx / ppt / pptx,仅下载分发。
书籍正文请在「章节与正文」中编辑,此处文件仅作附件分发。
</p>
{uploadMsg ? <p className="admin-doc-hint">{uploadMsg}</p> : null}
{editingDoc.files?.length ? (
<ul className="mt-2 flex flex-col" style={{ borderTop: "1px solid var(--line)" }}>
{editingDoc.files.map((f) => (
<li
key={f.id}
className="flex items-center gap-2.5 py-2"
style={{ borderBottom: "1px solid var(--line)" }}
>
<span
className="text-[10.5px] font-semibold px-1.5 py-0.5 rounded-md uppercase tracking-wide shrink-0"
style={{ color: "var(--accent)", background: "var(--accent-soft)" }}
>
{f.ext}
</span>
<span
className="min-w-0 flex-1 truncate text-[13px]"
style={{ color: "var(--ink)" }}
title={f.name}
>
{f.name}
</span>
<span className="meta text-[11.5px] shrink-0 tabular-nums">
{formatSize(f.size)}
</span>
<a
href={`/api/library/files/${f.id}/download`}
className="admin-doc-textbtn shrink-0"
title="下载"
>
<Download size={14} aria-hidden />
</a>
<button
type="button"
className="admin-doc-textbtn shrink-0"
title="删除文件"
onClick={() => setFileDeleteTarget(f)}
>
<Trash2 size={14} aria-hidden />
</button>
</li>
))}
</ul>
) : (
<p className="meta text-[12px] mt-2">还没有文件。</p>
)}
</div>
) : (
<div
className="mt-1 rounded-xl px-4 py-5 flex flex-col items-center gap-2 text-center"
style={{ border: "1px dashed var(--control-border)" }}
>
<Upload size={18} style={{ color: "var(--ink-3)" }} aria-hidden />
<p className="text-[13px] leading-relaxed" style={{ color: "var(--ink-2)" }}>
先保存条目,即可在此上传 html / pdf / epub 等文件(最多 20 个)
</p>
<button
type="button"
className="btn btn-primary btn-sm"
disabled={saving || !dirty}
title={dirty ? undefined : "没有需要保存的修改"}
onClick={() => void save()}
>
{saving ? (
<Loader2 size={13} className="animate-spin" aria-hidden />
) : null}
保存条目并上传文件
</button>
</div>
)}
</div>
{/* 操作区:状态提示 + 保存/关闭/线上页面 */}
<div
className="shrink-0 flex flex-col gap-1.5 pt-3"
style={{ borderTop: "1px solid var(--line)" }}
>
{formError ? (
<p className="admin-settings-row-error" role="alert">
{formError}
</p>
) : null}
<div className="flex items-center gap-2 flex-wrap">
{dirty ? (
<span className="meta text-[12px] mr-auto">有未保存的修改</span>
) : (
<span className="mr-auto" />
)}
{publicHref ? (
<a
href={publicHref}
target="_blank"
rel="noopener noreferrer"
className="btn btn-line btn-sm shrink-0"
>
<ExternalLink size={14} aria-hidden /> 线上页面
</a>
) : null}
<button
type="button"
className="btn btn-line btn-sm shrink-0"
disabled={saving}
onClick={() => guardNav(() => setEditingId(null))}
>
关闭
</button>
<button
type="button"
className="btn btn-primary btn-sm shrink-0"
disabled={saving || !dirty}
title={dirty ? undefined : "没有需要保存的修改"}
onClick={() => void save()}
aria-busy={saving || undefined}
>
{saving ? <Loader2 size={13} className="animate-spin" aria-hidden /> : null}
{saving ? "保存中…" : "保存"}
</button>
</div>
</div>
</aside>
</div>
)}
</div>
{/* 章节与正文 tab:key=doc.id,切换条目时重置面板内部状态 */}
{typeof editingId === "number" && editingDoc ? (
<div className={`admin-content-col-body ${tab === "sections" ? "" : "hidden"}`}>
<LibrarySectionsPanel
key={editingDoc.id}
doc={editingDoc}
onChanged={reload}
onDirtyChange={setSectionsDirty}
/>
</div>
) : null}
{/* 底部操作条:info 的保存/关闭已移至右列,此处仅服务章节 tab */}
{tab === "sections" ? (
<footer
className="shrink-0 flex items-center gap-2 px-3.5 sm:px-4 py-3"
style={{ borderTop: "1px solid var(--line)", background: "var(--panel-2)" }}
>
<span className="mr-auto" />
{publicHref ? (
<a
href={publicHref}
target="_blank"
rel="noopener noreferrer"
className="btn btn-line btn-sm shrink-0"
>
<ExternalLink size={14} aria-hidden /> 线上页面
</a>
) : null}
<button
type="button"
className="btn btn-line btn-sm"
disabled={saving}
onClick={() => guardNav(() => setEditingId(null))}
>
关闭
</button>
</footer>
) : null}
</>
) : (
<div className="admin-content-col-body">
<AdminEmpty
icon={<BookOpen size={22} />}
title="未选择条目"
description="从左侧选择条目编辑,或新建条目"
/>
</div>
)}
</section>
</div>
{/* 导入遇到同名书籍:确认后覆盖重传(保留同一 File) */}
<ConfirmDialog
open={!!importOverwrite}
title="覆盖导入同名书籍?"
message={
importOverwrite
? `备份包中有 ${importOverwrite.count} 本书的地址已被现有书籍占用。选择覆盖将用备份包整体替换这些书籍的介绍、章节、附件与封面,现有内容不可恢复;其余书籍正常新建。`
: ""
}
danger
busy={importing}
confirmLabel="覆盖导入"
cancelLabel="保留现有"
onCancel={() => !importing && setImportOverwrite(null)}
onConfirm={() => {
const pending = importOverwrite;
setImportOverwrite(null);
if (pending) void runImport(pending.file, "overwrite");
}}
/>
{/* 未保存关闭/切换确认 */}
<ConfirmDialog
open={!!pendingNav}
title="放弃未保存的修改?"
message="当前条目有未保存的修改,继续将丢失这些改动。"
danger
confirmLabel="放弃修改"
cancelLabel="继续编辑"
onCancel={() => setPendingNav(null)}
onConfirm={() => {
const action = pendingNav;
setPendingNav(null);
action?.();
}}
/>
{/* 从图库选择封面 */}
<LibraryCoverPicker
open={coverPickerOpen}
onClose={() => setCoverPickerOpen(false)}
onPick={(item) => void pickCoverFromMedia(item.id)}
/>
{/* 删除文件确认 */}
<ConfirmDialog
open={!!fileDeleteTarget}
title="删除文件"
message={
fileDeleteTarget
? `确定删除「${fileDeleteTarget.name}」吗?删除后不可恢复。`
: ""
}
danger
busy={fileDeleteBusy}
confirmLabel="删除"
onCancel={() => !fileDeleteBusy && setFileDeleteTarget(null)}
onConfirm={() => void removeFile()}
/>
{/* 删除条目确认(软删) */}
<ConfirmDialog
open={!!docDeleteTarget}
title="删除条目"
message={
docDeleteTarget
? `确定删除「${docDeleteTarget.title}」吗?删除后前台不再显示,可在列表中彻底删除以释放地址。`
: ""
}
danger
busy={docDeleteBusy}
confirmLabel="删除"
onCancel={() => !docDeleteBusy && setDocDeleteTarget(null)}
onConfirm={() => void removeDoc()}
/>
{/* 彻底删除条目确认 */}
<ConfirmDialog
open={!!docPurgeTarget}
title="彻底删除条目"
message={
docPurgeTarget
? `确定彻底删除「${docPurgeTarget.title}」吗?将同时清除其 ${docPurgeTarget.files?.length ?? 0} 个文件与全部章节记录,且不可恢复,地址将释放可重新使用。`
: ""
}
danger
busy={docDeleteBusy}
confirmLabel="彻底删除"
onCancel={() => !docDeleteBusy && setDocPurgeTarget(null)}
onConfirm={() => void purgeDoc()}
/>
</div>
);
}