"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; /** 关闭弹窗;导入成功时带出新建章节,父组件据此选中第一章 */ onClose: (created?: LibrarySectionItem[]) => void; }) { const fileInputRef = useRef(null); const [files, setFiles] = useState([]); const [pasted, setPasted] = useState(""); const [splitLevel, setSplitLevel] = useState("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( () => 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 ( } >

来源 {sourceFiles.length > 1 ? `(${sourceFiles.length} 个文件,按文件名顺序导入)` : ""}

{files.length > 0 ? ( ) : null}
void pickFiles(e.target.files)} />
{files.length > 0 ? (
    {files.map((f, i) => (
  • {f.name} {f.text.length} 字符
  • ))}
) : (