新增模块: - 友链(friendlink)后端处理 + 前端管理页与友链板 - 书库(library):导入、章节、封面、阅读页、横竖版自适应 AdaptiveCoverSlot - 顶栏导航(header_nav)配置与 MobileTabBar/MobileRailDrawers 移动端抽屉 - 排行榜 service 测试、站点页面测试、时间线发布(timeline_release) 其它改动: - 后端 handlers/services 全量小幅调整 - 前端组件、库函数、URL/品牌/站点 URL 工具更新 - Lightbox 图片、MdEntries 条目卡、coverColor 派生色相等前端能力
432 lines
16 KiB
TypeScript
432 lines
16 KiB
TypeScript
"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>
|
||
);
|
||
}
|