feat: 友链/书库/移动端导航/排行榜等多模块功能

新增模块:
- 友链(friendlink)后端处理 + 前端管理页与友链板
- 书库(library):导入、章节、封面、阅读页、横竖版自适应 AdaptiveCoverSlot
- 顶栏导航(header_nav)配置与 MobileTabBar/MobileRailDrawers 移动端抽屉
- 排行榜 service 测试、站点页面测试、时间线发布(timeline_release)

其它改动:
- 后端 handlers/services 全量小幅调整
- 前端组件、库函数、URL/品牌/站点 URL 工具更新
- Lightbox 图片、MdEntries 条目卡、coverColor 派生色相等前端能力
This commit is contained in:
2026-09-30 16:30:07 +08:00
parent e487bc2e92
commit 4f3f3a265c
105 changed files with 12371 additions and 852 deletions

View File

@@ -0,0 +1,431 @@
"use client";
import { useMemo, useRef, useState } from "react";
import { FileText, Loader2, Upload, X } from "lucide-react";
import Modal from "@/components/Modal";
import { toast } from "@/lib/toast";
import type { LibraryImportMdInput, LibrarySectionItem } from "@/lib/api";
import { parseEpub } from "./parseEpub";
const MAX_IMPORT_BYTES = 5 * 1024 * 1024; // 与后端 MaxImportBytes 一致(单文件)
const MAX_TOTAL_BYTES = 20 * 1024 * 1024; // 与后端 MaxImportTotalBytes 一致
const MAX_FILES = 50; // 与后端 MaxImportFiles 一致
const MAX_EPUB_BYTES = 50 * 1024 * 1024; // epub 原始文件上限(二进制,图片占大头,转出文本通常很小)
const MAX_CHAPTER_CHARS = 100_000; // 与后端 MaxSectionContent 一致(epub 每目录项即一章)
type SplitLevel = "h1" | "h2" | "none";
const SPLIT_OPTIONS: { value: SplitLevel; label: string; hint: string }[] = [
{ value: "h1", label: "按一级标题(#)", hint: "每个 # 标题拆为一章" },
{ value: "h2", label: "按二级标题(##)", hint: "每个 ## 标题拆为一章" },
{ value: "none", label: "不拆分", hint: "每个文件整篇一章" },
];
interface PendingFile {
name: string;
text: string;
}
/** 与后端 splitMarkdownChapters 逻辑一致:跳过代码围栏,按所选级别标题取章标题列表 */
function splitPreview(content: string, level: SplitLevel): string[] {
if (level === "none") return [];
const prefix = level === "h2" ? "## " : "# ";
const titles: string[] = [];
let inCode = false;
let count = 0;
for (const raw of content.replace(/\r\n/g, "\n").replace(/\r/g, "\n").split("\n")) {
const t = raw.trim();
if (t.startsWith("```")) inCode = !inCode;
if (!inCode && t.startsWith(prefix) && t.length > prefix.length) {
const title = t.slice(prefix.length).replace(/#+\s*$/, "").trim();
titles.push(title || `第 ${++count} 章`);
}
}
return titles;
}
/** 单个文件预计拆出的章数(未命中标题回退 1 章,标题取文件名) */
function splitCount(text: string, level: SplitLevel): number {
if (level === "none") return 1;
const n = splitPreview(text, level).length;
return n > 0 ? n : 1;
}
export default function ImportMdDialog({
onImport,
onClose,
}: {
/** 执行导入,返回新建章节(父组件负责刷新与选中第一章);失败时抛错 */
onImport: (input: LibraryImportMdInput) => Promise<LibrarySectionItem[]>;
/** 关闭弹窗;导入成功时带出新建章节,父组件据此选中第一章 */
onClose: (created?: LibrarySectionItem[]) => void;
}) {
const fileInputRef = useRef<HTMLInputElement>(null);
const [files, setFiles] = useState<PendingFile[]>([]);
const [pasted, setPasted] = useState("");
const [splitLevel, setSplitLevel] = useState<SplitLevel>("h2");
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
// epub 模式:列表已被 epub 解析结果整体替换,按目录顺序每项一章(不参与拆分)
const [fromEpub, setFromEpub] = useState(false);
const [epubBusy, setEpubBusy] = useState(false);
// 已选文件优先;未选文件时用粘贴内容(视为一个伪文件)
const sourceFiles = useMemo<PendingFile[]>(
() =>
files.length > 0
? files
: pasted.trim()
? [{ name: "未命名导入.md", text: pasted }]
: [],
[files, pasted]
);
const perFileCounts = useMemo(
() => sourceFiles.map((f) => ({ name: f.name, count: splitCount(f.text, splitLevel) })),
[sourceFiles, splitLevel]
);
const totalChapters = perFileCounts.reduce((n, f) => n + f.count, 0);
const single = sourceFiles.length === 1 ? sourceFiles[0] : null;
const singleTitles = useMemo(
() => (single && splitLevel !== "none" ? splitPreview(single.text, splitLevel) : []),
[single, splitLevel]
);
const willFallback =
single != null && splitLevel !== "none" && singleTitles.length === 0 && single.text.trim().length > 0;
const oversizeFile = sourceFiles.find((f) => f.text.length > MAX_IMPORT_BYTES);
const overFiles = sourceFiles.length > MAX_FILES;
const overTotal = sourceFiles.reduce((n, f) => n + f.text.length, 0) > MAX_TOTAL_BYTES;
// epub 每个目录项即一章,无法再拆;超限章直接阻止导入(与后端校验一致)
const oversizeChapters = fromEpub
? sourceFiles.filter((f) => f.text.length > MAX_CHAPTER_CHARS)
: [];
async function pickFiles(list: FileList | null) {
if (!list || list.length === 0) return;
setError("");
const msgs: string[] = [];
const fileList = Array.from(list);
const epubFiles = fileList.filter((f) => f.name.toLowerCase().endsWith(".epub"));
const mdFiles = fileList.filter((f) => !f.name.toLowerCase().endsWith(".epub"));
// epub:解析为章节伪文件并整体替换(同一批次不与 md 混选)
if (epubFiles.length > 0) {
setEpubBusy(true);
try {
const chapters: PendingFile[] = [];
for (const f of epubFiles) {
if (f.size > MAX_EPUB_BYTES) {
msgs.push(`超过 50MB 已跳过:${f.name}`);
continue;
}
const parsed = await parseEpub(f);
for (const c of parsed) {
chapters.push({
name: `${String(chapters.length + 1).padStart(3, "0")}-${c.title || "未命名"}.md`,
text: c.markdown,
});
}
}
if (chapters.length > 0) {
if (chapters.length > MAX_FILES) {
msgs.push(`epub 解析出 ${chapters.length} 章,超过单次上限,已截取前 ${MAX_FILES} 章`);
}
setFiles(chapters.slice(0, MAX_FILES));
setSplitLevel("none");
setFromEpub(true);
} else if (!msgs.length) {
msgs.push("未能从 epub 中解析出章节");
}
} catch (e) {
msgs.push(e instanceof Error ? e.message : "epub 解析失败");
} finally {
setEpubBusy(false);
}
} else {
// md/txt:沿用原合并逻辑;当前若是 epub 章节列表则先清空
const picked: PendingFile[] = [];
const tooBig: string[] = [];
const failed: string[] = [];
for (const f of mdFiles) {
if (f.size > MAX_IMPORT_BYTES) {
tooBig.push(f.name);
continue;
}
try {
picked.push({ name: f.name, text: await f.text() });
} catch {
failed.push(f.name);
}
}
let truncated = false;
setFiles((prev) => {
const merged = [...(fromEpub ? [] : prev), ...picked];
// 文件名自然排序,保证 01、02…23 顺序
merged.sort((a, b) => a.name.localeCompare(b.name, "zh-Hans-CN", { numeric: true }));
truncated = merged.length > MAX_FILES;
return merged.slice(0, MAX_FILES);
});
if (fromEpub && picked.length > 0) {
setFromEpub(false);
msgs.push("已清空 epub 章节,改用所选 Markdown 文件");
}
if (tooBig.length) msgs.push(`超过 5MB 已跳过:${tooBig.join("、")}`);
if (failed.length) msgs.push(`无法读取:${failed.join("、")}`);
if (truncated) msgs.push(`单次最多 ${MAX_FILES} 个文件,已截取前 ${MAX_FILES} 个`);
}
if (msgs.length) setError(msgs.join(";"));
if (fileInputRef.current) fileInputRef.current.value = "";
}
function removeAt(idx: number) {
setFiles((prev) => prev.filter((_, i) => i !== idx));
}
async function submit() {
if (sourceFiles.length === 0) {
setError("请先选择文件或粘贴 Markdown 内容");
return;
}
if (oversizeFile) {
setError(`单个文件不能超过 5MB:${oversizeFile.name}`);
return;
}
if (overFiles) {
setError(`单次最多导入 ${MAX_FILES} 个文件`);
return;
}
if (overTotal) {
setError("内容总量过大:不能超过 20MB");
return;
}
if (oversizeChapters.length > 0) {
setError(
`以下章节正文超过 100000 字,请先拆分后再导入:${oversizeChapters.map((f) => f.name).join("、")}`
);
return;
}
setBusy(true);
setError("");
try {
const created = await onImport({
files: sourceFiles.map((f) => ({ filename: f.name, content: f.text })),
split_level: splitLevel,
});
toast(
sourceFiles.length > 1
? `已导入 ${sourceFiles.length} 个文件共 ${created.length} 章`
: `已导入 ${created.length} 章`,
"ok"
);
onClose(created);
} catch (e) {
setError(e instanceof Error ? e.message : "导入失败");
} finally {
setBusy(false);
}
}
const blocked = Boolean(oversizeFile || overFiles || overTotal || oversizeChapters.length > 0);
return (
<Modal
open
title="导入章节"
description="Markdown 按所选方式拆章;epub 按目录顺序导入,每个目录项一章"
onClose={onClose}
busy={busy}
maxWidthClass="max-w-xl"
bodyClassName="max-h-[60vh] overflow-y-auto j13-rail-scroll"
footer={
<>
<button type="button" className="btn btn-line" onClick={() => onClose()} disabled={busy}>
取消
</button>
<button
type="button"
className="btn btn-primary"
disabled={busy || epubBusy || sourceFiles.length === 0 || blocked}
onClick={() => void submit()}
aria-busy={busy || epubBusy || undefined}
>
{busy || epubBusy ? (
<Loader2 size={14} className="animate-spin" aria-hidden />
) : (
<Upload size={14} aria-hidden />
)}
{busy || epubBusy ? (epubBusy ? "解析中…" : "导入中…") : "导入"}
</button>
</>
}
>
<div className="flex flex-col gap-4">
<div>
<p className="admin-doc-label">
来源
{sourceFiles.length > 1 ? `(${sourceFiles.length} 个文件,按文件名顺序导入)` : ""}
</p>
<div className="flex items-center gap-2">
<button
type="button"
className="btn btn-line btn-sm"
disabled={busy || epubBusy}
onClick={() => fileInputRef.current?.click()}
>
<FileText size={13} aria-hidden /> 选择 .md / .txt / .epub 文件(可多选)
</button>
{files.length > 0 ? (
<button
type="button"
className="admin-doc-textbtn"
disabled={busy || epubBusy}
onClick={() => {
setFiles([]);
setFromEpub(false);
}}
>
清空
</button>
) : null}
</div>
<input
ref={fileInputRef}
type="file"
multiple
accept=".md,.markdown,.txt,.epub,text/markdown,text/plain,application/epub+zip"
className="sr-only"
onChange={(e) => void pickFiles(e.target.files)}
/>
</div>
{files.length > 0 ? (
<ul
className="rounded-xl max-h-48 overflow-y-auto j13-rail-scroll"
style={{ border: "1px solid var(--line)", background: "var(--panel-2)" }}
>
{files.map((f, i) => (
<li
key={`${f.name}-${i}`}
className="flex items-center gap-2 px-3 py-1.5"
style={{ borderBottom: i < files.length - 1 ? "1px solid var(--line)" : undefined }}
>
<span className="text-[12.5px] truncate min-w-0 flex-1" style={{ color: "var(--ink)" }}>
{f.name}
</span>
<span className="meta text-[11px] shrink-0 tabular-nums">{f.text.length} 字符</span>
<button
type="button"
className="w-5 h-5 shrink-0 rounded-md flex items-center justify-center hover:bg-[var(--panel)] transition-colors"
style={{ color: "var(--ink-3)" }}
aria-label={`移除 ${f.name}`}
disabled={busy}
onClick={() => removeAt(i)}
>
<X size={12} aria-hidden />
</button>
</li>
))}
</ul>
) : (
<div className="admin-doc-field">
<label className="admin-doc-label" htmlFor="import-md-text">
或直接粘贴 Markdown
</label>
<textarea
id="import-md-text"
className="admin-field-input"
rows={6}
value={pasted}
onChange={(e) => setPasted(e.target.value)}
placeholder="# 第一章 …"
spellCheck={false}
/>
<p className="admin-doc-hint">{pasted.length} 字符</p>
</div>
)}
{fromEpub ? (
<p className="admin-doc-hint">epub 按目录顺序导入,每个目录项一章,不参与拆分。</p>
) : (
<div className="admin-doc-field">
<p className="admin-doc-label">拆分方式(对每个文件生效)</p>
<div className="flex flex-col gap-1.5">
{SPLIT_OPTIONS.map((opt) => (
<label key={opt.value} className="flex items-center gap-2 cursor-pointer">
<input
type="radio"
name="import-split"
checked={splitLevel === opt.value}
onChange={() => setSplitLevel(opt.value)}
disabled={busy}
/>
<span className="text-[13px]" style={{ color: "var(--ink)" }}>
{opt.label}
</span>
<span className="meta text-[11.5px]">{opt.hint}</span>
</label>
))}
</div>
</div>
)}
<div className="admin-doc-field">
<p className="admin-doc-label">拆分预览</p>
{sourceFiles.length === 0 ? (
<p className="meta text-[12.5px]">等待内容…</p>
) : sourceFiles.length > 1 ? (
<>
<ul
className="rounded-xl px-3 py-2 max-h-40 overflow-y-auto j13-rail-scroll"
style={{ border: "1px solid var(--line)", background: "var(--panel-2)" }}
>
{perFileCounts.map((f, i) => (
<li
key={`${f.name}-${i}`}
className="flex items-center justify-between gap-2 text-[12.5px] py-0.5"
style={{ color: "var(--ink-2)" }}
>
<span className="truncate">{f.name}</span>
<span className="meta text-[11.5px] shrink-0 tabular-nums">{f.count} 章</span>
</li>
))}
</ul>
<p className="admin-doc-hint">
共 {sourceFiles.length} 个文件 · 预计 {totalChapters} 章
</p>
</>
) : willFallback ? (
<p className="meta text-[12.5px]">
未检测到所选级别的标题,将整篇导入为一章(标题取文件名)。
</p>
) : (
<ul
className="rounded-xl px-3 py-2 max-h-40 overflow-y-auto j13-rail-scroll"
style={{ border: "1px solid var(--line)", background: "var(--panel-2)" }}
>
{(splitLevel === "none" ? [single!.name] : singleTitles).map((t, i) => (
<li key={i} className="text-[12.5px] py-0.5 truncate" style={{ color: "var(--ink-2)" }}>
{i + 1}. {t}
</li>
))}
</ul>
)}
</div>
{overTotal ? <p className="meta text-[12.5px]">内容总量超过 20MB,无法导入。</p> : null}
{oversizeChapters.length > 0 ? (
<p className="meta text-[12.5px]">
以下章节正文超过 100000 字,无法导入:{oversizeChapters.map((f) => f.name).join("、")}
</p>
) : null}
{error ? (
<p className="admin-settings-row-error" role="alert">
{error}
</p>
) : null}
</div>
</Modal>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,97 @@
"use client";
import { Images, Loader2 } from "lucide-react";
import { useEffect, useState } from "react";
import Modal from "@/components/Modal";
import { apiMyMedia, type MediaAttachment } from "@/lib/api";
/** 书籍封面图库选择弹窗:列出本人媒体库图片,选中后由父组件复制一份作封面素材 */
export default function LibraryCoverPicker({
open,
onClose,
onPick,
}: {
open: boolean;
onClose: () => void;
onPick: (item: MediaAttachment) => void;
}) {
const [items, setItems] = useState<MediaAttachment[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
useEffect(() => {
if (!open) return;
let cancelled = false;
setLoading(true);
setError("");
void apiMyMedia()
.then((res) => {
if (cancelled) return;
if (res.error) {
setError(res.error);
setItems([]);
return;
}
setItems((res.attachments || []).filter((item) => item.kind === "image"));
})
.catch(() => {
if (!cancelled) setError("加载媒体库失败");
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
}, [open]);
return (
<Modal
open={open}
title="从图库选择封面"
description="选择后会复制一份作为封面素材,之后删除原图不影响封面。"
onClose={onClose}
maxWidthClass="max-w-lg"
dismissOnOverlay
>
{loading ? (
<p className="meta text-[13px] flex items-center gap-2 py-6 justify-center">
<Loader2 size={14} className="animate-spin" /> 加载中
</p>
) : error ? (
<p className="text-[13px] py-4" style={{ color: "var(--danger, #b42318)" }}>
{error}
</p>
) : items.length === 0 ? (
<p className="meta text-[13px] py-6 text-center">
图库还没有图片。上传封面或发帖插图后,会自动出现在这里。
</p>
) : (
<div className="grid grid-cols-4 sm:grid-cols-5 gap-2 max-h-[46vh] overflow-y-auto">
{items.map((item) => (
<button
key={item.id}
type="button"
onClick={() => {
onPick(item);
onClose();
}}
className="relative group aspect-square rounded-xl overflow-hidden"
style={{ boxShadow: "0 0 0 1px var(--line)", background: "var(--panel-2)" }}
data-tip="复制一份用作封面"
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={item.url} alt="图库候选封面" className="w-full h-full object-contain" loading="lazy" />
<span
className="pointer-events-none absolute inset-0 flex items-center justify-center opacity-0 group-hover:opacity-100"
style={{ background: "color-mix(in srgb, var(--ink) 40%, transparent)", color: "#fff" }}
>
<Images size={14} />
</span>
</button>
))}
</div>
)}
</Modal>
);
}

View File

@@ -0,0 +1,505 @@
"use client";
import { useEffect, useMemo, useState, type ReactNode } from "react";
import {
ChevronDown,
ChevronUp,
CornerDownRight,
ListTree,
Loader2,
Plus,
Save,
Trash2,
Upload,
} from "lucide-react";
import {
apiAdminCreateLibrarySection,
apiAdminDeleteLibrarySection,
apiAdminImportLibraryMarkdownBatch,
apiAdminMoveLibrarySection,
apiAdminUpdateLibrarySection,
type LibraryDoc,
type LibraryImportMdInput,
type LibrarySectionItem,
} from "@/lib/api";
import { toast } from "@/lib/toast";
import ConfirmDialog from "@/components/ConfirmDialog";
import MarkdownEditor from "@/components/MarkdownEditor";
import { AdminEmpty } from "@/components/admin";
import ImportMdDialog from "./ImportMdDialog";
const MAX_TITLE = 200;
const MAX_CONTENT = 100_000; // 与后端 MaxSectionContent 保持一致
const EMPTY_SNAPSHOT = JSON.stringify({ title: "", content: "" });
/**
* 「章节与正文」面板:左章节树(章 → 节 两级),右章节编辑器。
* 数据从 doc.sections 派生(后端 detail 已带全量含 content);
* 所有变更后调 onChanged() 让父组件刷新列表,doc.sections 随之更新。
*/
export default function LibrarySectionsPanel({
doc,
onChanged,
onDirtyChange,
}: {
doc: LibraryDoc;
/** 数据变更后由父组件刷新(doc.sections 随之更新) */
onChanged: () => Promise<void>;
/** dirty 状态上报,供父组件在关闭编辑器 / 切换条目前确认 */
onDirtyChange: (dirty: boolean) => void;
}) {
const sections = doc.sections ?? [];
// 平铺 → 章分组(顺序沿用后端 sort_order)
const groups = useMemo(() => {
const gs: { chapter: LibrarySectionItem; subs: LibrarySectionItem[] }[] = [];
for (const s of sections) {
if (s.parent_id == null) gs.push({ chapter: s, subs: [] });
else gs[gs.length - 1]?.subs.push(s);
}
return gs;
}, [sections]);
const [selectedId, setSelectedId] = useState<number | null>(null);
const [title, setTitle] = useState("");
const [content, setContent] = useState("");
const [snapshot, setSnapshot] = useState(EMPTY_SNAPSHOT);
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
const [pendingNav, setPendingNav] = useState<(() => void) | null>(null);
const [deleteTarget, setDeleteTarget] = useState<LibrarySectionItem | null>(null);
const [deleteBusy, setDeleteBusy] = useState(false);
const [importOpen, setImportOpen] = useState(false);
// 新建 / 导入后要选中的章节 id:等 doc.sections 里出现再应用,避免被旧数据清掉
const [pendingSelectId, setPendingSelectId] = useState<number | null>(null);
const selected = useMemo(
() => sections.find((s) => s.id === selectedId) ?? null,
[sections, selectedId]
);
const dirty = JSON.stringify({ title, content }) !== snapshot;
// dirty 上报给父组件;卸载时归零,避免残留的 dirty 影响父组件的关闭确认
useEffect(() => {
onDirtyChange(dirty);
return () => onDirtyChange(false);
}, [dirty, onDirtyChange]);
// 选中项被删除后清空选中
useEffect(() => {
if (selectedId != null && !sections.some((s) => s.id === selectedId)) {
setSelectedId(null);
}
}, [sections, selectedId]);
// 应用待选章节(新建 / 导入完成后)
useEffect(() => {
if (pendingSelectId == null) return;
const s = sections.find((x) => x.id === pendingSelectId);
if (!s) return;
setPendingSelectId(null);
setSelectedId(s.id);
setTitle(s.title);
setContent(s.content);
setSnapshot(JSON.stringify({ title: s.title, content: s.content }));
setError("");
}, [sections, pendingSelectId]);
function loadSection(s: LibrarySectionItem) {
setSelectedId(s.id);
setTitle(s.title);
setContent(s.content);
setSnapshot(JSON.stringify({ title: s.title, content: s.content }));
setError("");
}
/** dirty 时先弹确认,再执行动作(切换章节 / 打开导入 / 新增章节) */
function guardNav(action: () => void) {
if (dirty && !busy) setPendingNav(() => action);
else action();
}
function selectSection(s: LibrarySectionItem) {
if (s.id === selectedId) return;
guardNav(() => loadSection(s));
}
async function addSection(parent: LibrarySectionItem | null) {
setBusy(true);
setError("");
try {
const { section } = await apiAdminCreateLibrarySection(doc.id, {
parent_id: parent?.id,
title: parent ? "新节" : "新章",
});
await onChanged();
setPendingSelectId(section.id);
toast(parent ? "小节已添加" : "章已添加", "ok");
} catch (e) {
setError(e instanceof Error ? e.message : "添加章节失败");
} finally {
setBusy(false);
}
}
async function save() {
if (selectedId == null) return;
const t = title.trim();
if (!t) {
setError("章节标题不能为空");
return;
}
setBusy(true);
setError("");
try {
const { section } = await apiAdminUpdateLibrarySection(selectedId, {
title: t,
content,
});
await onChanged();
// 以服务端返回为准(后端会 trim 标题),保证 dirty 归零
setTitle(section.title);
setContent(section.content);
setSnapshot(JSON.stringify({ title: section.title, content: section.content }));
toast("章节已保存", "ok");
} catch (e) {
setError(e instanceof Error ? e.message : "保存章节失败");
} finally {
setBusy(false);
}
}
async function move(s: LibrarySectionItem, direction: "up" | "down") {
setBusy(true);
setError("");
try {
await apiAdminMoveLibrarySection(s.id, direction);
await onChanged(); // 选中 id 不变,原地刷新顺序
} catch (e) {
setError(e instanceof Error ? e.message : "移动章节失败");
} finally {
setBusy(false);
}
}
async function removeSection() {
if (!deleteTarget) return;
const target = deleteTarget;
setDeleteBusy(true);
try {
await apiAdminDeleteLibrarySection(target.id);
toast(target.parent_id == null ? "章已删除" : "小节已删除", "ok");
setDeleteTarget(null);
await onChanged();
if (target.id === selectedId) {
setSelectedId(null);
setTitle("");
setContent("");
setSnapshot(EMPTY_SNAPSHOT);
setError("");
}
} catch (e) {
toast(e instanceof Error ? e.message : "删除章节失败");
} finally {
setDeleteBusy(false);
}
}
async function handleImport(input: LibraryImportMdInput) {
const res = await apiAdminImportLibraryMarkdownBatch(doc.id, input);
await onChanged();
return res.sections;
}
function closeImport(created?: LibrarySectionItem[]) {
setImportOpen(false);
if (created && created.length > 0) setPendingSelectId(created[0].id);
}
/** 该章节在同级兄弟中的位置(供上移/下移禁用判断) */
function siblingPos(s: LibrarySectionItem): { first: boolean; last: boolean } {
if (s.parent_id == null) {
const i = groups.findIndex((g) => g.chapter.id === s.id);
return { first: i <= 0, last: i < 0 || i >= groups.length - 1 };
}
const g = groups.find((x) => x.subs.some((y) => y.id === s.id));
const subs = g?.subs ?? [];
const i = subs.findIndex((y) => y.id === s.id);
return { first: i <= 0, last: i < 0 || i >= subs.length - 1 };
}
function RowAction({
label,
onClick,
disabled,
children,
}: {
label: string;
onClick: () => void;
disabled?: boolean;
children: ReactNode;
}) {
return (
<button
type="button"
className="w-6 h-6 shrink-0 rounded-md flex items-center justify-center hover:bg-[var(--panel)] transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
style={{ color: "var(--ink-3)" }}
title={label}
aria-label={label}
disabled={disabled}
onClick={onClick}
>
{children}
</button>
);
}
function row(s: LibrarySectionItem, depth: 0 | 1, isFirst: boolean, isLast: boolean) {
const active = s.id === selectedId;
const pos = siblingPos(s);
return (
<div
key={s.id}
className="flex items-center gap-0.5 pr-1.5"
style={{
paddingLeft: depth === 0 ? 4 : 22,
background: active ? "var(--accent-soft)" : undefined,
boxShadow: active ? "inset 2px 0 0 var(--accent)" : undefined,
}}
>
<button
type="button"
onClick={() => selectSection(s)}
aria-current={active ? "true" : undefined}
className="min-w-0 flex-1 text-left px-2 py-2 flex items-center gap-1 rounded-md transition-colors hover:bg-[var(--panel-2)]"
title={s.title}
>
{depth === 1 ? (
<CornerDownRight
size={12}
className="shrink-0"
style={{ color: "var(--ink-3)" }}
aria-hidden
/>
) : null}
<span
className="truncate text-[13px]"
style={{
color: active ? "var(--accent)" : "var(--ink)",
fontWeight: depth === 0 ? 600 : 400,
}}
>
{s.title || "未命名章节"}
</span>
</button>
{active ? (
<>
<RowAction label="上移" disabled={pos.first || busy} onClick={() => void move(s, "up")}>
<ChevronUp size={13} aria-hidden />
</RowAction>
<RowAction label="下移" disabled={pos.last || busy} onClick={() => void move(s, "down")}>
<ChevronDown size={13} aria-hidden />
</RowAction>
{depth === 0 ? (
<RowAction
label="添加小节"
disabled={busy}
onClick={() => void addSection(s)}
>
<CornerDownRight size={13} aria-hidden />
</RowAction>
) : null}
<RowAction label="删除" onClick={() => setDeleteTarget(s)}>
<Trash2 size={13} aria-hidden />
</RowAction>
</>
) : null}
</div>
);
}
return (
<div className="flex h-full min-h-0">
{/* 左:章节树 */}
<aside
className="w-52 sm:w-60 shrink-0 flex flex-col min-h-0"
style={{ borderRight: "1px solid var(--line)" }}
aria-label="章节树"
>
<div
className="shrink-0 flex items-center gap-1.5 px-2.5 py-2.5 flex-wrap"
style={{ borderBottom: "1px solid var(--line)", background: "var(--panel-2)" }}
>
<button
type="button"
className="btn btn-line btn-sm"
disabled={busy}
onClick={() => guardNav(() => void addSection(null))}
>
<Plus size={13} aria-hidden /> 添加章
</button>
<button
type="button"
className="btn btn-line btn-sm"
disabled={busy}
onClick={() => guardNav(() => setImportOpen(true))}
>
<Upload size={13} aria-hidden /> 导入
</button>
</div>
<div className="flex-1 min-h-0 overflow-y-auto j13-rail-scroll py-1.5">
{groups.length === 0 ? (
<p className="meta text-[12.5px] px-3 py-3 leading-relaxed">
还没有章节。点上方「添加章」手动编写,或「导入」批量导入 .md / .epub(epub 按目录顺序拆章)。
</p>
) : (
groups.map((g, gi) => (
<div key={g.chapter.id}>
{row(g.chapter, 0, gi === 0, gi === groups.length - 1)}
{g.subs.map((sub, si) => row(sub, 1, si === 0, si === g.subs.length - 1))}
</div>
))
)}
</div>
<div
className="shrink-0 px-3 py-2 meta text-[11.5px] tabular-nums"
style={{ borderTop: "1px solid var(--line)" }}
>
{sections.length} 章节
</div>
</aside>
{/* 右:章节编辑器 */}
<div className="flex-1 min-w-0 flex flex-col min-h-0">
{selected ? (
<>
<div className="flex-1 min-h-0 overflow-y-auto j13-rail-scroll px-4 sm:px-5 py-4 flex flex-col gap-4">
<div className="admin-doc-field">
<label className="admin-doc-label" htmlFor="lib-section-title">
章节标题
</label>
<input
id="lib-section-title"
className="admin-field-input"
maxLength={MAX_TITLE}
value={title}
onChange={(e) => {
setError("");
setTitle(e.target.value);
}}
placeholder="例如:第一章 …"
disabled={busy}
/>
</div>
{/* flex-1 撑满滚动区剩余高度:编辑框最大化、提示行紧贴底部操作条上方 */}
<div className="admin-doc-md flex flex-col flex-1 min-h-0">
<label className="admin-doc-label" htmlFor="lib-section-content">
正文(Markdown)
</label>
<MarkdownEditor
id="lib-section-content"
value={content}
onChange={(v) => {
setError("");
setContent(v.slice(0, MAX_CONTENT));
}}
maxChars={MAX_CONTENT}
allowHide={false}
entriesAuto={doc.entries_auto ?? false}
placeholder="本章正文,支持 Markdown…"
rows={16}
ariaLabel="章节正文"
/>
</div>
</div>
{/* 底部操作条:不随正文滚动 */}
<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)" }}
>
<div className="mr-auto flex items-center gap-3 min-w-0">
{error ? (
<p className="admin-settings-row-error" role="alert">
{error}
</p>
) : dirty ? (
<span className="meta text-[12px]">有未保存的修改</span>
) : null}
</div>
<button
type="button"
className="btn btn-line btn-sm"
disabled={busy || deleteBusy}
onClick={() => setDeleteTarget(selected)}
>
<Trash2 size={13} aria-hidden /> 删除章节
</button>
<button
type="button"
className="btn btn-primary btn-sm"
disabled={busy || !dirty}
title={dirty ? undefined : "没有需要保存的修改"}
onClick={() => void save()}
aria-busy={busy || undefined}
>
{busy ? (
<Loader2 size={13} className="animate-spin" aria-hidden />
) : (
<Save size={13} aria-hidden />
)}
{busy ? "保存中…" : "保存章节"}
</button>
</footer>
</>
) : (
<div className="flex-1 min-h-0 flex items-center justify-center p-6">
<AdminEmpty
icon={<ListTree size={22} />}
title="未选择章节"
description="从左侧选择章节编辑,或添加章 / 导入 Markdown"
/>
</div>
)}
</div>
{/* 未保存切换章节确认 */}
<ConfirmDialog
open={!!pendingNav}
title="放弃未保存的修改?"
message="当前章节有未保存的修改,继续将丢失这些改动。"
danger
confirmLabel="放弃修改"
cancelLabel="继续编辑"
onCancel={() => setPendingNav(null)}
onConfirm={() => {
const action = pendingNav;
setPendingNav(null);
action?.();
}}
/>
{/* 删除章节确认 */}
<ConfirmDialog
open={!!deleteTarget}
title={deleteTarget?.parent_id == null ? "删除章" : "删除小节"}
message={
deleteTarget
? `确定删除「${deleteTarget.title}」吗?${
deleteTarget.parent_id == null ? "其下所有小节将一并删除," : ""
}且不可恢复。`
: ""
}
danger
busy={deleteBusy}
confirmLabel="删除"
onCancel={() => !deleteBusy && setDeleteTarget(null)}
onConfirm={() => void removeSection()}
/>
{/* 导入 Markdown 拆章 */}
{importOpen ? <ImportMdDialog onImport={handleImport} onClose={closeImport} /> : null}
</div>
);
}

View File

@@ -0,0 +1,36 @@
import type { Metadata } from "next";
import { Suspense } from "react";
import { cookies } from "next/headers";
import LibraryAdmin from "./LibraryAdmin";
import { authCookieHeader } from "@/lib/cookies";
import { fetchAdminLibraryDocs, type LibraryDoc } from "@/lib/api";
import { getMeCached } from "@/lib/serverData";
import { isAdminOrAbove } from "@/lib/roles";
import Forbidden from "../Forbidden";
export const metadata: Metadata = {
title: "书库管理",
};
export default async function AdminLibraryPage() {
const cookie = authCookieHeader(await cookies());
const me = await getMeCached(cookie || undefined);
if (!me.user || !isAdminOrAbove(me.user.role)) {
return <Forbidden text="书库管理仅管理员及以上角色可操作。" />;
}
let initial: LibraryDoc[] = [];
let initError = "";
try {
const res = await fetchAdminLibraryDocs(cookie || undefined);
initial = res.docs ?? [];
} catch (e) {
initError = e instanceof Error ? e.message : "加载书库失败";
}
return (
<Suspense fallback={<p className="meta px-1 py-6">加载书库列表…</p>}>
<LibraryAdmin initial={initial} initError={initError} />
</Suspense>
);
}

View File

@@ -0,0 +1,91 @@
/**
* epub 导入解析(浏览器端):epub 本质是 zip + XHTML。
* 流程:JSZip 解压 → META-INF/container.xml 找 OPF → 按 spine 顺序读 XHTML
* → 清理(去 script/style、剔除内部相对路径图片)→ turndown 转 Markdown。
* 产物直接作为伪 .md 文件走现有批量导入接口,后端零改动。
* 两个依赖均在本模块内动态 import,避免打进主包。
*/
export interface EpubChapter {
title: string;
markdown: string;
}
/** 以 OPF 所在目录为基准解析 zip 内相对路径(处理 ./ ../ 与 URL 编码) */
function resolveZipPath(base: string, href: string): string {
const parts = `${base}${href}`.split("/");
const out: string[] = [];
for (const p of parts) {
if (!p || p === ".") continue;
if (p === "..") out.pop();
else out.push(p);
}
return out.join("/");
}
/** 清理标题里不适合做文件名的字符 */
function sanitizeTitle(t: string): string {
return t.replace(/[\\/:*?"<>|\n\r\t]+/g, " ").trim().slice(0, 60);
}
export async function parseEpub(file: File): Promise<EpubChapter[]> {
const { default: JSZip } = await import("jszip");
const { default: TurndownService } = await import("turndown");
const zip = await JSZip.loadAsync(await file.arrayBuffer()).catch(() => {
throw new Error("无法读取 epub:不是有效的 zip 文件");
});
// container.xml → OPF 路径
const containerEntry = zip.file("META-INF/container.xml");
if (!containerEntry) throw new Error("无法读取 epub:缺少 META-INF/container.xml");
const containerDoc = new DOMParser().parseFromString(await containerEntry.async("text"), "application/xml");
const rootfilePath = containerDoc.querySelector("rootfile")?.getAttribute("full-path");
if (!rootfilePath) throw new Error("无法读取 epub:container.xml 未声明 OPF 文件");
// OPF → manifest(id → href/mediaType)+ spine(阅读顺序)
const opfEntry = zip.file(rootfilePath) ?? zip.file(decodeURIComponent(rootfilePath));
if (!opfEntry) throw new Error(`无法读取 epub:缺少 OPF 文件 ${rootfilePath}`);
const opfDoc = new DOMParser().parseFromString(await opfEntry.async("text"), "application/xml");
const opfDir = rootfilePath.includes("/") ? rootfilePath.slice(0, rootfilePath.lastIndexOf("/") + 1) : "";
const manifest = new Map<string, { href: string; mediaType: string }>();
opfDoc.querySelectorAll("manifest > item").forEach((item) => {
const id = item.getAttribute("id");
const href = item.getAttribute("href");
if (id && href) manifest.set(id, { href, mediaType: item.getAttribute("media-type") ?? "" });
});
const spineIds = Array.from(opfDoc.querySelectorAll("spine > itemref"))
.map((el) => el.getAttribute("idref"))
.filter((id): id is string => !!id);
const td = new TurndownService({ headingStyle: "atx", codeBlockStyle: "fenced" });
const chapters: EpubChapter[] = [];
for (const id of spineIds) {
const item = manifest.get(id);
// spine 里也会有图片/样式等资源项,只处理 HTML 文档
if (!item || !/xhtml|html/i.test(item.mediaType)) continue;
const path = resolveZipPath(opfDir, item.href);
const entry = zip.file(path) ?? zip.file(decodeURIComponent(path));
if (!entry) continue;
const doc = new DOMParser().parseFromString(await entry.async("text"), "text/html");
doc.querySelectorAll("script, style").forEach((el) => el.remove());
// 内部相对路径图片导入后无法解析,剔除;外链 http(s) 图片保留
doc.querySelectorAll("img").forEach((img) => {
if (!/^https?:\/\//i.test(img.getAttribute("src") ?? "")) img.remove();
});
const body = doc.body;
if (!body) continue;
const markdown = td
.turndown(body.innerHTML)
.replace(/\n{3,}/g, "\n\n")
.trim();
if (!markdown) continue; // 封面页等纯图片页
const heading = Array.from(body.querySelectorAll("h1, h2, h3, h4, h5, h6")).find((h) => h.textContent?.trim());
const title = sanitizeTitle(heading?.textContent ?? doc.title ?? "") || `第 ${chapters.length + 1} 章`;
chapters.push({ title, markdown });
}
if (chapters.length === 0) throw new Error("未能从 epub 中解析出任何章节");
return chapters;
}