Files
jiang13-bbs/frontend/app/admin/library/ImportMdDialog.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

432 lines
16 KiB
TypeScript
Raw Permalink 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 { 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>
);
}