feat: 友链/书库/移动端导航/排行榜等多模块功能
新增模块: - 友链(friendlink)后端处理 + 前端管理页与友链板 - 书库(library):导入、章节、封面、阅读页、横竖版自适应 AdaptiveCoverSlot - 顶栏导航(header_nav)配置与 MobileTabBar/MobileRailDrawers 移动端抽屉 - 排行榜 service 测试、站点页面测试、时间线发布(timeline_release) 其它改动: - 后端 handlers/services 全量小幅调整 - 前端组件、库函数、URL/品牌/站点 URL 工具更新 - Lightbox 图片、MdEntries 条目卡、coverColor 派生色相等前端能力
This commit is contained in:
431
frontend/app/admin/library/ImportMdDialog.tsx
Normal file
431
frontend/app/admin/library/ImportMdDialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user