Files
jiang13-bbs/frontend/app/admin/library/LibraryAdmin.tsx
freefire 4f3f3a265c feat: 友链/书库/移动端导航/排行榜等多模块功能
新增模块:
- 友链(friendlink)后端处理 + 前端管理页与友链板
- 书库(library):导入、章节、封面、阅读页、横竖版自适应 AdaptiveCoverSlot
- 顶栏导航(header_nav)配置与 MobileTabBar/MobileRailDrawers 移动端抽屉
- 排行榜 service 测试、站点页面测试、时间线发布(timeline_release)

其它改动:
- 后端 handlers/services 全量小幅调整
- 前端组件、库函数、URL/品牌/站点 URL 工具更新
- Lightbox 图片、MdEntries 条目卡、coverColor 派生色相等前端能力
2026-09-30 16:30:07 +08:00

1032 lines
42 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 } from "react";
import {
BookOpen,
Download,
ExternalLink,
ImagePlus,
Images,
Loader2,
Plus,
Trash2,
Upload,
X,
} from "lucide-react";
import {
apiAdminCreateLibraryDoc,
apiAdminDeleteLibraryDoc,
apiAdminDeleteLibraryFile,
apiAdminListLibraryDocs,
apiAdminPurgeLibraryDoc,
apiAdminUpdateLibraryDoc,
apiAdminUploadLibraryFile,
apiLibraryCoverFromMedia,
apiUploadPostImage,
type LibraryDoc,
type LibraryFileItem,
type LibraryInput,
} from "@/lib/api";
import { formatDateTime, formatRelative } from "@/lib/format";
import { libraryDocPath } from "@/lib/urlStyle";
import { useUrlStyle } from "@/components/SiteBrandProvider";
import { toast } from "@/lib/toast";
import ConfirmDialog from "@/components/ConfirmDialog";
import MarkdownEditor from "@/components/MarkdownEditor";
import { AdminEmpty, AdminField, AdminPageHeader, AdminStatusChip, AdminSwitch } from "@/components/admin";
import LibrarySectionsPanel from "./LibrarySectionsPanel";
import LibraryCoverPicker from "./LibraryCoverPicker";
// 与后端 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;
slug: string;
description: string;
cover_url: string;
published: boolean;
sort_order: number;
entries_auto: boolean;
}
const EMPTY_FORM: DocForm = {
title: "",
slug: "",
description: "",
cover_url: "",
published: false,
sort_order: 0,
entries_auto: false,
};
function formOfDoc(d: LibraryDoc): DocForm {
return {
title: d.title,
slug: d.slug,
description: d.description ?? "",
cover_url: d.cover_url ?? "",
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);
// 条目删除(软删)与彻底删除
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);
}
}
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,
description: form.description,
cover_url: form.cover_url.trim(),
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);
if (res.url) {
patchForm({ cover_url: res.url });
} 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 = await apiLibraryCoverFromMedia(attachmentId);
patchForm({ cover_url: url });
} 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={
<button
type="button"
className="btn btn-primary"
onClick={() => guardNav(openNew)}
disabled={saving}
>
<Plus size={15} /> 新建条目
</button>
}
/>
<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 ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={d.cover_url}
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>
</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"
/>
<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="条目介绍"
/>
</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 items-center gap-2.5">
{form.cover_url ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={form.cover_url}
alt="封面预览"
className="w-12 h-12 rounded-lg object-cover shrink-0"
style={{ border: "1px solid var(--line)" }}
/>
) : (
<button
type="button"
className="w-12 h-12 rounded-lg flex items-center justify-center shrink-0"
style={{ border: "1px dashed var(--control-border)" }}
aria-label="上传封面"
onClick={() => coverInputRef.current?.click()}
>
<ImagePlus size={16} style={{ color: "var(--ink-3)" }} aria-hidden />
</button>
)}
<div className="flex flex-col gap-1">
<div className="flex gap-1.5">
<button
type="button"
className="btn btn-line btn-sm flex-1"
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 flex-1"
disabled={coverBusy}
onClick={() => setCoverPickerOpen(true)}
>
<Images size={13} aria-hidden />
图库
</button>
</div>
{form.cover_url ? (
<button
type="button"
className="admin-doc-textbtn"
onClick={() => patchForm({ cover_url: "" })}
>
移除
</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>
{/* 附件管理:长列表列内滚动 */}
<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>
{/* 未保存关闭/切换确认 */}
<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>
);
}