新增: - 书库导入导出(library_import/library_export)及测试 - 图片变体生成(image_variants)与响应式图片(responsiveImage) - 书籍搜索(bookSearch)+ BookSearch/LibrarySearchGrid 组件 - 小组件运行时(widgetRuntime)与静态检查(widgetLint) - 上传缓存中间件(upload_cache)与字体 CSS 提取脚本 其它: - 后端 handlers/services 全量调整 - 前端页面、组件、库函数与配置更新
1242 lines
51 KiB
TypeScript
1242 lines
51 KiB
TypeScript
"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>
|
||
);
|
||
}
|