Преглед изворни кода

feat: 增加拆书库批量导入与断点继续

Mochocyang пре 3 месеци
родитељ
комит
e2f3bc62b0
23 измењених фајлова са 6709 додато и 225 уклоњено
  1. 360 0
      src/components/novel/book-analysis-import-task-panel.spec.tsx
  2. 291 0
      src/components/novel/book-analysis-import-task-panel.tsx
  3. 378 0
      src/components/novel/book-analysis-input-dialog.spec.tsx
  4. 222 75
      src/components/novel/book-analysis-input-dialog.tsx
  5. 9 0
      src/components/novel/book-analysis-library-layout.spec.tsx
  6. 4 0
      src/components/novel/book-analysis-library-layout.tsx
  7. 514 14
      src/components/novel/book-analysis-view.spec.ts
  8. 122 83
      src/components/novel/book-analysis-view.tsx
  9. 54 36
      src/lib/novel/book-analysis/analysis-engine.ts
  10. 637 0
      src/lib/novel/book-analysis/batch-import-engine.spec.ts
  11. 288 0
      src/lib/novel/book-analysis/batch-import-engine.ts
  12. 33 0
      src/lib/novel/book-analysis/batch-import-hash.spec.ts
  13. 27 0
      src/lib/novel/book-analysis/batch-import-hash.ts
  14. 645 0
      src/lib/novel/book-analysis/batch-import-scheduler.spec.ts
  15. 351 0
      src/lib/novel/book-analysis/batch-import-scheduler.ts
  16. 845 0
      src/lib/novel/book-analysis/batch-import-storage.spec.ts
  17. 417 0
      src/lib/novel/book-analysis/batch-import-storage.ts
  18. 56 0
      src/lib/novel/book-analysis/batch-import-types.ts
  19. 259 3
      src/lib/novel/book-analysis/library-store.spec.ts
  20. 93 13
      src/lib/novel/book-analysis/library-store.ts
  21. 2 1
      src/lib/novel/book-analysis/types.ts
  22. 647 0
      src/stores/book-analysis-import-store.spec.ts
  23. 455 0
      src/stores/book-analysis-import-store.ts

+ 360 - 0
src/components/novel/book-analysis-import-task-panel.spec.tsx

@@ -0,0 +1,360 @@
+// @vitest-environment jsdom
+
+import { act } from "react"
+import { createRoot, type Root } from "react-dom/client"
+import { renderToStaticMarkup } from "react-dom/server"
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
+import type { BatchImportBatch, BatchImportTask, BatchImportTaskStatus } from "@/lib/novel/book-analysis/batch-import-types"
+import { BookAnalysisImportTaskPanel } from "./book-analysis-import-task-panel"
+
+;(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true
+
+const batch: BatchImportBatch = {
+  version: 1,
+  id: "batch-1",
+  projectPath: "E:/Novel",
+  taskIds: [],
+  createdAt: 1,
+  updatedAt: 1,
+}
+
+function createTask(status: BatchImportTaskStatus, id = `task-${status}`): BatchImportTask {
+  return {
+    version: 1,
+    id,
+    batchId: batch.id,
+    projectPath: "E:/Novel",
+    originalPath: `E:/Sources/${id}.txt`,
+    originalFileName: `${id}.txt`,
+    cachedSourcePath: `E:/Cache/${id}.txt`,
+    sourceSha256: "hash",
+    requestedTitle: `作品-${id}`,
+    finalTitle: status === "completed" ? `完成-${id}` : null,
+    bookId: `book-${id}`,
+    status,
+    completed: status === "splitting" ? 3 : 0,
+    total: status === "splitting" ? 10 : 0,
+    error: status === "failed" ? "导入失败" : null,
+    skipReason: status === "skipped" ? "作品已存在" : null,
+    createdAt: 1,
+    startedAt: status === "queued" ? null : 2,
+    completedAt: ["completed", "failed", "cancelled", "skipped"].includes(status) ? 3 : null,
+    updatedAt: 3,
+  }
+}
+
+const callbacks = () => ({
+  onCollapsedChange: vi.fn(),
+  onContinue: vi.fn(),
+  onRegenerate: vi.fn(),
+  onCancel: vi.fn(),
+  onCancelAllQueued: vi.fn(),
+  onOpenBook: vi.fn(),
+})
+
+let host: HTMLDivElement
+let root: Root
+
+beforeEach(() => {
+  host = document.createElement("div")
+  document.body.appendChild(host)
+  root = createRoot(host)
+})
+
+afterEach(() => {
+  act(() => root.unmount())
+  host.remove()
+  vi.restoreAllMocks()
+})
+
+function renderPanel(tasks: BatchImportTask[], props = callbacks(), collapsed = false) {
+  act(() => {
+    root.render(
+      <BookAnalysisImportTaskPanel
+        batches={[{ ...batch, taskIds: tasks.map((task) => task.id) }]}
+        tasks={tasks}
+        collapsed={collapsed}
+        {...props}
+      />,
+    )
+  })
+  return props
+}
+
+function clickButton(label: string) {
+  const button = Array.from(host.querySelectorAll("button")).find((item) => item.textContent?.trim() === label)
+  expect(button, `找不到按钮:${label}`).toBeTruthy()
+  act(() => button?.click())
+}
+
+describe("BookAnalysisImportTaskPanel", () => {
+  it("没有任务时不渲染", () => {
+    const html = renderToStaticMarkup(
+      <BookAnalysisImportTaskPanel
+        batches={[]}
+        tasks={[]}
+        collapsed={false}
+        {...callbacks()}
+      />,
+    )
+
+    expect(html).toBe("")
+  })
+
+  it("统计运行、等待和失败任务,并显示各状态允许的按钮", () => {
+    const tasks = [
+      createTask("queued"),
+      createTask("copying"),
+      createTask("splitting"),
+      createTask("interrupted"),
+      createTask("failed"),
+      createTask("cancelled"),
+      createTask("skipped"),
+      createTask("completed"),
+    ]
+    const html = renderToStaticMarkup(
+      <BookAnalysisImportTaskPanel
+        batches={[{ ...batch, taskIds: tasks.map((task) => task.id) }]}
+        tasks={tasks}
+        collapsed={false}
+        {...callbacks()}
+      />,
+    )
+
+    expect(html).toContain("批量导入任务")
+    expect(html).toContain("进行中 2")
+    expect(html).toContain("等待 1")
+    expect(html).toContain("失败 1")
+    expect(html).toContain("中断 1")
+    expect(html).toContain("完成 1")
+    expect(html).toContain("跳过 1")
+    expect(html).toContain("取消 1")
+    const document = new DOMParser().parseFromString(html, "text/html")
+    const panel = document.querySelector('section[aria-label="批量导入任务"]')
+    expect(panel?.classList.contains("min-h-0")).toBe(true)
+    expect(panel?.classList.contains("max-h-[45%]")).toBe(true)
+    expect(panel?.classList.contains("overflow-hidden")).toBe(true)
+    expect(panel?.classList.contains("shrink-0")).toBe(false)
+    const statusSummary = panel?.querySelector('[aria-live="polite"]')
+    expect(statusSummary).not.toBeNull()
+    const taskList = document.querySelector("#book-analysis-import-task-list")
+    expect(taskList?.classList.contains("min-h-0")).toBe(true)
+    expect(taskList?.classList.contains("flex-1")).toBe(true)
+    expect(taskList?.classList.contains("overflow-y-auto")).toBe(true)
+    expect((html.match(/>取消</g) ?? []).length).toBe(3)
+    expect((html.match(/>继续</g) ?? []).length).toBe(2)
+    expect((html.match(/>重新生成</g) ?? []).length).toBe(3)
+    expect((html.match(/>打开作品</g) ?? []).length).toBe(1)
+    expect(html).not.toContain("作品-task-skipped</span><button")
+  })
+
+  it("按任务 batchId 与批次 taskIds 双重校验并去重渲染", () => {
+    const validTask = createTask("queued", "valid-task")
+    const duplicateTask = { ...validTask, requestedTitle: "重复记录不应覆盖 Map 最终值" }
+    const mismatchedTask = { ...createTask("queued", "mismatched-task"), batchId: "batch-other" }
+    const html = renderToStaticMarkup(
+      <BookAnalysisImportTaskPanel
+        batches={[{ ...batch, taskIds: [validTask.id, validTask.id, mismatchedTask.id] }]}
+        tasks={[validTask, duplicateTask, mismatchedTask]}
+        collapsed={false}
+        {...callbacks()}
+      />,
+    )
+
+    const document = new DOMParser().parseFromString(html, "text/html")
+    const renderedTitles = Array.from(document.querySelectorAll("article .font-medium")).map(
+      (element) => element.textContent,
+    )
+    expect(renderedTitles.filter((title) => title === "重复记录不应覆盖 Map 最终值")).toHaveLength(1)
+    expect(renderedTitles.filter((title) => title === "作品-mismatched-task")).toHaveLength(1)
+    expect((html.match(/取消全部等待任务/g) ?? []).length).toBe(1)
+  })
+
+  it("未归组的等待任务不显示批量取消", () => {
+    const orphanTask = { ...createTask("queued", "orphan-task"), batchId: "missing-batch" }
+    const html = renderToStaticMarkup(
+      <BookAnalysisImportTaskPanel
+        batches={[]}
+        tasks={[orphanTask]}
+        collapsed={false}
+        {...callbacks()}
+      />,
+    )
+
+    expect(html).toContain("作品-orphan-task")
+    expect(html).not.toContain("取消全部等待任务")
+  })
+
+  it("批次引用缺失任务时显示去重后的缺失数量且不显示完成汇总", () => {
+    const completedTask = createTask("completed", "completed-present")
+    const html = renderToStaticMarkup(
+      <BookAnalysisImportTaskPanel
+        batches={[{ ...batch, taskIds: [completedTask.id, "missing-1", "missing-1", "missing-2"] }]}
+        tasks={[completedTask]}
+        collapsed={false}
+        {...callbacks()}
+      />,
+    )
+
+    expect(html).toContain("批次记录缺少 2 个任务")
+    expect(html).not.toContain("批次完成")
+  })
+  it("批次结束后汇总成功、跳过、失败和取消数量", () => {
+    const tasks = [
+      createTask("completed", "completed-1"),
+      createTask("completed", "completed-2"),
+      createTask("skipped", "skipped-1"),
+      createTask("failed", "failed-1"),
+      createTask("cancelled", "cancelled-1"),
+    ]
+    const html = renderToStaticMarkup(
+      <BookAnalysisImportTaskPanel
+        batches={[{ ...batch, taskIds: tasks.map((task) => task.id) }]}
+        tasks={tasks}
+        collapsed={false}
+        {...callbacks()}
+      />,
+    )
+
+    expect(html).toContain("批次完成")
+    expect(html).toContain("成功 2")
+    expect(html).toContain("跳过 1")
+    expect(html).toContain("失败 1")
+    expect(html).toContain("取消 1")
+  })
+
+  it("批次存在中断任务时不显示完成汇总", () => {
+    const interruptedTask = createTask("interrupted")
+    const html = renderToStaticMarkup(
+      <BookAnalysisImportTaskPanel
+        batches={[{ ...batch, taskIds: [interruptedTask.id] }]}
+        tasks={[interruptedTask]}
+        collapsed={false}
+        {...callbacks()}
+      />,
+    )
+
+    expect(html).not.toContain("批次完成")
+  })
+  it("截断错误显示并通过 title 保留完整错误", () => {
+    const longError = "读取源文件失败:" + "路径或文件内容异常".repeat(20)
+    const failedTask = { ...createTask("failed"), error: longError }
+    const html = renderToStaticMarkup(
+      <BookAnalysisImportTaskPanel
+        batches={[{ ...batch, taskIds: [failedTask.id] }]}
+        tasks={[failedTask]}
+        collapsed={false}
+        {...callbacks()}
+      />,
+    )
+
+    const document = new DOMParser().parseFromString(html, "text/html")
+    const errorElement = document.querySelector(`[title="${longError}"]`)
+    expect(errorElement).not.toBeNull()
+    expect(errorElement?.getAttribute("title")).toBe(longError)
+    expect(errorElement?.classList.contains("truncate")).toBe(true)
+    const taskElement = errorElement?.closest("article")
+    const descriptionId = taskElement?.getAttribute("aria-describedby")
+    expect(descriptionId).toBeTruthy()
+    const fullErrorElement = descriptionId ? document.getElementById(descriptionId) : null
+    expect(fullErrorElement?.classList.contains("sr-only")).toBe(true)
+    expect(fullErrorElement?.textContent).toBe(longError)
+  })
+
+  it.each([
+    ["queued", "取消", "onCancel"],
+    ["copying", "取消", "onCancel"],
+    ["splitting", "取消", "onCancel"],
+    ["interrupted", "继续", "onContinue"],
+    ["interrupted", "重新生成", "onRegenerate"],
+    ["failed", "继续", "onContinue"],
+    ["failed", "重新生成", "onRegenerate"],
+    ["cancelled", "重新生成", "onRegenerate"],
+  ] as const)("%s 状态点击%s调用%s", (status, label, callbackName) => {
+    const task = createTask(status)
+    const props = renderPanel([task])
+    const button = Array.from(host.querySelectorAll("button")).find((item) => item.textContent?.trim() === label)
+    expect(button?.getAttribute("aria-label")).toContain(task.finalTitle || task.requestedTitle)
+
+    clickButton(label)
+
+    expect(props[callbackName]).toHaveBeenCalledWith(`task-${status}`)
+  })
+
+  it("拆分进度提供 progressbar 语义和值", () => {
+    const task = createTask("splitting")
+    const html = renderToStaticMarkup(
+      <BookAnalysisImportTaskPanel
+        batches={[{ ...batch, taskIds: [task.id] }]}
+        tasks={[task]}
+        collapsed={false}
+        {...callbacks()}
+      />,
+    )
+    const document = new DOMParser().parseFromString(html, "text/html")
+    const progress = document.querySelector('[role="progressbar"]')
+
+    expect(progress?.getAttribute("aria-valuemin")).toBe("0")
+    expect(progress?.getAttribute("aria-valuemax")).toBe("10")
+    expect(progress?.getAttribute("aria-valuenow")).toBe("3")
+    expect(progress?.getAttribute("aria-label")).toContain(task.requestedTitle)
+  })
+  it("completed 状态打开作品,skipped 状态没有操作按钮", () => {
+    const completedTask = createTask("completed")
+    const props = renderPanel([completedTask])
+    const openButton = Array.from(host.querySelectorAll("button")).find(
+      (button) => button.textContent?.trim() === "打开作品",
+    )
+    expect(openButton?.getAttribute("aria-label")).toContain(completedTask.finalTitle)
+    clickButton("打开作品")
+    expect(props.onOpenBook).toHaveBeenCalledWith("book-task-completed")
+
+    renderPanel([createTask("skipped")], props)
+    expect(Array.from(host.querySelectorAll("button")).map((button) => button.textContent?.trim())).toEqual([
+      "收起导入任务",
+    ])
+  })
+
+  it("折叠状态完全受控,任务进度更新不会自行展开", () => {
+    const props = renderPanel([createTask("splitting")])
+    expect(host.textContent).toContain("作品-task-splitting")
+    const collapseButton = Array.from(host.querySelectorAll("button")).find(
+      (button) => button.textContent?.trim() === "收起导入任务",
+    )
+    expect(collapseButton?.getAttribute("aria-controls")).toBe("book-analysis-import-task-list")
+    expect(host.querySelector("#book-analysis-import-task-list")).not.toBeNull()
+
+    clickButton("收起导入任务")
+    expect(props.onCollapsedChange).toHaveBeenCalledWith(true)
+
+    renderPanel([{ ...createTask("splitting"), completed: 8 }], props, true)
+    expect(host.textContent).not.toContain("作品-task-splitting")
+    expect(props.onCollapsedChange).toHaveBeenCalledTimes(1)
+
+    clickButton("展开导入任务")
+    expect(props.onCollapsedChange).toHaveBeenLastCalledWith(false)
+  })
+
+  it("确认后取消当前批次的全部等待任务", () => {
+    const confirm = vi.spyOn(window, "confirm").mockReturnValue(true)
+    const props = renderPanel([createTask("queued")])
+
+    const cancelAllButton = Array.from(host.querySelectorAll("button")).find(
+      (button) => button.textContent?.trim() === "取消全部等待任务",
+    )
+    expect(cancelAllButton?.getAttribute("aria-label")).toContain("作品-task-queued")
+    clickButton("取消全部等待任务")
+
+    expect(confirm).toHaveBeenCalledWith("确定取消当前批次的全部等待任务吗?")
+    expect(props.onCancelAllQueued).toHaveBeenCalledWith("batch-1")
+  })
+
+  it("拒绝确认时不取消全部等待任务", () => {
+    vi.spyOn(window, "confirm").mockReturnValue(false)
+    const props = renderPanel([createTask("queued")])
+
+    clickButton("取消全部等待任务")
+
+    expect(props.onCancelAllQueued).not.toHaveBeenCalled()
+  })
+})

+ 291 - 0
src/components/novel/book-analysis-import-task-panel.tsx

@@ -0,0 +1,291 @@
+import { ChevronDown, ChevronRight } from "lucide-react"
+import { Button } from "@/components/ui/button"
+import type {
+  BatchImportBatch,
+  BatchImportTask,
+  BatchImportTaskStatus,
+} from "@/lib/novel/book-analysis/batch-import-types"
+
+interface BookAnalysisImportTaskPanelProps {
+  batches: BatchImportBatch[]
+  tasks: BatchImportTask[]
+  collapsed: boolean
+  onCollapsedChange: (value: boolean) => void
+  onContinue: (taskId: string) => void
+  onRegenerate: (taskId: string) => void
+  onCancel: (taskId: string) => void
+  onCancelAllQueued: (batchId: string) => void
+  onOpenBook: (bookId: string) => void
+}
+
+interface TaskGroup {
+  batchId?: string
+  tasks: BatchImportTask[]
+  missingCount: number
+}
+
+const taskListId = "book-analysis-import-task-list"
+
+const statusLabels: Record<BatchImportTaskStatus, string> = {
+  queued: "等待中",
+  copying: "复制中",
+  splitting: "拆分中",
+  interrupted: "已中断",
+  failed: "失败",
+  cancelled: "已取消",
+  skipped: "已跳过",
+  completed: "已完成",
+}
+
+const terminalStatuses = new Set<BatchImportTaskStatus>(["failed", "cancelled", "skipped", "completed"])
+
+function getTaskTitle(task: BatchImportTask) {
+  return task.finalTitle || task.requestedTitle
+}
+
+function getErrorDescriptionId(taskId: string) {
+  return `book-analysis-import-task-error-${taskId.replace(/[^a-zA-Z0-9_-]/g, "-")}`
+}
+
+function TaskActions({
+  task,
+  onContinue,
+  onRegenerate,
+  onCancel,
+  onOpenBook,
+}: Pick<BookAnalysisImportTaskPanelProps, "onContinue" | "onRegenerate" | "onCancel" | "onOpenBook"> & {
+  task: BatchImportTask
+}) {
+  const title = getTaskTitle(task)
+
+  if (["queued", "copying", "splitting"].includes(task.status)) {
+    return (
+      <Button size="xs" variant="outline" aria-label={`取消作品《${title}》`} onClick={() => onCancel(task.id)}>
+        取消
+      </Button>
+    )
+  }
+
+  if (task.status === "interrupted" || task.status === "failed") {
+    return (
+      <div className="flex shrink-0 items-center gap-1">
+        <Button size="xs" variant="outline" aria-label={`继续作品《${title}》`} onClick={() => onContinue(task.id)}>
+          继续
+        </Button>
+        <Button
+          size="xs"
+          variant="outline"
+          aria-label={`重新生成作品《${title}》`}
+          onClick={() => onRegenerate(task.id)}
+        >
+          重新生成
+        </Button>
+      </div>
+    )
+  }
+
+  if (task.status === "cancelled") {
+    return (
+      <Button
+        size="xs"
+        variant="outline"
+        aria-label={`重新生成作品《${title}》`}
+        onClick={() => onRegenerate(task.id)}
+      >
+        重新生成
+      </Button>
+    )
+  }
+
+  if (task.status === "completed") {
+    return (
+      <Button size="xs" variant="outline" aria-label={`打开作品《${title}》`} onClick={() => onOpenBook(task.bookId)}>
+        打开作品
+      </Button>
+    )
+  }
+
+  return null
+}
+
+function BatchSummary({ tasks, missingCount }: { tasks: BatchImportTask[]; missingCount: number }) {
+  if (missingCount > 0 || tasks.length === 0 || tasks.some((task) => !terminalStatuses.has(task.status))) return null
+
+  const count = (status: BatchImportTaskStatus) => tasks.filter((task) => task.status === status).length
+  return (
+    <p className="rounded-md bg-muted px-3 py-2 text-xs text-muted-foreground">
+      批次完成 · 成功 {count("completed")} · 跳过 {count("skipped")} · 失败 {count("failed")} · 取消 {count("cancelled")}
+    </p>
+  )
+}
+
+function buildTaskGroups(batches: BatchImportBatch[], tasks: BatchImportTask[]) {
+  const taskMap = new Map(tasks.map((task) => [task.id, task]))
+  const batchMap = new Map(batches.map((batch) => [batch.id, batch]))
+  const groupedTaskIds = new Set<string>()
+  const groups: TaskGroup[] = []
+
+  for (const batch of batchMap.values()) {
+    const taskIds = [...new Set(batch.taskIds)]
+    const groupTasks: BatchImportTask[] = []
+    let missingCount = 0
+
+    for (const taskId of taskIds) {
+      const task = taskMap.get(taskId)
+      if (!task || task.batchId !== batch.id) {
+        missingCount += 1
+        continue
+      }
+      if (!groupedTaskIds.has(task.id)) {
+        groupedTaskIds.add(task.id)
+        groupTasks.push(task)
+      }
+    }
+
+    if (groupTasks.length > 0 || missingCount > 0) {
+      groups.push({ batchId: batch.id, tasks: groupTasks, missingCount })
+    }
+  }
+
+  const ungroupedTasks = [...taskMap.values()].filter((task) => !groupedTaskIds.has(task.id))
+  if (ungroupedTasks.length > 0) {
+    groups.push({ tasks: ungroupedTasks, missingCount: 0 })
+  }
+
+  return { groups, uniqueTasks: [...taskMap.values()] }
+}
+
+export function BookAnalysisImportTaskPanel({
+  batches,
+  tasks,
+  collapsed,
+  onCollapsedChange,
+  onContinue,
+  onRegenerate,
+  onCancel,
+  onCancelAllQueued,
+  onOpenBook,
+}: BookAnalysisImportTaskPanelProps) {
+  if (tasks.length === 0) return null
+
+  const { groups, uniqueTasks } = buildTaskGroups(batches, tasks)
+  const count = (statuses: BatchImportTaskStatus[]) => uniqueTasks.filter((task) => statuses.includes(task.status)).length
+
+  return (
+    <section
+      className="flex min-h-0 max-h-[45%] flex-col overflow-hidden border-b bg-background px-5 py-3"
+      aria-label="批量导入任务"
+    >
+      <div className="flex shrink-0 items-center justify-between gap-3">
+        <div className="min-w-0">
+          <h3 className="text-sm font-semibold">批量导入任务</h3>
+          <p className="mt-1 flex flex-wrap gap-x-3 gap-y-1 text-xs text-muted-foreground" aria-live="polite">
+            <span>进行中 {count(["copying", "splitting"])}</span>
+            <span>等待 {count(["queued"])}</span>
+            <span>失败 {count(["failed"])}</span>
+            <span>中断 {count(["interrupted"])}</span>
+            <span>完成 {count(["completed"])}</span>
+            <span>跳过 {count(["skipped"])}</span>
+            <span>取消 {count(["cancelled"])}</span>
+          </p>
+        </div>
+        <Button
+          size="sm"
+          variant="ghost"
+          aria-controls={taskListId}
+          aria-expanded={!collapsed}
+          onClick={() => onCollapsedChange(!collapsed)}
+        >
+          {collapsed ? <ChevronRight /> : <ChevronDown />}
+          {collapsed ? "展开导入任务" : "收起导入任务"}
+        </Button>
+      </div>
+
+      {!collapsed && (
+        <div id={taskListId} className="mt-3 min-h-0 flex-1 space-y-3 overflow-y-auto pr-1">
+          {groups.map((group, groupIndex) => {
+            const batchId = group.batchId
+            const queuedTasks = group.tasks.filter((task) => task.status === "queued")
+            const queuedCount = queuedTasks.length
+            return (
+              <div key={batchId ?? `ungrouped-${groupIndex}`} className="space-y-2">
+                {batchId && queuedCount > 0 && (
+                  <div className="flex justify-end">
+                    <Button
+                      size="xs"
+                      variant="outline"
+                      aria-label={`取消全部等待作品:${queuedTasks.map(getTaskTitle).join("、")}`}
+                      onClick={() => {
+                        if (window.confirm("确定取消当前批次的全部等待任务吗?")) {
+                          onCancelAllQueued(batchId)
+                        }
+                      }}
+                    >
+                      取消全部等待任务
+                    </Button>
+                  </div>
+                )}
+                {group.missingCount > 0 && (
+                  <p className="rounded-md bg-destructive/10 px-3 py-2 text-xs text-destructive">
+                    批次记录缺少 {group.missingCount} 个任务
+                  </p>
+                )}
+                {group.tasks.map((task) => {
+                  const title = getTaskTitle(task)
+                  const errorDescriptionId = task.error ? getErrorDescriptionId(task.id) : undefined
+                  return (
+                    <article
+                      key={task.id}
+                      className="flex items-center gap-3 rounded-md border px-3 py-2"
+                      aria-describedby={errorDescriptionId}
+                    >
+                      <div className="min-w-0 flex-1">
+                        <div className="flex min-w-0 items-center gap-2">
+                          <span className="truncate text-sm font-medium">{title}</span>
+                          <span className="shrink-0 text-xs text-muted-foreground">{statusLabels[task.status]}</span>
+                          {(task.status === "copying" || task.status === "splitting") && task.total > 0 && (
+                            <span
+                              className="shrink-0 text-xs text-muted-foreground"
+                              role="progressbar"
+                              aria-label={`${title}导入进度`}
+                              aria-valuemin={0}
+                              aria-valuemax={task.total}
+                              aria-valuenow={task.completed}
+                            >
+                              {task.completed}/{task.total}
+                            </span>
+                          )}
+                        </div>
+                        {task.error && (
+                          <>
+                            <p className="mt-1 truncate text-xs text-destructive" title={task.error} aria-hidden="true">
+                              {task.error}
+                            </p>
+                            <span id={errorDescriptionId} className="sr-only">{task.error}</span>
+                          </>
+                        )}
+                        {!task.error && task.skipReason && (
+                          <p className="mt-1 truncate text-xs text-muted-foreground" title={task.skipReason}>{task.skipReason}</p>
+                        )}
+                      </div>
+                      <TaskActions
+                        task={task}
+                        onContinue={onContinue}
+                        onRegenerate={onRegenerate}
+                        onCancel={onCancel}
+                        onOpenBook={onOpenBook}
+                      />
+                    </article>
+                  )
+                })}
+                {batchId && <BatchSummary tasks={group.tasks} missingCount={group.missingCount} />}
+              </div>
+            )
+          })}
+        </div>
+      )}
+    </section>
+  )
+}
+
+export type { BookAnalysisImportTaskPanelProps }

+ 378 - 0
src/components/novel/book-analysis-input-dialog.spec.tsx

@@ -0,0 +1,378 @@
+// @vitest-environment jsdom
+
+import { act } from "react"
+import { createRoot, type Root } from "react-dom/client"
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
+import { BookAnalysisInputDialog } from "./book-analysis-input-dialog"
+
+const mocks = vi.hoisted(() => ({
+  openDialog: vi.fn(),
+  getFileSize: vi.fn(),
+}));
+
+vi.mock("@tauri-apps/plugin-dialog", () => ({
+  open: mocks.openDialog,
+}));
+
+vi.mock("@/commands/fs", () => ({
+  getFileSize: mocks.getFileSize,
+}));
+
+(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true
+
+let host: HTMLDivElement
+let root: Root
+
+function findButton(label: string) {
+  const button = [...document.body.querySelectorAll("button")].find(
+    (candidate) => candidate.textContent?.trim() === label,
+  )
+  if (!button) throw new Error(`未找到按钮:${label}`)
+  return button as HTMLButtonElement
+}
+
+async function clickButton(label: string) {
+  await act(async () => {
+    findButton(label).dispatchEvent(new MouseEvent("click", { bubbles: true }))
+    await Promise.resolve()
+  })
+}
+
+function renderDialog(onSubmit = vi.fn(), onOpenChange = vi.fn()) {
+  act(() => {
+    root.render(
+      <BookAnalysisInputDialog
+        open
+        onOpenChange={onOpenChange}
+        onSubmit={onSubmit}
+      />,
+    )
+  })
+  return { onSubmit, onOpenChange }
+}
+
+describe("BookAnalysisInputDialog", () => {
+  beforeEach(() => {
+    host = document.createElement("div")
+    document.body.appendChild(host)
+    root = createRoot(host)
+    mocks.openDialog.mockReset()
+    mocks.getFileSize.mockReset()
+  })
+
+  afterEach(() => {
+    act(() => root.unmount())
+    host.remove()
+    vi.clearAllMocks()
+  })
+
+  it("以多选 TXT 对话框添加文件并显示名称、大小和完整路径", async () => {
+    mocks.openDialog.mockResolvedValue([
+      "C:\\小说\\第一本.txt",
+      "D:\\资料\\第二本.txt",
+    ])
+    mocks.getFileSize
+      .mockResolvedValueOnce(1536)
+      .mockResolvedValueOnce(2 * 1024 * 1024)
+
+    renderDialog()
+    await clickButton("选择文件")
+
+    expect(mocks.openDialog).toHaveBeenCalledWith({
+      multiple: true,
+      filters: [{ name: "文本文件", extensions: ["txt"] }],
+    })
+    expect(mocks.getFileSize).toHaveBeenNthCalledWith(1, "C:\\小说\\第一本.txt")
+    expect(mocks.getFileSize).toHaveBeenNthCalledWith(2, "D:\\资料\\第二本.txt")
+    expect(document.body.textContent).toContain("已选择 2 本小说")
+    expect(document.body.textContent).toContain("第一本.txt")
+    expect(document.body.textContent).toContain("1.5 KB")
+    expect(document.body.textContent).toContain("C:\\小说\\第一本.txt")
+    expect(document.body.textContent).toContain("第二本.txt")
+    expect(document.body.textContent).toContain("2 MB")
+    expect(document.body.textContent).toContain("D:\\资料\\第二本.txt")
+    expect(findButton("继续添加")).toBeTruthy()
+  })
+
+  it("兼容单个字符串返回值,并在单项读取失败时保留其他成功文件", async () => {
+    mocks.openDialog
+      .mockResolvedValueOnce("C:\\小说\\单本.txt")
+      .mockResolvedValueOnce([
+        "D:\\小说\\成功.txt",
+        "D:\\小说\\损坏.txt",
+      ])
+    mocks.getFileSize
+      .mockResolvedValueOnce(1024)
+      .mockResolvedValueOnce(2048)
+      .mockRejectedValueOnce(new Error("read failed"))
+
+    renderDialog()
+    await clickButton("选择文件")
+    await clickButton("继续添加")
+
+    expect(document.body.textContent).toContain("已选择 2 本小说")
+    expect(document.body.textContent).toContain("单本.txt")
+    expect(document.body.textContent).toContain("成功.txt")
+    expect(document.body.textContent).toContain("读取文件“损坏.txt”失败,已跳过该文件")
+    expect(mocks.getFileSize).toHaveBeenCalledTimes(3)
+  })
+
+  it("支持移除单项,并在空列表时禁用开始导入", async () => {
+    mocks.openDialog.mockResolvedValue("C:\\小说\\待移除.txt")
+    mocks.getFileSize.mockResolvedValue(512)
+
+    renderDialog()
+    expect(findButton("开始导入").disabled).toBe(true)
+
+    await clickButton("选择文件")
+    expect(findButton("开始导入").disabled).toBe(false)
+
+    await clickButton("移除")
+    expect(document.body.textContent).toContain("已选择 0 本小说")
+    expect(document.body.textContent).not.toContain("待移除.txt")
+    expect(findButton("开始导入").disabled).toBe(true)
+  })
+
+  it("成功提交候选文件后清空列表,并由父级处理关闭", async () => {
+    mocks.openDialog.mockResolvedValue("C:\\小说\\提交.txt")
+    mocks.getFileSize.mockResolvedValue(4096)
+    const onSubmit = vi.fn().mockResolvedValue(undefined)
+    const onOpenChange = vi.fn()
+
+    renderDialog(onSubmit, onOpenChange)
+    await clickButton("选择文件")
+    await clickButton("开始导入")
+
+    expect(onSubmit).toHaveBeenCalledWith([
+      {
+        sourcePath: "C:\\小说\\提交.txt",
+        fileName: "提交.txt",
+        fileSize: 4096,
+      },
+    ])
+    expect(document.body.textContent).toContain("已选择 0 本小说")
+    expect(onOpenChange).not.toHaveBeenCalled()
+  })
+
+  it("限制弹窗高度并让确认列表在边界内滚动", () => {
+    renderDialog()
+
+    const dialog = document.body.querySelector<HTMLElement>('[role="dialog"]')
+    const scrollList = [...document.body.querySelectorAll<HTMLElement>("div")].find(
+      (element) => element.classList.contains("min-h-0") && element.classList.contains("overflow-y-auto"),
+    )
+
+    expect(dialog?.classList.contains("max-h-[85vh]")).toBe(true)
+    expect(scrollList).toBeTruthy()
+  })
+
+  it("按规范化路径跳过同次选择和继续添加中的重复文件", async () => {
+    mocks.openDialog
+      .mockResolvedValueOnce([
+        "C:\\Novel\\Book.txt",
+        "c:/novel/book.TXT",
+      ])
+      .mockResolvedValueOnce("C:/NOVEL/BOOK.txt")
+    mocks.getFileSize.mockResolvedValue(1024)
+    const onSubmit = vi.fn().mockResolvedValue(undefined)
+
+    renderDialog(onSubmit)
+    await clickButton("选择文件")
+
+    expect(document.body.textContent).toContain("已选择 1 本小说")
+    expect(document.body.textContent).toContain("重复文件")
+    expect(mocks.getFileSize).toHaveBeenCalledTimes(1)
+
+    await clickButton("继续添加")
+    expect(document.body.textContent).toContain("已选择 1 本小说")
+    expect(document.body.textContent).toContain("重复文件")
+    expect(mocks.getFileSize).toHaveBeenCalledTimes(1)
+
+    await clickButton("开始导入")
+    expect(onSubmit).toHaveBeenCalledWith([
+      {
+        sourcePath: "C:\\Novel\\Book.txt",
+        fileName: "Book.txt",
+        fileSize: 1024,
+      },
+    ])
+  })
+
+  it("提交 pending 时同步阻止双击并禁用所有可变更和关闭按钮", async () => {
+    let resolveSubmit!: () => void
+    const pendingSubmit = new Promise<void>((resolve) => {
+      resolveSubmit = resolve
+    })
+    const onSubmit = vi.fn(() => pendingSubmit)
+    mocks.openDialog.mockResolvedValue("C:\\小说\\提交中.txt")
+    mocks.getFileSize.mockResolvedValue(1024)
+
+    renderDialog(onSubmit)
+    await clickButton("选择文件")
+
+    await act(async () => {
+      const startButton = findButton("开始导入")
+      startButton.dispatchEvent(new MouseEvent("click", { bubbles: true }))
+      startButton.dispatchEvent(new MouseEvent("click", { bubbles: true }))
+      await Promise.resolve()
+    })
+
+    expect(onSubmit).toHaveBeenCalledTimes(1)
+    expect(findButton("正在导入…").disabled).toBe(true)
+    expect(findButton("继续添加").disabled).toBe(true)
+    expect(findButton("移除").disabled).toBe(true)
+    expect(findButton("取消").disabled).toBe(true)
+
+    await act(async () => {
+      resolveSubmit()
+      await pendingSubmit
+    })
+  })
+
+  it("提交失败时保留列表并允许重试", async () => {
+    const onSubmit = vi.fn().mockRejectedValue(new Error("submit failed"))
+    mocks.openDialog.mockResolvedValue("C:\\小说\\重试.txt")
+    mocks.getFileSize.mockResolvedValue(2048)
+
+    renderDialog(onSubmit)
+    await clickButton("选择文件")
+    await clickButton("开始导入")
+
+    expect(document.body.textContent).toContain("已选择 1 本小说")
+    expect(document.body.textContent).toContain("重试.txt")
+    expect(document.body.querySelector('[role="alert"]')?.textContent).toContain("开始导入失败,请重试")
+    expect(findButton("开始导入").disabled).toBe(false)
+  })
+
+  it("父级关闭会清理状态,且关闭后的异步选择结果不会写回重开的弹窗", async () => {
+    let resolveSelection!: (paths: string[]) => void
+    const pendingSelection = new Promise<string[]>((resolve) => {
+      resolveSelection = resolve
+    })
+    mocks.openDialog
+      .mockResolvedValueOnce("C:\\小说\\旧状态.txt")
+      .mockReturnValueOnce(pendingSelection)
+    mocks.getFileSize.mockResolvedValue(1024)
+    const onSubmit = vi.fn()
+    const onOpenChange = vi.fn()
+    const renderWithOpen = (open: boolean) => {
+      act(() => {
+        root.render(
+          <BookAnalysisInputDialog open={open} onOpenChange={onOpenChange} onSubmit={onSubmit} />,
+        )
+      })
+    }
+
+    renderWithOpen(true)
+    await clickButton("选择文件")
+    expect(document.body.textContent).toContain("旧状态.txt")
+
+    await clickButton("继续添加")
+    renderWithOpen(false)
+    renderWithOpen(true)
+    expect(document.body.textContent).toContain("已选择 0 本小说")
+    expect(document.body.textContent).not.toContain("旧状态.txt")
+
+    await act(async () => {
+      resolveSelection(["C:\\小说\\异步回写.txt"])
+      await pendingSelection
+      await Promise.resolve()
+    })
+
+    expect(document.body.textContent).toContain("已选择 0 本小说")
+    expect(document.body.textContent).not.toContain("异步回写.txt")
+  })
+
+  it("大量错误使用可滚动的告警区域,移除按钮包含文件名标签", async () => {
+    mocks.openDialog
+      .mockResolvedValueOnce("C:\\小说\\可移除.txt")
+      .mockResolvedValueOnce(Array.from({ length: 20 }, (_, index) => `C:\\错误\\文件${index}.pdf`))
+    mocks.getFileSize.mockResolvedValue(512)
+
+    renderDialog()
+    await clickButton("选择文件")
+    expect(document.body.querySelector('button[aria-label="移除可移除.txt"]')).toBeTruthy()
+
+    await clickButton("继续添加")
+    const alert = document.body.querySelector<HTMLElement>('[role="alert"]')
+    expect(alert).toBeTruthy()
+    expect(alert?.classList.contains("max-h-32")).toBe(true)
+    expect(alert?.classList.contains("overflow-y-auto")).toBe(true)
+    expect(alert?.textContent).toContain("文件19.pdf")
+  })
+
+  it("读取文件大小 pending 时锁定选择和提交,完成后保留首批文件并恢复操作", async () => {
+    let resolveFileSize!: (size: number) => void
+    const pendingFileSize = new Promise<number>((resolve) => {
+      resolveFileSize = resolve
+    })
+    mocks.openDialog
+      .mockResolvedValueOnce("C:\\小说\\首批.txt")
+      .mockResolvedValueOnce("C:\\小说\\读取中.txt")
+    mocks.getFileSize
+      .mockResolvedValueOnce(1024)
+      .mockReturnValueOnce(pendingFileSize)
+    const onSubmit = vi.fn()
+
+    renderDialog(onSubmit)
+    await clickButton("选择文件")
+
+    let pendingSelection!: Promise<void>
+    await act(async () => {
+      findButton("继续添加").dispatchEvent(new MouseEvent("click", { bubbles: true }))
+      await Promise.resolve()
+    })
+    pendingSelection = pendingFileSize.then(async () => {
+      await Promise.resolve()
+    })
+
+    expect(findButton("正在读取…").disabled).toBe(true)
+    expect(findButton("开始导入").disabled).toBe(true)
+    expect(findButton("移除").disabled).toBe(true)
+    expect(findButton("取消").disabled).toBe(true)
+
+    await act(async () => {
+      findButton("正在读取…").dispatchEvent(new MouseEvent("click", { bubbles: true }))
+      findButton("开始导入").dispatchEvent(new MouseEvent("click", { bubbles: true }))
+      await Promise.resolve()
+    })
+    expect(mocks.openDialog).toHaveBeenCalledTimes(2)
+    expect(onSubmit).not.toHaveBeenCalled()
+
+    await act(async () => {
+      resolveFileSize(2048)
+      await pendingSelection
+      await Promise.resolve()
+    })
+
+    expect(document.body.textContent).toContain("已选择 2 本小说")
+    expect(document.body.textContent).toContain("首批.txt")
+    expect(document.body.textContent).toContain("读取中.txt")
+    expect(findButton("继续添加").disabled).toBe(false)
+    expect(findButton("开始导入").disabled).toBe(false)
+    expect(findButton("移除").disabled).toBe(false)
+    expect(findButton("取消").disabled).toBe(false)
+  })
+
+  it("Windows 盘符和 UNC 路径忽略大小写,POSIX 路径保留大小写", async () => {
+    mocks.openDialog.mockResolvedValue([
+      "/Books/Novel.txt",
+      "/books/novel.txt",
+      "C:\\Novel\\Drive.txt",
+      "c:/novel/drive.TXT",
+      "\\\\Server\\Share\\Unc.txt",
+      "\\\\server\\share\\unc.TXT",
+    ])
+    mocks.getFileSize.mockResolvedValue(1024)
+
+    renderDialog()
+    await clickButton("选择文件")
+
+    expect(document.body.textContent).toContain("已选择 4 本小说")
+    expect(document.body.textContent).toContain("/Books/Novel.txt")
+    expect(document.body.textContent).toContain("/books/novel.txt")
+    expect(mocks.getFileSize).toHaveBeenCalledTimes(4)
+    expect(document.body.textContent).toContain("重复文件“drive.TXT”,已跳过")
+    expect(document.body.textContent).toContain("重复文件“unc.TXT”,已跳过")
+  })
+})

+ 222 - 75
src/components/novel/book-analysis-input-dialog.tsx

@@ -1,17 +1,32 @@
-import { useState } from "react"
-import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog"
+import { useEffect, useRef, useState } from "react"
+import { AlertCircle, FileText, X } from "lucide-react"
+import { getFileSize } from "@/commands/fs"
 import { Button } from "@/components/ui/button"
-import { Input } from "@/components/ui/input"
-import { Label } from "@/components/ui/label"
-import { FileText, AlertCircle } from "lucide-react"
+import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"
+import type { BatchImportCandidate } from "@/lib/novel/book-analysis/batch-import-types"
+import { normalizePath } from "@/lib/path-utils"
 
 interface BookAnalysisInputDialogProps {
   open: boolean
   onOpenChange: (open: boolean) => void
-  onSubmit: (config: {
-    sourceType: "file"
-    sourcePath: string
-  }) => void
+  onSubmit: (files: BatchImportCandidate[]) => Promise<void> | void
+}
+
+function getFileName(path: string) {
+  return path.split(/[\\/]/).pop() || path
+}
+
+function getPathKey(path: string) {
+  const normalizedPath = normalizePath(path)
+  const isWindowsPath = /^[A-Za-z]:\//.test(normalizedPath) || normalizedPath.startsWith("//")
+  return isWindowsPath ? normalizedPath.toLowerCase() : normalizedPath
+}
+
+function formatFileSize(bytes: number) {
+  if (bytes < 1024) return `${bytes} B`
+  if (bytes < 1024 * 1024) return `${Number((bytes / 1024).toFixed(1))} KB`
+  if (bytes < 1024 * 1024 * 1024) return `${Number((bytes / 1024 / 1024).toFixed(1))} MB`
+  return `${Number((bytes / 1024 / 1024 / 1024).toFixed(1))} GB`
 }
 
 export function BookAnalysisInputDialog({
@@ -19,14 +34,68 @@ export function BookAnalysisInputDialog({
   onOpenChange,
   onSubmit,
 }: BookAnalysisInputDialogProps) {
-  const [filePath, setFilePath] = useState("")
+  const [files, setFiles] = useState<BatchImportCandidate[]>([])
   const [error, setError] = useState("")
+  const [isSubmitting, setIsSubmitting] = useState(false)
+  const [isSelecting, setIsSelecting] = useState(false)
+  const filesRef = useRef<BatchImportCandidate[]>([])
+  const isSubmittingRef = useRef(false)
+  const isSelectingRef = useRef(false)
+  const openRef = useRef(open)
+  const sessionTokenRef = useRef(0)
+  const selectionTokenRef = useRef(0)
+  openRef.current = open
+
+  const replaceFiles = (
+    nextFiles: BatchImportCandidate[] | ((currentFiles: BatchImportCandidate[]) => BatchImportCandidate[]),
+  ) => {
+    setFiles((currentFiles) => {
+      const resolvedFiles = typeof nextFiles === "function" ? nextFiles(currentFiles) : nextFiles
+      filesRef.current = resolvedFiles
+      return resolvedFiles
+    })
+  }
+
+  const resetDialogState = () => {
+    sessionTokenRef.current += 1
+    selectionTokenRef.current += 1
+    filesRef.current = []
+    isSubmittingRef.current = false
+    isSelectingRef.current = false
+    setFiles([])
+    setError("")
+    setIsSubmitting(false)
+    setIsSelecting(false)
+  }
 
-  const handleSelectFile = async () => {
+  useEffect(() => {
+    if (!open) resetDialogState()
+
+    return () => {
+      sessionTokenRef.current += 1
+      selectionTokenRef.current += 1
+    }
+  }, [open])
+
+  const isSelectionSessionActive = (sessionToken: number, selectionToken: number) => (
+    openRef.current
+    && sessionTokenRef.current === sessionToken
+    && selectionTokenRef.current === selectionToken
+  )
+
+  const handleSelectFiles = async () => {
+    if (isSubmittingRef.current || isSelectingRef.current) return
+
+    isSelectingRef.current = true
+    setIsSelecting(true)
+    const sessionToken = sessionTokenRef.current
+    const selectionToken = ++selectionTokenRef.current
     try {
       const { open: openDialog } = await import("@tauri-apps/plugin-dialog")
+      if (!isSelectionSessionActive(sessionToken, selectionToken)) return
+
       const selected = await openDialog({
-        multiple: false,
+        multiple: true,
         filters: [
           {
             name: "文本文件",
@@ -34,99 +103,177 @@ export function BookAnalysisInputDialog({
           },
         ],
       })
+      if (!isSelectionSessionActive(sessionToken, selectionToken)) return
+
+      const selectedPaths = (typeof selected === "string" ? [selected] : selected ?? [])
+        .filter((path): path is string => typeof path === "string")
+      const knownPaths = new Set(filesRef.current.map((file) => getPathKey(file.sourcePath)))
+      const nextFiles: BatchImportCandidate[] = []
+      const failureMessages: string[] = []
+
+      for (const sourcePath of selectedPaths) {
+        const fileName = getFileName(sourcePath)
+        if (!fileName.toLowerCase().endsWith(".txt")) {
+          failureMessages.push(`仅支持 TXT 文件,已跳过“${fileName}”`)
+          continue
+        }
 
-      if (selected && typeof selected === "string") {
-        setFilePath(selected)
-        setError("")
+        const pathKey = getPathKey(sourcePath)
+        if (knownPaths.has(pathKey)) {
+          failureMessages.push(`重复文件“${fileName}”,已跳过`)
+          continue
+        }
+        knownPaths.add(pathKey)
+
+        try {
+          const fileSize = await getFileSize(sourcePath)
+          if (!isSelectionSessionActive(sessionToken, selectionToken)) return
+          nextFiles.push({ sourcePath, fileName, fileSize })
+        } catch (err) {
+          if (!isSelectionSessionActive(sessionToken, selectionToken)) return
+          failureMessages.push(`读取文件“${fileName}”失败,已跳过该文件`)
+          console.error(err)
+        }
       }
+
+      if (!isSelectionSessionActive(sessionToken, selectionToken)) return
+      if (nextFiles.length > 0) {
+        replaceFiles((currentFiles) => [...currentFiles, ...nextFiles])
+      }
+      setError(failureMessages.join("\n"))
     } catch (err) {
-      setError("选择文件失败")
+      if (!isSelectionSessionActive(sessionToken, selectionToken)) return
+      setError("选择文件失败,请重试")
       console.error(err)
+    } finally {
+      if (isSelectionSessionActive(sessionToken, selectionToken)) {
+        isSelectingRef.current = false
+        setIsSelecting(false)
+      }
     }
   }
 
-  const handleSubmit = () => {
-    setError("")
+  const handleRemove = (index: number) => {
+    if (isSubmittingRef.current || isSelectingRef.current) return
+    replaceFiles((currentFiles) => currentFiles.filter((_, fileIndex) => fileIndex !== index))
+  }
 
-    if (!filePath.trim()) {
-      setError("请选择一个TXT文件")
-      return
-    }
+  const handleSubmit = async () => {
+    if (isSubmittingRef.current || isSelectingRef.current || filesRef.current.length === 0) return
 
-    onSubmit({
-      sourceType: "file",
-      sourcePath: filePath,
-    })
-
-    // 重置表单
-    setFilePath("")
+    isSubmittingRef.current = true
+    setIsSubmitting(true)
     setError("")
+    selectionTokenRef.current += 1
+    const sessionToken = sessionTokenRef.current
+    const submittedFiles = [...filesRef.current]
+    const submittedPathKeys = new Set(submittedFiles.map((file) => getPathKey(file.sourcePath)))
+
+    try {
+      await onSubmit(submittedFiles)
+      if (sessionTokenRef.current !== sessionToken) return
+      replaceFiles((currentFiles) => (
+        currentFiles.filter((file) => !submittedPathKeys.has(getPathKey(file.sourcePath)))
+      ))
+    } catch (err) {
+      if (sessionTokenRef.current !== sessionToken) return
+      setError("开始导入失败,请重试")
+      console.error(err)
+    } finally {
+      if (sessionTokenRef.current === sessionToken) {
+        isSubmittingRef.current = false
+        setIsSubmitting(false)
+      }
+    }
   }
 
-  const handleCancel = () => {
-    setError("")
-    setFilePath("")
-    onOpenChange(false)
+  const handleOpenChange = (nextOpen: boolean) => {
+    if (!nextOpen) {
+      if (isSubmittingRef.current || isSelectingRef.current) return
+      resetDialogState()
+    }
+    onOpenChange(nextOpen)
   }
 
+  const isBusy = isSelecting || isSubmitting
+
   return (
-    <Dialog open={open} onOpenChange={onOpenChange}>
-      <DialogContent className="sm:max-w-[500px]">
+    <Dialog open={open} onOpenChange={handleOpenChange}>
+      <DialogContent
+        className="flex max-h-[85vh] flex-col sm:max-w-[640px]"
+        showCloseButton={!isBusy}
+      >
         <DialogHeader>
-          <DialogTitle>拆书作品</DialogTitle>
+          <DialogTitle>批量导入小说</DialogTitle>
         </DialogHeader>
 
-        <div className="space-y-6 py-4">
-          {/* 文件选择 */}
-          <div className="space-y-2">
-            <Label>选择小说文件</Label>
-            <div className="flex gap-2">
-              <Input
-                value={filePath}
-                placeholder="点击右侧按钮选择TXT文件..."
-                readOnly
-                className="flex-1"
-              />
-              <Button onClick={handleSelectFile} variant="outline">
-                <FileText className="h-4 w-4 mr-2" />
-                浏览
-              </Button>
+        <div className="flex min-h-0 flex-1 flex-col gap-4 overflow-hidden py-4">
+          <div className="flex items-center justify-between gap-3">
+            <div>
+              <p className="font-medium">已选择 {files.length} 本小说</p>
+              <p className="text-xs text-muted-foreground">仅支持 TXT 文件</p>
             </div>
-            <p className="text-xs text-muted-foreground">
-              支持UTF-8和GBK编码的TXT文件,建议大小在50MB以内
-            </p>
+            <Button onClick={handleSelectFiles} variant="outline" disabled={isBusy}>
+              <FileText className="mr-2 h-4 w-4" />
+              {isSelecting ? "正在读取…" : files.length > 0 ? "继续添加" : "选择文件"}
+            </Button>
+          </div>
+
+          <div className="min-h-0 flex-1 space-y-2 overflow-y-auto pr-1">
+            {files.length === 0 ? (
+              <div className="flex min-h-28 items-center justify-center rounded-md border border-dashed text-sm text-muted-foreground">
+                暂未选择小说文件
+              </div>
+            ) : (
+              files.map((file, index) => (
+                <div key={getPathKey(file.sourcePath)} className="flex items-start gap-3 rounded-md border p-3">
+                  <FileText className="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground" />
+                  <div className="min-w-0 flex-1">
+                    <div className="flex items-center gap-2">
+                      <p className="truncate text-sm font-medium">{file.fileName}</p>
+                      <span className="shrink-0 text-xs text-muted-foreground">
+                        {formatFileSize(file.fileSize)}
+                      </span>
+                    </div>
+                    <p className="mt-1 break-all text-xs text-muted-foreground">{file.sourcePath}</p>
+                  </div>
+                  <Button
+                    type="button"
+                    variant="ghost"
+                    size="sm"
+                    className="shrink-0"
+                    aria-label={`移除${file.fileName}`}
+                    disabled={isBusy}
+                    onClick={() => handleRemove(index)}
+                  >
+                    <X className="mr-1 h-4 w-4" />
+                    移除
+                  </Button>
+                </div>
+              ))
+            )}
           </div>
 
-          {/* 错误提示 */}
           {error && (
-            <div className="flex items-start gap-2 p-3 bg-destructive/10 text-destructive rounded-md">
-              <AlertCircle className="h-4 w-4 mt-0.5 flex-shrink-0" />
-              <span className="text-sm">{error}</span>
+            <div
+              role="alert"
+              className="flex max-h-32 items-start gap-2 overflow-y-auto rounded-md bg-destructive/10 p-3 text-destructive"
+            >
+              <AlertCircle className="mt-0.5 h-4 w-4 shrink-0" />
+              <span className="whitespace-pre-line text-sm">{error}</span>
             </div>
           )}
-
-          {/* 说明 */}
-          <div className="p-4 bg-muted rounded-md text-sm text-muted-foreground space-y-2">
-            <p className="font-medium text-foreground">导入后可进行:</p>
-            <ul className="list-disc list-inside space-y-1 ml-2">
-              <li>自动识别章节,可选择需要分析的章节范围</li>
-              <li>提取小说中的所有角色及其性格特征</li>
-              <li>为每个角色生成可复用的 Skill 技能</li>
-              <li>将角色添加到自定义灵魂库,绑定到自己的作品中</li>
-            </ul>
-            <p className="text-xs mt-3 text-muted-foreground/80">
-              💡 提示:大型小说(500+章)分析耗时较长,支持随时暂停和继续
-            </p>
-          </div>
         </div>
 
         <DialogFooter>
-          <Button variant="outline" onClick={handleCancel}>
+          <Button variant="outline" onClick={() => handleOpenChange(false)} disabled={isBusy}>
             取消
           </Button>
-          <Button onClick={handleSubmit}>开始拆书</Button>
+          <Button onClick={handleSubmit} disabled={files.length === 0 || isBusy}>
+            {isSubmitting ? "正在导入…" : "开始导入"}
+          </Button>
         </DialogFooter>
       </DialogContent>
     </Dialog>
   )
-}
+}

+ 9 - 0
src/components/novel/book-analysis-library-layout.spec.tsx

@@ -124,6 +124,7 @@ describe("BookAnalysisLibraryLayout", () => {
         extractingStyle={false}
         extractingCharacters={false}
         addingToSoul={false}
+        importTaskPanel={<div>批量导入任务</div>}
         storyFrameworks={storyFrameworks}
         onSelectBook={vi.fn()}
         onSelectCharacter={vi.fn()}
@@ -140,6 +141,14 @@ describe("BookAnalysisLibraryLayout", () => {
     )
 
     expect(html).toContain("拆书库")
+    expect(html).toContain("批量导入任务")
+    const headerTextIndex = html.indexOf("管理作品文风、角色 Skill 和小说人物绑定。")
+    const importTaskPanelIndex = html.indexOf(">批量导入任务</div>")
+    const bookContentTitleIndex = html.indexOf('<h3 class="text-lg font-semibold">凡人修仙传</h3>')
+    expect(headerTextIndex).toBeGreaterThanOrEqual(0)
+    expect(importTaskPanelIndex).toBeGreaterThan(headerTextIndex)
+    expect(bookContentTitleIndex).toBeGreaterThan(importTaskPanelIndex)
+    expect(html).toContain('class="flex min-h-0 flex-1 flex-col gap-4 overflow-y-auto p-5"')
     expect(html).toContain("启用文风")
     expect(html).toContain("凡人修仙传")
     expect(html).toContain("作品文风")

+ 4 - 0
src/components/novel/book-analysis-library-layout.tsx

@@ -1,3 +1,4 @@
+import type { ReactNode } from "react"
 import { Plus, RefreshCw, Users } from "lucide-react"
 import { Button } from "@/components/ui/button"
 import type { BookAnalysisLibraryState } from "@/lib/novel/book-analysis/library-state"
@@ -15,6 +16,7 @@ interface BookAnalysisLibraryLayoutProps {
   extractingStoryFramework: boolean
   addingToSoul: boolean
   storyFrameworks?: PlotFramework[]
+  importTaskPanel?: ReactNode
   onSelectBook: (bookId: string) => void
   onSelectCharacter: (characterId: string) => void
   onImportNovel: () => void
@@ -36,6 +38,7 @@ export function BookAnalysisLibraryLayout({
   extractingStoryFramework,
   addingToSoul,
   storyFrameworks = [],
+  importTaskPanel,
   onSelectCharacter,
   onImportNovel,
   onExtractStyle,
@@ -83,6 +86,7 @@ export function BookAnalysisLibraryLayout({
             </Button>
           </div>
         </header>
+        {importTaskPanel}
         {selectedBook ? (
           <div className="flex min-h-0 flex-1 flex-col gap-4 overflow-y-auto p-5">
             <div>

+ 514 - 14
src/components/novel/book-analysis-view.spec.ts

@@ -1,20 +1,520 @@
-import { readFileSync } from "node:fs"
-import { describe, expect, it } from "vitest"
+// @vitest-environment jsdom
 
-const source = readFileSync("src/components/novel/book-analysis-view.tsx", "utf8")
-const libraryOpsSource = readFileSync("src/components/novel/hooks/use-library-operations.ts", "utf8")
+import { act, createElement } from "react"
+import { createRoot, type Root } from "react-dom/client"
+import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from "vitest"
 
-describe("BookAnalysisView 三栏资源库接入", () => {
-  it("把拆书库主界面接入三栏资源库,同时保留旧结果查看器兼容入口", () => {
-    expect(source).toContain("BookAnalysisLibraryLayout")
-    expect(source).toContain("useLibraryOperations")
-    expect(source).toContain("toBookAnalysisResult")
-    expect(source).toContain("BookAnalysisResultViewer")
-    expect(source).toContain("setCurrentResult(toBookAnalysisResult")
+const mocks = vi.hoisted(() => {
+  const candidates = [
+    { sourcePath: "C:/小说/长夜.txt", fileName: "长夜.txt", fileSize: 1024 },
+    { sourcePath: "D:/小说/归途.txt", fileName: "归途.txt", fileSize: 2048 },
+  ]
+  const libraryState = {
+    books: [{
+      id: "book-1",
+      path: "E:/项目甲/book-analysis/book-1",
+      metadata: {
+        title: "长夜",
+        totalChapters: 2,
+        totalWords: 3200,
+        sourceType: "file" as const,
+        createdAt: 1,
+        updatedAt: 2,
+      },
+      recognizedCharacters: [],
+      characters: [],
+      skills: [],
+      styleStatus: "missing" as const,
+      boundAurasCount: 0,
+      addedAuraCharacterIds: [],
+    }, {
+      id: "book-2", path: "E:/项目甲/book-analysis/book-2",
+      metadata: { title: "归途", totalChapters: 3, totalWords: 4800, sourceType: "file" as const, createdAt: 3, updatedAt: 4 },
+      recognizedCharacters: [], characters: [], skills: [], styleStatus: "missing" as const,
+      boundAurasCount: 0, addedAuraCharacterIds: [],
+    }],
+    enabledStyle: null,
+    bindings: [],
+  }
+
+  const initializeProject = vi.fn()
+  const createBatch = vi.fn()
+  const continueTask = vi.fn()
+  const regenerateTask = vi.fn()
+  const cancelImportTask = vi.fn()
+  const cancelAllQueued = vi.fn()
+  const dispose = vi.fn()
+  const setPanelCollapsed = vi.fn()
+  const triggerSidebarRefresh = vi.fn()
+  const setSelectedLibraryBookId = vi.fn()
+  const setCurrentResult = vi.fn()
+  const setShowResultViewer = vi.fn()
+  const clearRecognition = vi.fn()
+  const reloadLibraryState = vi.fn()
+  const handleLibraryExtractStyle = vi.fn()
+  const handleLibraryReextractCharacters = vi.fn()
+  const loadBookStoryFrameworkChapters = vi.fn()
+
+  return {
+    candidates,
+    libraryState,
+    wikiState: {
+      project: { id: "project-a", name: "项目甲", path: "E:/项目甲" } as { id: string; name: string; path: string } | null,
+      llmConfig: {},
+      novelConfig: { defaultLlmModel: "" },
+      aiChatModel: "",
+      providerConfigs: [],
+    },
+    oldState: {
+      selectedLibraryBookId: null as string | null,
+      sidebarRefreshCounter: 0,
+      pendingRecognitionTaskId: null as string | null,
+      startTask: vi.fn(),
+      cancelTask: vi.fn(),
+      tasks: [],
+      currentResult: null,
+      showResultViewer: false,
+      setShowResultViewer,
+      recognitionStatus: "idle",
+      recognizedCharacters: [],
+      selectedCharacterIds: [],
+      setRecognitionStatus: vi.fn(),
+      setRecognizedCharacters: vi.fn(),
+      setSelectedCharacterIds: vi.fn(),
+      clearRecognition,
+      recognitionError: undefined,
+      setRecognitionError: vi.fn(),
+      consumeReopenRequest: vi.fn(),
+      triggerSidebarRefresh,
+      setSelectedLibraryBookId,
+      setCurrentResult,
+    },
+    importState: {
+      projectPath: "E:/项目甲" as string | null,
+      batches: [],
+      tasks: [],
+      panelCollapsed: false,
+      revision: 0,
+      initializeProject,
+      createBatch,
+      continueTask,
+      regenerateTask,
+      cancelTask: cancelImportTask,
+      cancelAllQueued,
+      setPanelCollapsed,
+      dispose,
+    },
+    initializeProject,
+    createBatch,
+    continueTask,
+    regenerateTask,
+    cancelImportTask,
+    cancelAllQueued,
+    dispose,
+    setPanelCollapsed,
+    triggerSidebarRefresh,
+    setSelectedLibraryBookId,
+    setCurrentResult,
+    setShowResultViewer,
+    clearRecognition,
+    reloadLibraryState,
+    handleLibraryExtractStyle,
+    handleLibraryReextractCharacters,
+    loadBookStoryFrameworkChapters,
+    libraryParams: null as any,
+    inputDialogProps: null as any,
+    taskPanelProps: null as any,
+    layoutProps: null as any,
+    chapterPanelProps: null as any,
+  }
+})
+
+vi.mock("@/stores/wiki-store", () => {
+  const useWikiStore = Object.assign(
+    (selector: (state: typeof mocks.wikiState) => unknown) => selector(mocks.wikiState),
+    { getState: () => mocks.wikiState },
+  )
+  return { useWikiStore }
+})
+
+vi.mock("@/stores/book-analysis-store", () => {
+  const useBookAnalysisStore = Object.assign(
+    (selector: (state: typeof mocks.oldState) => unknown) => selector(mocks.oldState),
+    { getState: () => mocks.oldState },
+  )
+  return { useBookAnalysisStore }
+})
+
+vi.mock("@/stores/book-analysis-import-store", () => {
+  const useBookAnalysisImportStore = Object.assign(
+    (selector: (state: typeof mocks.importState) => unknown) => selector(mocks.importState),
+    { getState: () => mocks.importState },
+  )
+  return { useBookAnalysisImportStore }
+})
+
+vi.mock("./hooks/use-library-operations", () => ({
+  useLibraryOperations: (params: any) => {
+    mocks.libraryParams = params
+    return {
+      styleExtracting: false,
+      addingToSoul: false,
+      reloadLibraryState: mocks.reloadLibraryState,
+      handleLibraryExtractStyle: mocks.handleLibraryExtractStyle,
+      handleLibraryToggleStyle: vi.fn(),
+      handleLibraryAddSkillsToSoul: vi.fn(),
+      handleLibraryDeleteBook: vi.fn(),
+      handleLibraryReextractCharacters: mocks.handleLibraryReextractCharacters,
+    }
+  },
+}))
+
+vi.mock("./hooks/use-character-recognition", () => ({
+  useCharacterRecognition: () => ({
+    handleChapterSelectionConfirm: vi.fn(),
+    handleToggleCharacter: vi.fn(),
+    handleSelectAllMain: vi.fn(),
+    handleClearSelection: vi.fn(),
+  }),
+}))
+
+vi.mock("./hooks/use-character-extraction", () => ({
+  useCharacterExtraction: () => ({
+    handleDeepExtract: vi.fn(),
+    handleSimpleExtract: vi.fn(),
+    handleResumeFailedExtraction: vi.fn(),
+  }),
+}))
+
+vi.mock("./book-analysis-input-dialog", async () => {
+  const React = await import("react")
+  return {
+    BookAnalysisInputDialog: (props: any) => {
+      mocks.inputDialogProps = props
+      return React.createElement(
+        "button",
+        {
+          type: "button",
+          "data-testid": "提交批量导入",
+          onClick: () => void props.onSubmit(mocks.candidates),
+        },
+        "提交批量导入",
+      )
+    },
+  }
+})
+
+vi.mock("./book-analysis-import-task-panel", async () => {
+  const React = await import("react")
+  return {
+    BookAnalysisImportTaskPanel: (props: any) => {
+      mocks.taskPanelProps = props
+      return React.createElement("div", { "data-testid": "批量任务面板" },
+        React.createElement("button", { type: "button", onClick: () => props.onCollapsedChange(true) }, "收起任务"),
+        React.createElement("button", { type: "button", onClick: () => props.onContinue("task-continue") }, "继续任务"),
+        React.createElement("button", { type: "button", onClick: () => props.onRegenerate("task-regenerate") }, "重新生成任务"),
+        React.createElement("button", { type: "button", onClick: () => props.onCancel("task-cancel") }, "取消任务"),
+        React.createElement("button", { type: "button", onClick: () => props.onCancelAllQueued("batch-1") }, "取消批次等待"),
+        React.createElement("button", { type: "button", onClick: () => props.onOpenBook("book-1") }, "打开导入作品"),
+      )
+    },
+  }
+})
+
+vi.mock("./book-analysis-library-layout", async () => {
+  const React = await import("react")
+  return {
+    BookAnalysisLibraryLayout: (props: any) => {
+      mocks.layoutProps = props
+      return React.createElement("section", { "data-testid": "拆书库布局" },
+        props.importTaskPanel,
+        React.createElement("button", { type: "button", onClick: props.onImportNovel }, "打开导入弹窗"),
+        React.createElement("button", { type: "button", onClick: () => props.onSelectCharacter("character-1") }, "选择角色"),
+        React.createElement("button", { type: "button", onClick: () => props.onSelectBook("book-2") }, "选择普通作品B"),
+        React.createElement("button", { type: "button", onClick: props.onReextractCharacters }, "旧角色入口"),
+        React.createElement("button", { type: "button", onClick: props.onExtractStyle }, "旧文风入口"),
+        React.createElement("button", { type: "button", onClick: props.onExtractStoryFramework }, "旧框架入口"),
+      )
+    },
+  }
+})
+
+vi.mock("./chapter-selection-panel", async () => {
+  const React = await import("react")
+  return {
+    ChapterSelectionPanel: (props: any) => {
+      mocks.chapterPanelProps = props
+      return React.createElement("div", { "data-testid": "章节选择面板" }, "章节选择面板")
+    },
+  }
+})
+
+vi.mock("./book-analysis-result-viewer", async () => {
+  const React = await import("react")
+  return {
+    BookAnalysisResultViewer: (props: any) => React.createElement("div", { "data-testid": "历史结果查看器" },
+      "历史结果查看器",
+      React.createElement("button", { type: "button", onClick: props.onClose }, "关闭历史结果"),
+    ),
+  }
+})
+
+vi.mock("./outline-editor", () => ({ OutlineCreatorDialog: () => null }))
+
+vi.mock("@/lib/novel/model-resolver", () => ({ resolveDefaultModel: () => ({}) }))
+
+vi.mock("@/lib/novel/book-analysis/story-framework-extraction", () => ({
+  loadBookStoryFrameworkChapters: mocks.loadBookStoryFrameworkChapters,
+  buildBookStoryFrameworkPrompt: vi.fn(),
+  buildPlotFrameworkDraftFromBookStoryOutput: vi.fn(),
+}))
+
+vi.mock("@/lib/novel/plot-framework-library", () => ({
+  loadPlotFrameworkLibrary: vi.fn().mockResolvedValue({ frameworks: [] }),
+  upsertPlotFramework: vi.fn(),
+}))
+
+vi.mock("@/lib/llm-client", () => ({ streamChat: vi.fn() }))
+vi.mock("@/lib/has-usable-llm", () => ({ hasUsableLlm: () => true }))
+vi.mock("@/lib/toast", () => ({
+  toast: { info: vi.fn(), success: vi.fn(), error: vi.fn() },
+}))
+
+import { BookAnalysisView } from "./book-analysis-view"
+
+type ActGlobal = typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }
+const actGlobal = globalThis as ActGlobal
+const originalActEnvironment = actGlobal.IS_REACT_ACT_ENVIRONMENT
+function restoreActEnvironment() {
+  if (originalActEnvironment === undefined) delete actGlobal.IS_REACT_ACT_ENVIRONMENT
+  else actGlobal.IS_REACT_ACT_ENVIRONMENT = originalActEnvironment
+}
+
+let host: HTMLDivElement
+let root: Root
+let mounted = false
+
+async function flushEffects() {
+  await act(async () => {
+    await Promise.resolve()
+    await new Promise((resolve) => setTimeout(resolve, 0))
   })
+}
+
+async function renderView() {
+  await act(async () => {
+    root.render(createElement(BookAnalysisView))
+    await Promise.resolve()
+  })
+  mounted = true
+  await flushEffects()
+}
+
+async function rerenderView() {
+  await act(async () => {
+    root.render(createElement(BookAnalysisView))
+    await Promise.resolve()
+  })
+  await flushEffects()
+}
+
+async function clickButton(label: string) {
+  const button = [...host.querySelectorAll("button")].find((item) => item.textContent?.trim() === label)
+  if (!button) throw new Error(`未找到按钮:${label}`)
+  await act(async () => {
+    button.dispatchEvent(new MouseEvent("click", { bubbles: true }))
+    await Promise.resolve()
+  })
+  await flushEffects()
+}
+
+beforeEach(() => {
+  actGlobal.IS_REACT_ACT_ENVIRONMENT = true
+  host = document.createElement("div")
+  document.body.appendChild(host)
+  root = createRoot(host)
+  mounted = false
+
+  mocks.wikiState.project = { id: "project-a", name: "项目甲", path: "E:/项目甲" }
+  mocks.oldState.selectedLibraryBookId = null
+  mocks.oldState.sidebarRefreshCounter = 0
+  mocks.oldState.pendingRecognitionTaskId = null
+  mocks.oldState.tasks = []
+  mocks.oldState.currentResult = null
+  mocks.oldState.showResultViewer = false
+  mocks.importState.projectPath = "E:/项目甲"
+  mocks.importState.batches = []
+  mocks.importState.tasks = []
+  mocks.importState.panelCollapsed = false
+  mocks.importState.revision = 0
+  mocks.libraryParams = null
+  mocks.inputDialogProps = null
+  mocks.taskPanelProps = null
+  mocks.layoutProps = null
+  mocks.chapterPanelProps = null
+
+  mocks.initializeProject.mockReset().mockResolvedValue(undefined)
+  mocks.createBatch.mockReset().mockResolvedValue(undefined)
+  mocks.continueTask.mockReset().mockResolvedValue(undefined)
+  mocks.regenerateTask.mockReset().mockResolvedValue(undefined)
+  mocks.cancelImportTask.mockReset().mockResolvedValue(undefined)
+  mocks.cancelAllQueued.mockReset().mockResolvedValue(undefined)
+  mocks.dispose.mockReset().mockResolvedValue(undefined)
+  mocks.setPanelCollapsed.mockReset()
+  mocks.triggerSidebarRefresh.mockReset().mockImplementation(() => { mocks.oldState.sidebarRefreshCounter += 1 })
+  mocks.setSelectedLibraryBookId.mockReset().mockImplementation((id: string | null) => { mocks.oldState.selectedLibraryBookId = id })
+  mocks.setCurrentResult.mockReset()
+  mocks.setShowResultViewer.mockReset()
+  mocks.clearRecognition.mockReset()
+  mocks.handleLibraryExtractStyle.mockReset()
+  mocks.handleLibraryReextractCharacters.mockReset()
+  mocks.loadBookStoryFrameworkChapters.mockReset().mockResolvedValue([
+    { id: "chapter-1", title: "第一章", order: 1, content: "正文" },
+  ])
+  mocks.reloadLibraryState.mockReset().mockImplementation(async () => {
+    mocks.libraryParams.setLibraryState(mocks.libraryState)
+    mocks.libraryParams.setSelectedBookId((current: string | null) => current && mocks.libraryState.books.some((book) => book.id === current) ? current : "book-1")
+  })
+})
+
+afterEach(async () => {
+  if (mounted) {
+    await act(async () => {
+      root.unmount()
+      await Promise.resolve()
+    })
+  }
+  host.remove()
+  vi.clearAllMocks()
+})
+
+afterAll(() => restoreActEnvironment())
+
+describe("BookAnalysisView 批量导入运行时接线", () => {
+  it("挂载和项目切换时初始化对应项目,并在切换和卸载时异步释放", async () => {
+    await renderView()
+    expect(mocks.initializeProject).toHaveBeenCalledWith("E:/项目甲")
+
+    mocks.wikiState.project = { id: "project-b", name: "项目乙", path: "F:/项目乙" }
+    await rerenderView()
+
+    expect(mocks.dispose).toHaveBeenCalledTimes(1)
+    expect(mocks.initializeProject).toHaveBeenLastCalledWith("F:/项目乙")
+
+    await act(async () => {
+      root.unmount()
+      await Promise.resolve()
+    })
+    mounted = false
+    expect(mocks.dispose).toHaveBeenCalledTimes(2)
+  })
+
+  it("弹窗通过同名批量入口原样提交完整候选列表,并在成功后关闭", async () => {
+    await renderView()
+    await clickButton("打开导入弹窗")
+    expect(mocks.inputDialogProps.open).toBe(true)
+    expect(mocks.inputDialogProps.onSubmit.name).toBe("handleStartAnalysis")
+
+    await clickButton("提交批量导入")
+
+    expect(mocks.createBatch).toHaveBeenCalledTimes(1)
+    expect(mocks.createBatch.mock.calls[0][0]).toBe(mocks.candidates)
+    expect(mocks.inputDialogProps.open).toBe(false)
+  })
+
+  it("A 旧初始化在 A→B→A 后完成时不能清除新 A 初始化身份", async () => {
+    let finishOldA!: () => void
+    let finishNewA!: () => void
+    mocks.initializeProject
+      .mockReset()
+      .mockImplementationOnce((path: string) => { mocks.importState.projectPath = path; return new Promise<void>((resolve) => { finishOldA = resolve }) })
+      .mockImplementationOnce((path: string) => { mocks.importState.projectPath = path; return Promise.resolve() })
+      .mockImplementationOnce((path: string) => { mocks.importState.projectPath = path; return new Promise<void>((resolve) => { finishNewA = resolve }) })
+
+    await renderView()
+    mocks.wikiState.project = { id: "project-b", name: "项目乙", path: "F:/项目乙" }
+    await rerenderView()
+    mocks.wikiState.project = { id: "project-a-2", name: "项目甲新会话", path: "E:/项目甲" }
+    await rerenderView()
+    mocks.triggerSidebarRefresh.mockClear()
+
+    finishOldA()
+    await flushEffects()
+    mocks.importState.revision = 1
+    await rerenderView()
+    expect(mocks.triggerSidebarRefresh).not.toHaveBeenCalled()
+
+    finishNewA()
+    await flushEffects()
+    mocks.importState.revision = 2
+    await rerenderView()
+    expect(mocks.triggerSidebarRefresh).toHaveBeenCalledTimes(1)
+  })
+  it("revision 按项目建基线且同项目只通过侧栏链 reload 一次", async () => {
+    await renderView(); mocks.reloadLibraryState.mockClear(); mocks.triggerSidebarRefresh.mockClear()
+    mocks.wikiState.project = { id: "project-b", name: "项目乙", path: "F:/项目乙" }
+    mocks.importState.revision = 5
+    await rerenderView()
+    expect(mocks.triggerSidebarRefresh).not.toHaveBeenCalled()
+    mocks.reloadLibraryState.mockClear()
+    mocks.importState.revision = 6
+    await rerenderView()
+    expect(mocks.triggerSidebarRefresh).toHaveBeenCalledTimes(1)
+    await rerenderView()
+    expect(mocks.reloadLibraryState).toHaveBeenCalledTimes(1)
+    expect(host.querySelector('[data-testid="章节选择面板"]')).toBeNull()
+  })
+
+  it("createBatch 失败时弹窗保持打开", async () => {
+    mocks.createBatch.mockRejectedValue(new Error("磁盘不可用")); await renderView(); await clickButton("打开导入弹窗")
+    try { await mocks.inputDialogProps.onSubmit(mocks.candidates) } catch {}
+    expect(mocks.inputDialogProps.open).toBe(true)
+  })
+
+  it("任务面板把每个动作的任务或批次 ID 传给批量 Store", async () => {
+    await renderView()
+
+    await clickButton("收起任务")
+    await clickButton("继续任务")
+    await clickButton("重新生成任务")
+    await clickButton("取消任务")
+    await clickButton("取消批次等待")
+
+    expect(mocks.setPanelCollapsed).toHaveBeenCalledWith(true)
+    expect(mocks.continueTask).toHaveBeenCalledWith("task-continue")
+    expect(mocks.regenerateTask).toHaveBeenCalledWith("task-regenerate")
+    expect(mocks.cancelImportTask).toHaveBeenCalledWith("task-cancel")
+    expect(mocks.cancelAllQueued).toHaveBeenCalledWith("batch-1")
+  })
+
+  it("打开 A 后普通选择 B 不回跳并同步状态", async () => {
+    await renderView()
+    await clickButton("打开导入作品")
+    await clickButton("选择角色")
+    await clickButton("选择普通作品B")
+    await rerenderView()
+    expect(mocks.layoutProps.selectedBookId).toBe("book-2")
+    expect(mocks.oldState.selectedLibraryBookId).toBe("book-2")
+    expect(mocks.setCurrentResult).toHaveBeenLastCalledWith(expect.objectContaining({ bookId: "book-2" }))
+    expect(mocks.layoutProps.selectedCharacterId).toBeNull()
+    expect(mocks.clearRecognition).toHaveBeenCalledTimes(2)
+  })
+
+  it("旧角色、文风、框架和历史结果入口仍可触发", async () => {
+    mocks.oldState.showResultViewer = true
+    await renderView()
+
+    await clickButton("旧角色入口")
+    await clickButton("旧文风入口")
+    await clickButton("旧框架入口")
+
+    expect(mocks.handleLibraryReextractCharacters).toHaveBeenCalledTimes(1)
+    expect(mocks.handleLibraryExtractStyle).toHaveBeenCalledTimes(1)
+    expect(mocks.loadBookStoryFrameworkChapters).toHaveBeenCalledWith(mocks.libraryState.books[0].path)
+    expect(host.querySelector('[data-testid="章节选择面板"]')).not.toBeNull()
+    expect(host.querySelector('[data-testid="历史结果查看器"]')).not.toBeNull()
 
-  it("作品库操作钩子包含库状态加载逻辑", () => {
-    expect(libraryOpsSource).toContain("loadBookAnalysisLibraryState")
-    expect(libraryOpsSource).toContain("reloadLibraryState")
+    await clickButton("关闭历史结果")
+    expect(mocks.setShowResultViewer).toHaveBeenCalledWith(false)
   })
 })

+ 122 - 83
src/components/novel/book-analysis-view.tsx

@@ -1,10 +1,12 @@
-import { useCallback, useEffect, useMemo, useState } from "react"
+import { useCallback, useEffect, useMemo, useRef, useState } from "react"
 import { Button } from "@/components/ui/button"
 import { BookAnalysisInputDialog } from "./book-analysis-input-dialog"
+import { BookAnalysisImportTaskPanel } from "./book-analysis-import-task-panel"
 import { BookAnalysisLibraryLayout } from "./book-analysis-library-layout"
 import { BookAnalysisResultViewer } from "./book-analysis-result-viewer"
 import { ChapterSelectionPanel } from "./chapter-selection-panel"
 import { useBookAnalysisStore } from "@/stores/book-analysis-store"
+import { useBookAnalysisImportStore } from "@/stores/book-analysis-import-store"
 import { useWikiStore } from "@/stores/wiki-store"
 import { resolveDefaultModel } from "@/lib/novel/model-resolver"
 import { streamChat, type ChatMessage } from "@/lib/llm-client"
@@ -32,6 +34,7 @@ import {
 } from "@/lib/novel/book-analysis/story-framework-extraction"
 import { loadPlotFrameworkLibrary, upsertPlotFramework } from "@/lib/novel/plot-framework-library"
 import type { PlotFramework } from "@/lib/novel/plot-framework"
+import type { BatchImportCandidate } from "@/lib/novel/book-analysis/batch-import-types"
 import { OutlineCreatorDialog } from "./outline-editor"
 
 interface StoryFrameworkSelectionData {
@@ -91,6 +94,21 @@ export function BookAnalysisView() {
   const currentResult = useBookAnalysisStore((s) => s.currentResult)
   const showResultViewer = useBookAnalysisStore((s) => s.showResultViewer)
   const setShowResultViewer = useBookAnalysisStore((s) => s.setShowResultViewer)
+  const importBatches = useBookAnalysisImportStore((s) => s.batches)
+  const importTasks = useBookAnalysisImportStore((s) => s.tasks)
+  const importPanelCollapsed = useBookAnalysisImportStore((s) => s.panelCollapsed)
+  const importRevision = useBookAnalysisImportStore((s) => s.revision)
+  const initializeImportProject = useBookAnalysisImportStore((s) => s.initializeProject)
+  const createImportBatch = useBookAnalysisImportStore((s) => s.createBatch)
+  const continueImportTask = useBookAnalysisImportStore((s) => s.continueTask)
+  const regenerateImportTask = useBookAnalysisImportStore((s) => s.regenerateTask)
+  const cancelImportTask = useBookAnalysisImportStore((s) => s.cancelTask)
+  const cancelAllQueuedImportTasks = useBookAnalysisImportStore((s) => s.cancelAllQueued)
+  const setImportPanelCollapsed = useBookAnalysisImportStore((s) => s.setPanelCollapsed)
+  const disposeImportStore = useBookAnalysisImportStore((s) => s.dispose)
+  const importRevisionBaselineRef = useRef({ projectPath: currentProject?.path ?? null, revision: importRevision })
+  const importInitializationSequenceRef = useRef(0)
+  const importInitializationTokenRef = useRef<{ sequence: number; projectPath: string } | null>(null)
 
   // 角色识别 LLM 配置(feature/llm-character-recognizer)
   const baseLlmConfig = useWikiStore((s) => s.llmConfig)
@@ -186,6 +204,49 @@ export function BookAnalysisView() {
     reloadLibraryState,
   })
 
+  useEffect(() => {
+    if (!currentProject?.path) return
+
+    const projectPath = currentProject.path
+    const initializationToken = { sequence: ++importInitializationSequenceRef.current, projectPath }
+    importInitializationTokenRef.current = initializationToken
+    importRevisionBaselineRef.current = { projectPath, revision: useBookAnalysisImportStore.getState().revision }
+    void initializeImportProject(projectPath).then(() => {
+      if (importInitializationTokenRef.current !== initializationToken) return
+      const importState = useBookAnalysisImportStore.getState()
+      if (currentProject.path === projectPath && importState.projectPath === projectPath) {
+        importRevisionBaselineRef.current = { projectPath, revision: importState.revision }
+      }
+    }).catch((error) => {
+      if (useBookAnalysisImportStore.getState().projectPath !== projectPath) return
+      console.error("加载批量导入任务失败", error)
+      toast.error("加载批量导入任务失败,请稍后重试。")
+    }).finally(() => {
+      if (importInitializationTokenRef.current === initializationToken) importInitializationTokenRef.current = null
+    })
+
+    return () => {
+      void disposeImportStore().catch((error) => {
+        console.error("释放批量导入任务失败", error)
+      })
+    }
+  }, [currentProject?.path, initializeImportProject, disposeImportStore])
+
+  useEffect(() => {
+    const projectPath = currentProject?.path ?? null
+    const baseline = importRevisionBaselineRef.current
+    if (!projectPath || baseline.projectPath !== projectPath || importInitializationTokenRef.current?.projectPath === projectPath) {
+      importRevisionBaselineRef.current = { projectPath, revision: importRevision }
+      return
+    }
+    if (importRevision <= baseline.revision) {
+      importRevisionBaselineRef.current = { projectPath, revision: importRevision }
+      return
+    }
+    importRevisionBaselineRef.current = { projectPath, revision: importRevision }
+    useBookAnalysisStore.getState().triggerSidebarRefresh()
+  }, [currentProject?.path, importRevision])
+
   useEffect(() => {
     void reloadLibraryState()
   }, [currentProject?.path, tasks.length, sidebarRefreshCounter, reloadLibraryState])
@@ -234,81 +295,6 @@ export function BookAnalysisView() {
     })
   }, [pendingRecognitionTaskId, tasks, chapterSelectionData, setChapterSelectionData])
 
-  const handleStartAnalysis = async (config: {
-    sourceType: "file"
-    sourcePath: string
-  }) => {
-    if (!currentProject?.path) {
-      console.error("没有打开的项目")
-      return
-    }
-
-    // 新导入:先清空上一本书残留的识别结果(fix:导入 B 仍弹出 A 的角色弹窗)。
-    clearRecognition()
-
-    const abortController = new AbortController()
-
-    const taskId = startTask(currentProject.path, {
-      sourceType: config.sourceType,
-      sourcePath: config.sourcePath,
-      selectedChapters: [],
-    }, abortController)
-
-    setInputDialogOpen(false)
-
-    try {
-      const { splitNovelIntoChapters } = await import("@/lib/novel/book-analysis/analysis-engine")
-      const { useWikiStore } = await import("@/stores/wiki-store")
-      const storeState = useWikiStore.getState()
-      const analysisLlmConfig = resolveDefaultModel(storeState.llmConfig)
-      const updateTaskProgress = useBookAnalysisStore.getState().updateTaskProgress
-      const updateTaskMetadata = useBookAnalysisStore.getState().updateTaskMetadata
-
-      const splitResult = await splitNovelIntoChapters(
-        config.sourcePath,
-        currentProject.path,
-        analysisLlmConfig,
-        (progress) => {
-          updateTaskProgress(taskId, {
-            stage: progress.stage as any,
-            stageLabel: progress.stageLabel,
-            completed: progress.completed,
-            total: progress.total,
-            percentage: progress.percentage,
-            currentItem: progress.currentItem,
-          })
-        },
-        abortController.signal
-      )
-
-      if (splitResult.success) {
-        updateTaskMetadata(taskId, splitResult.metadata)
-
-        useBookAnalysisStore.getState().updateTaskBookData(taskId, splitResult.bookId, splitResult.chapters, splitResult.bookPath)
-
-        useBookAnalysisStore.getState().triggerSidebarRefresh()
-
-        await reloadLibraryState()
-
-        setChapterSelectionData({
-          taskId,
-          bookPath: splitResult.bookPath,
-          chapters: splitResult.chapters,
-          metadata: splitResult.metadata,
-          abortController,
-          selectedChapterIds: [],
-          depth: "standard" as AnalysisDepth,
-        })
-      }
-    } catch (error) {
-      const errorTaskFn = useBookAnalysisStore.getState().errorTask
-      const errorMessage = error instanceof Error ? error.message : "分析失败"
-      if (!errorMessage.includes("取消") && !errorMessage.includes("已停止")) {
-        errorTaskFn(taskId, errorMessage)
-      }
-    }
-  }
-
   const handleChapterSelectionCancel = () => {
     if (chapterSelectionData) {
       cancelTask(chapterSelectionData.taskId)
@@ -457,6 +443,52 @@ export function BookAnalysisView() {
     }
   }
 
+  const handleStartAnalysis = useCallback(async function handleStartAnalysis(files: BatchImportCandidate[]) {
+    await createImportBatch(files)
+    setInputDialogOpen(false)
+  }, [createImportBatch])
+
+  const runImportTaskAction = useCallback((action: () => Promise<void>, failureMessage: string) => {
+    void action().catch((error) => {
+      console.error(failureMessage, error)
+      toast.error(`${failureMessage}:${error instanceof Error ? error.message : String(error)}`)
+    })
+  }, [])
+
+  const handleContinueImportTask = useCallback((taskId: string) => {
+    runImportTaskAction(() => continueImportTask(taskId), "继续导入失败")
+  }, [continueImportTask, runImportTaskAction])
+
+  const handleRegenerateImportTask = useCallback((taskId: string) => {
+    runImportTaskAction(() => regenerateImportTask(taskId), "重新生成失败")
+  }, [regenerateImportTask, runImportTaskAction])
+
+  const handleCancelImportTask = useCallback((taskId: string) => {
+    runImportTaskAction(() => cancelImportTask(taskId), "取消导入失败")
+  }, [cancelImportTask, runImportTaskAction])
+
+  const handleCancelAllQueuedImportTasks = useCallback((batchId: string) => {
+    runImportTaskAction(() => cancelAllQueuedImportTasks(batchId), "取消等待任务失败")
+  }, [cancelAllQueuedImportTasks, runImportTaskAction])
+
+  const handleSelectBook = useCallback((bookId: string) => {
+    const book = libraryState.books.find((item) => item.id === bookId)
+    setSelectedBookId(bookId)
+    setSelectedCharacterId(null)
+    clearRecognition()
+    const analysisStore = useBookAnalysisStore.getState()
+    analysisStore.setSelectedLibraryBookId(bookId)
+    if (book) analysisStore.setCurrentResult(toBookAnalysisResult(book))
+  }, [clearRecognition, libraryState.books])
+
+  const handleOpenImportedBook = useCallback((bookId: string) => {
+    if (!libraryState.books.some((item) => item.id === bookId)) {
+      toast.error("未找到已导入作品,请刷新后重试。")
+      return
+    }
+    handleSelectBook(bookId)
+  }, [handleSelectBook, libraryState.books])
+
   const libraryLayout = (
     <BookAnalysisLibraryLayout
       state={libraryState}
@@ -467,13 +499,20 @@ export function BookAnalysisView() {
       extractingStoryFramework={storyFrameworkExtracting}
       addingToSoul={addingToSoul}
       storyFrameworks={storyFrameworks}
-      onSelectBook={(bookId) => {
-        const book = libraryState.books.find((item) => item.id === bookId)
-        setSelectedBookId(bookId)
-        setSelectedCharacterId(null)
-        clearRecognition()
-        if (book) useBookAnalysisStore.getState().setCurrentResult(toBookAnalysisResult(book))
-      }}
+      importTaskPanel={
+        <BookAnalysisImportTaskPanel
+          batches={importBatches}
+          tasks={importTasks}
+          collapsed={importPanelCollapsed}
+          onCollapsedChange={setImportPanelCollapsed}
+          onContinue={handleContinueImportTask}
+          onRegenerate={handleRegenerateImportTask}
+          onCancel={handleCancelImportTask}
+          onCancelAllQueued={handleCancelAllQueuedImportTasks}
+          onOpenBook={handleOpenImportedBook}
+        />
+      }
+      onSelectBook={handleSelectBook}
       onSelectCharacter={setSelectedCharacterId}
       onImportNovel={() => setInputDialogOpen(true)}
       onExtractStyle={handleLibraryExtractStyle}

+ 54 - 36
src/lib/novel/book-analysis/analysis-engine.ts

@@ -42,6 +42,48 @@ export interface SplitChaptersResult {
   }>
 }
 
+export interface ParsedNovelChapter {
+  title: string
+  content: string
+  order: number
+}
+
+export function parseNovelChapters(content: string): ParsedNovelChapter[] {
+  const chapterRegex = /第[零〇一二三四五六七八九十百千万两0-9]+章[^\n]*/gi
+  const matches = Array.from(content.matchAll(chapterRegex))
+
+  if (matches.length === 0) {
+    throw new Error("未能识别到章节标记,请确保小说文件包含\"第X章\"格式的章节标题")
+  }
+
+  return matches.map((match, index) => {
+    const startIdx = match.index!
+    const endIdx = matches[index + 1]?.index ?? content.length
+    return {
+      title: match[0].trim(),
+      content: content.slice(startIdx, endIdx).trim(),
+      order: index + 1,
+    }
+  })
+}
+
+export function buildChapterMarkdown(
+  bookId: string,
+  chapter: ParsedNovelChapter,
+): string {
+  void bookId
+  const chapterId = `ch-${String(chapter.order).padStart(4, "0")}`
+  return `---
+id: ${chapterId}
+title: ${chapter.title}
+order: ${chapter.order}
+wordCount: ${chapter.content.length}
+---
+
+${chapter.content}
+`
+}
+
 /**
  * 创建拆书分析目录结构
  */
@@ -120,13 +162,7 @@ export async function splitNovelIntoChapters(
   })
 
   // 章节拆分(支持多种格式)
-  const chapterRegex = /第[零〇一二三四五六七八九十百千万两0-9]+章[^\n]*/gi
-  const matches = Array.from(content.matchAll(chapterRegex))
-
-  if (matches.length === 0) {
-    throw new Error("未能识别到章节标记,请确保小说文件包含\"第X章\"格式的章节标题")
-  }
-
+  const parsedChapters = parseNovelChapters(content)
   const chapters: Array<{
     id: string
     title: string
@@ -135,56 +171,38 @@ export async function splitNovelIntoChapters(
     path: string
   }> = []
 
-  const totalChapters = matches.length
+  const totalChapters = parsedChapters.length
   let totalWords = 0
 
-  for (let i = 0; i < matches.length; i++) {
+  for (const chapter of parsedChapters) {
     if (signal?.aborted) {
       throw new Error("用户取消分析")
     }
 
-    const match = matches[i]
-    const title = match[0].trim()
-    const startIdx = match.index!
-    const endIdx = matches[i + 1]?.index ?? content.length
-
-    const chapterContent = content.slice(startIdx, endIdx).trim()
-    const wordCount = chapterContent.length
-    totalWords += wordCount
-
-    const chapterId = `ch-${String(i + 1).padStart(4, "0")}`
+    const chapterId = `ch-${String(chapter.order).padStart(4, "0")}`
     const chapterPath = joinPath(bookPath, "chapters", `${chapterId}.md`)
+    const wordCount = chapter.content.length
+    totalWords += wordCount
 
-    // 生成 markdown 格式
-    const markdown = `---
-id: ${chapterId}
-title: ${title}
-order: ${i + 1}
-wordCount: ${wordCount}
----
-
-${chapterContent}
-`
-
-    await writeFile(chapterPath, markdown)
+    await writeFile(chapterPath, buildChapterMarkdown(bookId, chapter))
 
     chapters.push({
       id: chapterId,
-      title,
-      order: i + 1,
+      title: chapter.title,
+      order: chapter.order,
       wordCount,
       path: chapterPath,
     })
 
     // 更新进度
-    const progress = Math.floor(10 + (i + 1) / totalChapters * 20)
+    const progress = Math.floor(10 + chapter.order / totalChapters * 20)
     onProgress?.({
       stage: "splitting_chapters",
       stageLabel: "拆分章节中",
-      completed: i + 1,
+      completed: chapter.order,
       total: totalChapters,
       percentage: progress,
-      currentItem: title,
+      currentItem: chapter.title,
     })
   }
 

+ 637 - 0
src/lib/novel/book-analysis/batch-import-engine.spec.ts

@@ -0,0 +1,637 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
+import type { BatchImportCheckpoint, BatchImportTask } from "./batch-import-types"
+
+const fsMocks = vi.hoisted(() => ({
+  files: new Map<string, string>(),
+  directories: new Set<string>(),
+  readFile: vi.fn<(path: string) => Promise<string>>(),
+  writeFile: vi.fn<(path: string, contents: string) => Promise<void>>(),
+  writeFileAtomic: vi.fn<(path: string, contents: string) => Promise<void>>(),
+  createDirectory: vi.fn<(path: string) => Promise<void>>(),
+  copyDirectory: vi.fn<(source: string, destination: string) => Promise<string[]>>(),
+  deleteFile: vi.fn<(path: string) => Promise<void>>(),
+  fileExists: vi.fn<(path: string) => Promise<boolean>>(),
+  listDirectory: vi.fn(),
+}))
+
+const libraryMocks = vi.hoisted(() => ({
+  findBookLibraryEntry: vi.fn(),
+  removeBookLibraryEntry: vi.fn(),
+  upsertBookLibraryEntry: vi.fn(),
+}))
+
+const storageMocks = vi.hoisted(() => ({
+  withBatchImportTaskLock: vi.fn(),
+  cleanupCompletedTaskWorkspaceUnlocked: vi.fn(),
+  loadTaskCheckpoint: vi.fn(),
+  saveBatchImportTaskUnlocked: vi.fn(),
+  saveTaskCheckpointUnlocked: vi.fn(),
+}))
+
+vi.mock("@/commands/fs", () => ({
+  readFile: fsMocks.readFile,
+  writeFile: fsMocks.writeFile,
+  writeFileAtomic: fsMocks.writeFileAtomic,
+  createDirectory: fsMocks.createDirectory,
+  copyDirectory: fsMocks.copyDirectory,
+  deleteFile: fsMocks.deleteFile,
+  fileExists: fsMocks.fileExists,
+  listDirectory: fsMocks.listDirectory,
+}))
+
+vi.mock("./library-store", () => ({
+  findBookLibraryEntry: libraryMocks.findBookLibraryEntry,
+  removeBookLibraryEntry: libraryMocks.removeBookLibraryEntry,
+  upsertBookLibraryEntry: libraryMocks.upsertBookLibraryEntry,
+}))
+
+vi.mock("./batch-import-storage", () => ({
+  cleanupCompletedTaskWorkspaceUnlocked: storageMocks.cleanupCompletedTaskWorkspaceUnlocked,
+  importTaskDir: (projectPath: string, taskId: string) => (
+    `${projectPath}/book-analysis/import-tasks/${taskId}`
+  ),
+  withBatchImportTaskLock: storageMocks.withBatchImportTaskLock,
+  loadTaskCheckpoint: storageMocks.loadTaskCheckpoint,
+  saveBatchImportTaskUnlocked: storageMocks.saveBatchImportTaskUnlocked,
+  saveTaskCheckpointUnlocked: storageMocks.saveTaskCheckpointUnlocked,
+}))
+
+import {
+  buildChapterMarkdown,
+  parseNovelChapters,
+  splitNovelIntoChapters,
+} from "./analysis-engine"
+import { hashNormalizedNovel } from "./batch-import-hash"
+import { runBatchImportTask } from "./batch-import-engine"
+
+const SOURCE_CONTENT = [
+  "第一章 起",
+  "正文甲",
+  "第二章 承",
+  "正文乙",
+  "第三章 终",
+  "正文丙",
+].join("\n")
+const PROJECT_PATH = "E:/Novel"
+const TASK_DIR = `${PROJECT_PATH}/book-analysis/import-tasks/task-1`
+const CACHED_SOURCE_PATH = `${TASK_DIR}/source.txt`
+const BOOK_PATH = `${PROJECT_PATH}/book-analysis/book-fixed`
+
+let checkpoint: BatchImportCheckpoint | null
+let task: BatchImportTask
+let sourceSha256: string
+let lockActive: boolean
+let savedTasks: BatchImportTask[]
+let operations: string[]
+let libraryBookIds: Set<string>
+
+function workspaceChapterPath(index: number): string {
+  return `${TASK_DIR}/chapters/ch-${String(index + 1).padStart(4, "0")}.md`
+}
+
+function seedChapter(index: number, contents?: string): void {
+  const chapter = parseNovelChapters(SOURCE_CONTENT)[index]
+  fsMocks.files.set(
+    workspaceChapterPath(index),
+    contents ?? buildChapterMarkdown(task.bookId, chapter),
+  )
+}
+
+function seedCheckpoint(completedChapterIndexes: number[]): void {
+  const chapters = parseNovelChapters(SOURCE_CONTENT)
+  checkpoint = {
+    version: 1,
+    sourceSha256,
+    totalChapters: chapters.length,
+    completedChapterIndexes,
+    totalWords: completedChapterIndexes.reduce(
+      (sum, index) => sum + chapters[index].content.length,
+      0,
+    ),
+    updatedAt: 100,
+  }
+}
+
+afterEach(() => {
+  vi.restoreAllMocks()
+})
+
+beforeEach(async () => {
+  vi.clearAllMocks()
+  fsMocks.files.clear()
+  fsMocks.directories.clear()
+  checkpoint = null
+  lockActive = false
+  savedTasks = []
+  operations = []
+  libraryBookIds = new Set()
+  sourceSha256 = await hashNormalizedNovel(SOURCE_CONTENT)
+  task = {
+    version: 1,
+    id: "task-1",
+    batchId: "batch-1",
+    projectPath: PROJECT_PATH,
+    originalPath: "E:/Sources/长夜.txt",
+    originalFileName: "长夜.txt",
+    cachedSourcePath: CACHED_SOURCE_PATH,
+    sourceSha256,
+    requestedTitle: "长夜",
+    finalTitle: "长夜(2)",
+    bookId: "book-fixed",
+    status: "splitting",
+    completed: 0,
+    total: 0,
+    error: "软件上次关闭时任务尚未完成",
+    skipReason: null,
+    createdAt: 100,
+    startedAt: 110,
+    completedAt: null,
+    updatedAt: 120,
+  }
+  fsMocks.files.set(CACHED_SOURCE_PATH, SOURCE_CONTENT)
+
+  fsMocks.readFile.mockImplementation(async (path) => {
+    if (path === CACHED_SOURCE_PATH && !lockActive) {
+      throw new Error("缓存验证未持有任务锁")
+    }
+    const contents = fsMocks.files.get(path)
+    if (contents === undefined) throw new Error(`文件不存在:${path}`)
+    return contents
+  })
+  fsMocks.writeFile.mockImplementation(async (path, contents) => {
+    operations.push(`write:${path}`)
+    fsMocks.files.set(path, contents)
+  })
+  fsMocks.writeFileAtomic.mockImplementation(async (path, contents) => {
+    if (!lockActive) {
+      throw new Error("批量导入原子写入未持有任务锁")
+    }
+    operations.push(`atomic:${path}`)
+    fsMocks.files.set(path, contents)
+  })
+  fsMocks.createDirectory.mockImplementation(async (path) => {
+    operations.push(`mkdir:${path}`)
+    fsMocks.directories.add(path)
+  })
+  fsMocks.copyDirectory.mockImplementation(async (source, destination) => {
+    operations.push(`copy:${source}->${destination}`)
+    const copied: string[] = []
+    for (const [path, contents] of [...fsMocks.files.entries()]) {
+      if (!path.startsWith(`${source}/`)) continue
+      const target = `${destination}${path.slice(source.length)}`
+      fsMocks.files.set(target, contents)
+      copied.push(target)
+    }
+    return copied
+  })
+  fsMocks.deleteFile.mockImplementation(async (path) => {
+    operations.push(`delete:${path}`)
+    fsMocks.files.delete(path)
+  })
+  fsMocks.fileExists.mockImplementation(async (path) => (
+    fsMocks.files.has(path) || fsMocks.directories.has(path)
+  ))
+  fsMocks.listDirectory.mockResolvedValue([])
+
+  libraryMocks.findBookLibraryEntry.mockResolvedValue(undefined)
+  libraryMocks.removeBookLibraryEntry.mockImplementation(async (
+    _projectPath: string,
+    bookId: string,
+  ) => {
+    operations.push("library:remove")
+    libraryBookIds.delete(bookId)
+  })
+  libraryMocks.upsertBookLibraryEntry.mockImplementation(async (
+    _projectPath: string,
+    entry: { bookId: string },
+  ) => {
+    operations.push("library:upsert")
+    libraryBookIds.add(entry.bookId)
+  })
+  storageMocks.withBatchImportTaskLock.mockImplementation(async (...args: unknown[]) => {
+    const action = args.at(-1)
+    if (typeof action !== "function") throw new Error("任务锁缺少执行函数")
+    lockActive = true
+    try {
+      return await action()
+    } finally {
+      lockActive = false
+    }
+  })
+  storageMocks.cleanupCompletedTaskWorkspaceUnlocked.mockImplementation(async () => {
+    if (!lockActive) throw new Error("完成清理未持有任务锁")
+    operations.push("cleanup:completed-workspace")
+  })
+  storageMocks.loadTaskCheckpoint.mockImplementation(async () => {
+    if (!lockActive) throw new Error("检查点读取未持有任务锁")
+    return checkpoint
+  })
+  storageMocks.saveTaskCheckpointUnlocked.mockImplementation(
+    async (_task: BatchImportTask, next: BatchImportCheckpoint) => {
+      if (!lockActive) throw new Error("检查点保存未持有任务锁")
+      operations.push(`checkpoint:${next.completedChapterIndexes.join(",")}`)
+      checkpoint = structuredClone(next)
+    },
+  )
+  storageMocks.saveBatchImportTaskUnlocked.mockImplementation(
+    async (next: BatchImportTask) => {
+      if (!lockActive) throw new Error("任务保存未持有任务锁")
+      operations.push(`task:${next.status}`)
+      savedTasks.push(structuredClone(next))
+    },
+  )
+})
+
+describe("analysis engine chapter helpers", () => {
+  it("解析章节时保持旧拆分规则、章节顺序和正文内容", () => {
+    const chapters = parseNovelChapters(
+      "作品前言\n第一章 开始\n这是正文一\n第二章 继续\n这是正文二",
+    )
+
+    expect(chapters).toEqual([
+      { title: "第一章 开始", content: "第一章 开始\n这是正文一", order: 1 },
+      { title: "第二章 继续", content: "第二章 继续\n这是正文二", order: 2 },
+    ])
+  })
+
+  it("没有章节标记时保留旧中文错误", () => {
+    expect(() => parseNovelChapters("没有章节标题")).toThrow(
+      "未能识别到章节标记,请确保小说文件包含\"第X章\"格式的章节标题",
+    )
+  })
+
+  it("构建与旧引擎相同的章节 Markdown", () => {
+    expect(buildChapterMarkdown("book-fixed", {
+      title: "第一章 开始",
+      content: "第一章 开始\n正文",
+      order: 1,
+    })).toBe(`---
+id: ch-0001
+title: 第一章 开始
+order: 1
+wordCount: 9
+---
+
+第一章 开始
+正文
+`)
+  })
+
+  it("旧 splitNovelIntoChapters API 仍按原路径和格式写入", async () => {
+    vi.spyOn(Date, "now").mockReturnValue(1234)
+    const sourcePath = "E:/Sources/旧入口.txt"
+    fsMocks.files.set(sourcePath, "第一章 开始\n正文\n第二章 结束\n尾声")
+
+    const result = await splitNovelIntoChapters(
+      sourcePath,
+      PROJECT_PATH,
+      {} as never,
+    )
+
+    expect(result.bookId).toBe("book-1234")
+    expect(result.chapters).toEqual([
+      {
+        id: "ch-0001",
+        title: "第一章 开始",
+        order: 1,
+        wordCount: 9,
+        path: "E:/Novel/book-analysis/book-1234/chapters/ch-0001.md",
+      },
+      {
+        id: "ch-0002",
+        title: "第二章 结束",
+        order: 2,
+        wordCount: 9,
+        path: "E:/Novel/book-analysis/book-1234/chapters/ch-0002.md",
+      },
+    ])
+    expect(fsMocks.writeFile).toHaveBeenCalledWith(
+      "E:/Novel/book-analysis/book-1234/chapters/ch-0001.md",
+      buildChapterMarkdown("book-1234", {
+        title: "第一章 开始",
+        content: "第一章 开始\n正文",
+        order: 1,
+      }),
+    )
+    expect(libraryMocks.upsertBookLibraryEntry).toHaveBeenCalledTimes(1)
+  })
+})
+
+describe("runBatchImportTask", () => {
+  it.each(["completed", "skipped"] as const)(
+    "直接调用 engine 时拒绝终态 %s 且不写正式目录",
+    async (status) => {
+      task = { ...task, status }
+
+      await expect(runBatchImportTask(task, {
+        signal: new AbortController().signal,
+      })).rejects.toThrow("批量导入任务状态不允许执行")
+
+      expect(fsMocks.createDirectory).not.toHaveBeenCalled()
+      expect(fsMocks.copyDirectory).not.toHaveBeenCalled()
+      expect(fsMocks.writeFileAtomic).not.toHaveBeenCalled()
+      expect(libraryMocks.upsertBookLibraryEntry).not.toHaveBeenCalled()
+      expect(storageMocks.saveBatchImportTaskUnlocked).not.toHaveBeenCalled()
+    },
+  )
+
+  it("继续时跳过已验证章节并从首个缺失章节写入", async () => {
+    seedCheckpoint([0, 1])
+    seedChapter(0)
+    seedChapter(1)
+
+    const result = await runBatchImportTask(task, {
+      signal: new AbortController().signal,
+    })
+
+    const workspaceWrites = fsMocks.writeFileAtomic.mock.calls
+      .map(([path]) => path)
+      .filter((path) => path.startsWith(`${TASK_DIR}/chapters/`))
+    expect(workspaceWrites).toEqual([workspaceChapterPath(2)])
+    expect(storageMocks.withBatchImportTaskLock).toHaveBeenCalledWith(
+      PROJECT_PATH,
+      "task-1",
+      expect.any(Function),
+    )
+    expect(result.chapters).toHaveLength(3)
+    expect(checkpoint?.completedChapterIndexes).toEqual([0, 1, 2])
+  })
+
+  it.each([
+    ["非空正文被篡改", (markdown: string) => markdown.replace("正文乙", "正文被篡改")],
+    ["title 损坏", (markdown: string) => markdown.replace("title: 第二章 承", "title: 损坏")],
+    ["wordCount 损坏", (markdown: string) => markdown.replace(/wordCount: \d+/, "wordCount: 999")],
+  ])("已完成章节%s时从该章开始重写后续章节", async (_label, damage) => {
+    seedCheckpoint([0, 1, 2])
+    seedChapter(0)
+    const secondChapter = parseNovelChapters(SOURCE_CONTENT)[1]
+    seedChapter(1, damage(buildChapterMarkdown(task.bookId, secondChapter)))
+    seedChapter(2)
+
+    await runBatchImportTask(task, {
+      signal: new AbortController().signal,
+    })
+
+    const workspaceWrites = fsMocks.writeFileAtomic.mock.calls
+      .map(([path]) => path)
+      .filter((path) => path.startsWith(`${TASK_DIR}/chapters/`))
+    expect(workspaceWrites).toEqual([
+      workspaceChapterPath(1),
+      workspaceChapterPath(2),
+    ])
+  })
+
+  it("checkpoint JSON 损坏时警告并从第一章重来", async () => {
+    seedCheckpoint([0, 1, 2])
+    seedChapter(0)
+    seedChapter(1)
+    seedChapter(2)
+    const warn = vi.spyOn(console, "warn").mockImplementation(() => undefined)
+    storageMocks.loadTaskCheckpoint.mockRejectedValueOnce(new SyntaxError("JSON 格式错误"))
+
+    await runBatchImportTask(task, {
+      signal: new AbortController().signal,
+    })
+
+    const workspaceWrites = fsMocks.writeFileAtomic.mock.calls
+      .map(([path]) => path)
+      .filter((path) => path.startsWith(`${TASK_DIR}/chapters/`))
+    expect(workspaceWrites).toEqual([
+      workspaceChapterPath(0),
+      workspaceChapterPath(1),
+      workspaceChapterPath(2),
+    ])
+    expect(warn).toHaveBeenCalledWith(
+      expect.stringContaining("checkpoint 数据损坏"),
+      expect.any(SyntaxError),
+    )
+  })
+
+  it("checkpoint 真实读取错误继续向上抛出", async () => {
+    storageMocks.loadTaskCheckpoint.mockRejectedValueOnce(new Error("磁盘读取失败"))
+
+    await expect(runBatchImportTask(task, {
+      signal: new AbortController().signal,
+    })).rejects.toThrow("磁盘读取失败")
+
+    expect(fsMocks.writeFileAtomic).not.toHaveBeenCalled()
+  })
+
+  it("运行中取消会抛中文错误且不写 metadata", async () => {
+    const controller = new AbortController()
+
+    await expect(runBatchImportTask(task, {
+      signal: controller.signal,
+      onProgress: (completed) => {
+        if (completed === 1) controller.abort()
+      },
+    })).rejects.toThrow("用户取消导入")
+
+    expect(fsMocks.writeFileAtomic.mock.calls.some(
+      ([path]) => path.endsWith("/metadata.json"),
+    )).toBe(false)
+    expect(libraryMocks.upsertBookLibraryEntry).not.toHaveBeenCalled()
+    expect(savedTasks.some((saved) => saved.status === "completed")).toBe(false)
+  })
+
+  it("metadata 写入期间发生 abort 仍完成提交", async () => {
+    const controller = new AbortController()
+    const writeAtomic = fsMocks.writeFileAtomic.getMockImplementation()!
+    fsMocks.writeFileAtomic.mockImplementation(async (path, contents) => {
+      await writeAtomic(path, contents)
+      if (path === `${BOOK_PATH}/metadata.json`) controller.abort()
+    })
+
+    const result = await runBatchImportTask(task, { signal: controller.signal })
+
+    expect(controller.signal.aborted).toBe(true)
+    expect(result.task.status).toBe("completed")
+    expect(savedTasks.at(-1)?.status).toBe("completed")
+    expect(libraryBookIds.has(task.bookId)).toBe(true)
+  })
+
+  it("library 写入期间发生 abort 仍完成提交", async () => {
+    const controller = new AbortController()
+    libraryMocks.upsertBookLibraryEntry.mockImplementationOnce(async (
+      _projectPath: string,
+      entry: { bookId: string },
+    ) => {
+      operations.push("library:upsert")
+      libraryBookIds.add(entry.bookId)
+      controller.abort()
+    })
+
+    const result = await runBatchImportTask(task, { signal: controller.signal })
+
+    expect(controller.signal.aborted).toBe(true)
+    expect(result.task.status).toBe("completed")
+    expect(savedTasks.at(-1)?.status).toBe("completed")
+    expect(libraryBookIds.has(task.bookId)).toBe(true)
+  })
+  it("缓存 source 的 SHA-256 不匹配时停止且不发布", async () => {
+    task = { ...task, sourceSha256: "0".repeat(64) }
+
+    await expect(runBatchImportTask(task, {
+      signal: new AbortController().signal,
+    })).rejects.toThrow("缓存源文件校验失败")
+
+    expect(fsMocks.writeFileAtomic).not.toHaveBeenCalled()
+    expect(libraryMocks.upsertBookLibraryEntry).not.toHaveBeenCalled()
+  })
+
+  it("正式发布先复制章节和原子写 source,metadata 最后写入,再更新索引和任务", async () => {
+    vi.spyOn(Date, "now").mockReturnValue(999)
+
+    const result = await runBatchImportTask(task, {
+      signal: new AbortController().signal,
+    })
+
+    const copyIndex = operations.indexOf(`copy:${TASK_DIR}/chapters->${BOOK_PATH}/chapters`)
+    const sourceIndex = operations.indexOf(`atomic:${BOOK_PATH}/source.txt`)
+    const metadataIndex = operations.indexOf(`atomic:${BOOK_PATH}/metadata.json`)
+    const libraryIndex = operations.indexOf("library:upsert")
+    const taskIndex = operations.indexOf("task:completed")
+    expect(copyIndex).toBeGreaterThanOrEqual(0)
+    expect(sourceIndex).toBeGreaterThan(copyIndex)
+    expect(metadataIndex).toBeGreaterThan(sourceIndex)
+    expect(libraryIndex).toBeGreaterThan(metadataIndex)
+    expect(taskIndex).toBeGreaterThan(libraryIndex)
+    expect(result.metadata).toEqual(expect.objectContaining({
+      title: "长夜(2)",
+      totalChapters: 3,
+      createdAt: 100,
+      updatedAt: 999,
+    }))
+  })
+
+  it("作品索引保存完整 SHA 且 sourcePath 指向正式 source", async () => {
+    await runBatchImportTask(task, {
+      signal: new AbortController().signal,
+    })
+
+    expect(libraryMocks.upsertBookLibraryEntry).toHaveBeenCalledWith(
+      PROJECT_PATH,
+      expect.objectContaining({
+        bookId: "book-fixed",
+        title: "长夜(2)",
+        sourcePath: `${BOOK_PATH}/source.txt`,
+        contentSha256: sourceSha256,
+        status: "completed",
+      }),
+    )
+    expect(savedTasks.at(-1)).toEqual(expect.objectContaining({
+      status: "completed",
+      cachedSourcePath: `${BOOK_PATH}/source.txt`,
+      completed: 3,
+      total: 3,
+    }))
+  })
+
+  it("索引更新失败时回滚 metadata 和索引且不把任务标记为完成", async () => {
+    libraryMocks.upsertBookLibraryEntry.mockRejectedValueOnce(new Error("索引保存失败"))
+
+    await expect(runBatchImportTask(task, {
+      signal: new AbortController().signal,
+    })).rejects.toThrow("索引保存失败")
+
+    expect(fsMocks.files.has(`${BOOK_PATH}/metadata.json`)).toBe(false)
+    expect(libraryMocks.removeBookLibraryEntry).toHaveBeenCalledWith(PROJECT_PATH, task.bookId)
+    expect(libraryBookIds.has(task.bookId)).toBe(false)
+    expect(savedTasks.some((saved) => saved.status === "completed")).toBe(false)
+    expect(fsMocks.files.has(`${BOOK_PATH}/source.txt`)).toBe(true)
+    expect(fsMocks.files.has(`${BOOK_PATH}/chapters/ch-0001.md`)).toBe(true)
+  })
+
+  it("metadata 写入失败时回滚可见标志但保留正式章节和 source", async () => {
+    const writeAtomic = fsMocks.writeFileAtomic.getMockImplementation()!
+    fsMocks.writeFileAtomic.mockImplementation(async (path, contents) => {
+      await writeAtomic(path, contents)
+      if (path === `${BOOK_PATH}/metadata.json`) throw new Error("metadata 保存失败")
+    })
+
+    await expect(runBatchImportTask(task, {
+      signal: new AbortController().signal,
+    })).rejects.toThrow("metadata 保存失败")
+
+    expect(fsMocks.files.has(`${BOOK_PATH}/metadata.json`)).toBe(false)
+    expect(libraryMocks.removeBookLibraryEntry).toHaveBeenCalledWith(PROJECT_PATH, task.bookId)
+    expect(fsMocks.files.has(`${BOOK_PATH}/source.txt`)).toBe(true)
+    expect(fsMocks.files.has(`${BOOK_PATH}/chapters/ch-0001.md`)).toBe(true)
+  })
+
+  it("library 成功但 completed 任务保存失败时回滚可见提交点", async () => {
+    storageMocks.saveBatchImportTaskUnlocked.mockRejectedValueOnce(new Error("任务状态保存失败"))
+
+    await expect(runBatchImportTask(task, {
+      signal: new AbortController().signal,
+    })).rejects.toThrow("任务状态保存失败")
+
+    expect(fsMocks.files.has(`${BOOK_PATH}/metadata.json`)).toBe(false)
+    expect(libraryBookIds.has(task.bookId)).toBe(false)
+    expect(libraryMocks.removeBookLibraryEntry).toHaveBeenCalledWith(PROJECT_PATH, task.bookId)
+    expect(savedTasks.some((saved) => saved.status === "completed")).toBe(false)
+    expect(fsMocks.files.has(`${BOOK_PATH}/source.txt`)).toBe(true)
+    expect(fsMocks.files.has(`${BOOK_PATH}/chapters/ch-0001.md`)).toBe(true)
+  })
+
+  it("提交回滚失败时仅警告并保留原始提交错误", async () => {
+    const warn = vi.spyOn(console, "warn").mockImplementation(() => undefined)
+    storageMocks.saveBatchImportTaskUnlocked.mockRejectedValueOnce(new Error("原始任务保存失败"))
+    libraryMocks.removeBookLibraryEntry.mockRejectedValueOnce(new Error("索引回滚失败"))
+    fsMocks.deleteFile.mockRejectedValueOnce(new Error("metadata 回滚失败"))
+
+    await expect(runBatchImportTask(task, {
+      signal: new AbortController().signal,
+    })).rejects.toThrow("原始任务保存失败")
+
+    expect(warn).toHaveBeenCalledWith(
+      expect.stringContaining("回滚作品索引失败"),
+      expect.any(Error),
+    )
+    expect(warn).toHaveBeenCalledWith(
+      expect.stringContaining("回滚 metadata 失败"),
+      expect.any(Error),
+    )
+  })
+
+  it("完成持久化成功后清理工作区并保留原文副本", async () => {
+    const result = await runBatchImportTask(task, {
+      signal: new AbortController().signal,
+    })
+
+    expect(result.task.status).toBe("completed")
+    expect(operations.indexOf("cleanup:completed-workspace")).toBeGreaterThan(
+      operations.indexOf("task:completed"),
+    )
+    expect(storageMocks.cleanupCompletedTaskWorkspaceUnlocked).toHaveBeenCalledWith(result.task)
+    expect(fsMocks.deleteFile).not.toHaveBeenCalledWith(CACHED_SOURCE_PATH)
+    expect(fsMocks.files.get(CACHED_SOURCE_PATH)).toBe(SOURCE_CONTENT)
+  })
+
+  it("完成后清理失败仅中文警告且不降级 completed", async () => {
+    const warn = vi.spyOn(console, "warn").mockImplementation(() => undefined)
+    storageMocks.cleanupCompletedTaskWorkspaceUnlocked.mockRejectedValueOnce(new Error("目录占用"))
+
+    const result = await runBatchImportTask(task, {
+      signal: new AbortController().signal,
+    })
+
+    expect(result.task.status).toBe("completed")
+    expect(savedTasks.at(-1)?.status).toBe("completed")
+    expect(warn).toHaveBeenCalledWith(
+      "批量导入:清理已完成任务工作区失败",
+      expect.any(Error),
+    )
+  })
+
+  it("任务完成持久化失败时不清理并保留章节断点", async () => {
+    storageMocks.saveBatchImportTaskUnlocked.mockRejectedValueOnce(new Error("任务状态保存失败"))
+
+    await expect(runBatchImportTask(task, {
+      signal: new AbortController().signal,
+    })).rejects.toThrow("任务状态保存失败")
+
+    expect(storageMocks.cleanupCompletedTaskWorkspaceUnlocked).not.toHaveBeenCalled()
+    expect(checkpoint?.completedChapterIndexes).toEqual([0, 1, 2])
+  })
+})

+ 288 - 0
src/lib/novel/book-analysis/batch-import-engine.ts

@@ -0,0 +1,288 @@
+import {
+  copyDirectory,
+  createDirectory,
+  deleteFile,
+  fileExists,
+  readFile,
+  writeFileAtomic,
+} from "@/commands/fs"
+import { fingerprintFileSample } from "./content-fingerprint"
+import { joinPath, normalizePath } from "@/lib/path-utils"
+import {
+  buildChapterMarkdown,
+  parseNovelChapters,
+  type ParsedNovelChapter,
+} from "./analysis-engine"
+import { hashNormalizedNovel, normalizeNovelForHash } from "./batch-import-hash"
+import {
+  cleanupCompletedTaskWorkspaceUnlocked,
+  importTaskDir,
+  loadTaskCheckpoint,
+  saveBatchImportTaskUnlocked,
+  saveTaskCheckpointUnlocked,
+  withBatchImportTaskLock,
+} from "./batch-import-storage"
+import type {
+  BatchImportCheckpoint,
+  BatchImportTask,
+} from "./batch-import-types"
+import { removeBookLibraryEntry, upsertBookLibraryEntry } from "./library-store"
+import type { BookAnalysisMetadata } from "./types"
+
+export interface ChapterSummary {
+  id: string
+  title: string
+  order: number
+  wordCount: number
+  path: string
+}
+
+export interface RunBatchImportTaskOptions {
+  signal: AbortSignal
+  onProgress?: (completed: number, total: number, currentTitle: string) => void
+}
+
+function throwIfCancelled(signal: AbortSignal): void {
+  if (signal.aborted) throw new Error("用户取消导入")
+}
+
+function assertRunnableTask(task: BatchImportTask): void {
+  if (
+    task.version !== 1
+    || !task.id
+    || !task.projectPath
+    || !task.cachedSourcePath
+    || !task.sourceSha256
+    || !task.bookId
+    || !(task.finalTitle || task.requestedTitle)
+  ) {
+    throw new Error("批量导入任务数据无效")
+  }
+  if (task.status !== "splitting") {
+    throw new Error("批量导入任务状态不允许执行")
+  }
+}
+
+function chapterId(chapter: ParsedNovelChapter): string {
+  return `ch-${String(chapter.order).padStart(4, "0")}`
+}
+
+function chapterFilePath(chaptersPath: string, chapter: ParsedNovelChapter): string {
+  return joinPath(chaptersPath, `${chapterId(chapter)}.md`)
+}
+
+function isValidChapterFile(
+  contents: string,
+  bookId: string,
+  chapter: ParsedNovelChapter,
+): boolean {
+  return normalizeNovelForHash(contents) === normalizeNovelForHash(
+    buildChapterMarkdown(bookId, chapter),
+  )
+}
+
+async function findResumeIndex(
+  task: BatchImportTask,
+  checkpoint: BatchImportCheckpoint | null,
+  chapters: ParsedNovelChapter[],
+  chaptersPath: string,
+): Promise<number> {
+  if (
+    !checkpoint
+    || checkpoint.version !== 1
+    || checkpoint.sourceSha256 !== task.sourceSha256
+    || checkpoint.totalChapters !== chapters.length
+    || !Array.isArray(checkpoint.completedChapterIndexes)
+  ) {
+    return 0
+  }
+
+  const completed = new Set(checkpoint.completedChapterIndexes)
+  for (let index = 0; index < chapters.length; index += 1) {
+    if (!completed.has(index)) return index
+
+    const path = chapterFilePath(chaptersPath, chapters[index])
+    if (!(await fileExists(path))) return index
+    try {
+      if (!isValidChapterFile(await readFile(path), task.bookId, chapters[index])) return index
+    } catch {
+      return index
+    }
+  }
+  return chapters.length
+}
+
+async function rollbackPublishedCommit(
+  projectPath: string,
+  bookId: string,
+  metadataPath: string,
+): Promise<void> {
+  try {
+    await removeBookLibraryEntry(projectPath, bookId)
+  } catch (error) {
+    console.warn("批量导入:回滚作品索引失败", error)
+  }
+
+  try {
+    if (await fileExists(metadataPath)) await deleteFile(metadataPath)
+  } catch (error) {
+    console.warn("批量导入:回滚 metadata 失败", error)
+  }
+}
+function buildChapterSummaries(
+  chapters: ParsedNovelChapter[],
+  chaptersPath: string,
+): ChapterSummary[] {
+  return chapters.map((chapter) => ({
+    id: chapterId(chapter),
+    title: chapter.title,
+    order: chapter.order,
+    wordCount: chapter.content.length,
+    path: chapterFilePath(chaptersPath, chapter),
+  }))
+}
+
+export async function runBatchImportTask(
+  task: BatchImportTask,
+  options: RunBatchImportTaskOptions,
+): Promise<{
+  task: BatchImportTask
+  metadata: BookAnalysisMetadata
+  chapters: ChapterSummary[]
+}> {
+  return withBatchImportTaskLock(task.projectPath, task.id, async () => {
+    assertRunnableTask(task)
+    throwIfCancelled(options.signal)
+
+    const sourceContent = await readFile(task.cachedSourcePath)
+    throwIfCancelled(options.signal)
+    const sourceSha256 = await hashNormalizedNovel(sourceContent)
+    if (sourceSha256 !== task.sourceSha256) {
+      throw new Error("缓存源文件校验失败:文件内容与任务记录不一致")
+    }
+
+    const parsedChapters = parseNovelChapters(sourceContent)
+    let checkpoint: BatchImportCheckpoint | null
+    try {
+      checkpoint = await loadTaskCheckpoint(task)
+    } catch (error) {
+      if (!(error instanceof SyntaxError)) throw error
+      console.warn("批量导入:checkpoint 数据损坏,将从第一章重新开始", error)
+      checkpoint = null
+    }
+    const taskDirectory = importTaskDir(task.projectPath, task.id)
+    const taskChaptersPath = joinPath(taskDirectory, "chapters")
+    const resumeIndex = await findResumeIndex(
+      task,
+      checkpoint,
+      parsedChapters,
+      taskChaptersPath,
+    )
+    let totalWords = parsedChapters
+      .slice(0, resumeIndex)
+      .reduce((sum, chapter) => sum + chapter.content.length, 0)
+    const completedChapterIndexes = Array.from(
+      { length: resumeIndex },
+      (_, index) => index,
+    )
+
+    for (let index = resumeIndex; index < parsedChapters.length; index += 1) {
+      throwIfCancelled(options.signal)
+      const chapter = parsedChapters[index]
+      await writeFileAtomic(
+        chapterFilePath(taskChaptersPath, chapter),
+        buildChapterMarkdown(task.bookId, chapter),
+      )
+      throwIfCancelled(options.signal)
+
+      totalWords += chapter.content.length
+      completedChapterIndexes.push(index)
+      await saveTaskCheckpointUnlocked(task, {
+        version: 1,
+        sourceSha256,
+        totalChapters: parsedChapters.length,
+        completedChapterIndexes: [...completedChapterIndexes],
+        totalWords,
+        updatedAt: Date.now(),
+      })
+      options.onProgress?.(index + 1, parsedChapters.length, chapter.title)
+    }
+
+    throwIfCancelled(options.signal)
+    const bookPath = normalizePath(joinPath(
+      task.projectPath,
+      "book-analysis",
+      task.bookId,
+    ))
+    const publishedChaptersPath = joinPath(bookPath, "chapters")
+    const publishedSourcePath = joinPath(bookPath, "source.txt")
+    await createDirectory(normalizePath(joinPath(task.projectPath, "book-analysis")))
+    await createDirectory(bookPath)
+    await createDirectory(publishedChaptersPath)
+    await createDirectory(joinPath(bookPath, "characters"))
+    await createDirectory(joinPath(bookPath, "skills"))
+
+    throwIfCancelled(options.signal)
+    await copyDirectory(taskChaptersPath, publishedChaptersPath)
+    throwIfCancelled(options.signal)
+    await writeFileAtomic(publishedSourcePath, sourceContent)
+    const now = Date.now()
+    const metadataPath = joinPath(bookPath, "metadata.json")
+    const metadata: BookAnalysisMetadata = {
+      title: task.finalTitle ?? task.requestedTitle,
+      author: undefined,
+      totalChapters: parsedChapters.length,
+      totalWords,
+      sourceType: "file",
+      createdAt: task.createdAt,
+      updatedAt: now,
+    }
+    const completedTask: BatchImportTask = {
+      ...task,
+      cachedSourcePath: publishedSourcePath,
+      status: "completed",
+      completed: parsedChapters.length,
+      total: parsedChapters.length,
+      error: null,
+      skipReason: null,
+      completedAt: now,
+      updatedAt: now,
+    }
+
+    // metadata 是正式发布的可见提交点;通过此安全点后必须完成或回滚提交。
+    throwIfCancelled(options.signal)
+    try {
+      await writeFileAtomic(metadataPath, JSON.stringify(metadata, null, 2))
+      await upsertBookLibraryEntry(task.projectPath, {
+        bookId: task.bookId,
+        sourcePath: publishedSourcePath,
+        contentHash: fingerprintFileSample(sourceContent),
+        contentSha256: sourceSha256,
+        title: metadata.title,
+        totalChapters: parsedChapters.length,
+        totalWords,
+        charactersCount: 0,
+        skillsCount: 0,
+        status: "completed",
+        createdAt: task.createdAt,
+        updatedAt: now,
+      })
+      await saveBatchImportTaskUnlocked(completedTask)
+    } catch (error) {
+      await rollbackPublishedCommit(task.projectPath, task.bookId, metadataPath)
+      throw error
+    }
+
+    try {
+      await cleanupCompletedTaskWorkspaceUnlocked(completedTask)
+    } catch (error) {
+      console.warn("批量导入:清理已完成任务工作区失败", error)
+    }
+
+    return {
+      task: completedTask,
+      metadata,
+      chapters: buildChapterSummaries(parsedChapters, publishedChaptersPath),
+    }
+  })
+}

+ 33 - 0
src/lib/novel/book-analysis/batch-import-hash.spec.ts

@@ -0,0 +1,33 @@
+import { describe, expect, it } from "vitest"
+import {
+  hashNormalizedNovel,
+  normalizeNovelForHash,
+  reserveUniqueTitle,
+} from "./batch-import-hash"
+
+describe("batch import hash", () => {
+  it("移除开头 BOM 并将 CRLF、CR 统一为 LF", () => {
+    expect(normalizeNovelForHash("\ufeff第一章\r\n正文\r结尾")).toBe("第一章\n正文\n结尾")
+  })
+
+  it("忽略 BOM 和换行差异但比较完整正文", async () => {
+    expect(await hashNormalizedNovel("\ufeff第一章\r\n正文")).toBe(
+      await hashNormalizedNovel("第一章\n正文"),
+    )
+    expect(await hashNormalizedNovel("第一章\n正文A")).not.toBe(
+      await hashNormalizedNovel("第一章\n正文B"),
+    )
+  })
+
+  it("使用完整文本计算 SHA-256", async () => {
+    expect(await hashNormalizedNovel("abc")).toBe(
+      "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad",
+    )
+  })
+
+  it("为同名不同内容分配稳定编号并立即保留名称", () => {
+    const reserved = new Set(["长夜", "长夜(2)"])
+    expect(reserveUniqueTitle("长夜", reserved)).toBe("长夜(3)")
+    expect(reserved.has("长夜(3)")).toBe(true)
+  })
+})

+ 27 - 0
src/lib/novel/book-analysis/batch-import-hash.ts

@@ -0,0 +1,27 @@
+export function normalizeNovelForHash(content: string): string {
+  return content.replace(/^\uFEFF/, "").replace(/\r\n?/g, "\n")
+}
+
+export async function hashNormalizedNovel(content: string): Promise<string> {
+  const bytes = new TextEncoder().encode(normalizeNovelForHash(content))
+  const digest = await crypto.subtle.digest("SHA-256", bytes)
+  return Array.from(
+    new Uint8Array(digest),
+    (byte) => byte.toString(16).padStart(2, "0"),
+  ).join("")
+}
+
+export function reserveUniqueTitle(base: string, reserved: Set<string>): string {
+  if (!reserved.has(base)) {
+    reserved.add(base)
+    return base
+  }
+
+  for (let index = 2; ; index += 1) {
+    const candidate = `${base}(${index})`
+    if (!reserved.has(candidate)) {
+      reserved.add(candidate)
+      return candidate
+    }
+  }
+}

+ 645 - 0
src/lib/novel/book-analysis/batch-import-scheduler.spec.ts

@@ -0,0 +1,645 @@
+import { beforeEach, describe, expect, it, vi } from "vitest"
+import type { BatchImportTask } from "./batch-import-types"
+
+const mocks = vi.hoisted(() => ({
+  runBatchImportTask: vi.fn(),
+  saveBatchImportTask: vi.fn<(task: BatchImportTask) => Promise<void>>(),
+  resetBatchImportTask: vi.fn<(projectPath: string, taskId: string) => Promise<BatchImportTask>>(),
+}))
+
+vi.mock("./batch-import-engine", () => ({
+  runBatchImportTask: mocks.runBatchImportTask,
+}))
+
+vi.mock("./batch-import-storage", () => ({
+  saveBatchImportTask: mocks.saveBatchImportTask,
+  resetBatchImportTask: mocks.resetBatchImportTask,
+}))
+
+import { createBatchImportScheduler } from "./batch-import-scheduler"
+
+const PROJECT_PATH = "E:/Novel"
+
+function makeTask(id: string, overrides: Partial<BatchImportTask> = {}): BatchImportTask {
+  return {
+    version: 1,
+    id,
+    batchId: "batch-1",
+    projectPath: PROJECT_PATH,
+    originalPath: `E:/Sources/${id}.txt`,
+    originalFileName: `${id}.txt`,
+    cachedSourcePath: `${PROJECT_PATH}/book-analysis/import-tasks/${id}/source.txt`,
+    sourceSha256: `sha-${id}`,
+    requestedTitle: id,
+    finalTitle: null,
+    bookId: `book-${id}`,
+    status: "queued",
+    completed: 0,
+    total: 0,
+    error: null,
+    skipReason: null,
+    createdAt: 100,
+    startedAt: null,
+    completedAt: null,
+    updatedAt: 100,
+    ...overrides,
+  }
+}
+
+function completed(task: BatchImportTask): BatchImportTask {
+  return {
+    ...task,
+    status: "completed",
+    completedAt: Date.now(),
+    updatedAt: Date.now(),
+  }
+}
+
+function success(task: BatchImportTask) {
+  return {
+    task: completed(task),
+    metadata: {},
+    chapters: [],
+  }
+}
+
+function deferred<T>() {
+  let resolve!: (value: T) => void
+  let reject!: (reason?: unknown) => void
+  const promise = new Promise<T>((resolvePromise, rejectPromise) => {
+    resolve = resolvePromise
+    reject = rejectPromise
+  })
+  return { promise, resolve, reject }
+}
+
+async function waitFor(predicate: () => boolean): Promise<void> {
+  for (let index = 0; index < 100; index += 1) {
+    if (predicate()) return
+    await new Promise((resolve) => setTimeout(resolve, 0))
+  }
+  throw new Error("等待调度器状态更新超时")
+}
+
+function latestTasks(scheduler: ReturnType<typeof createBatchImportScheduler>) {
+  let tasks: BatchImportTask[] = []
+  scheduler.subscribe((nextTasks) => {
+    tasks = nextTasks
+  })
+  return () => tasks
+}
+
+describe("createBatchImportScheduler", () => {
+  beforeEach(() => {
+    vi.clearAllMocks()
+    mocks.saveBatchImportTask.mockResolvedValue(undefined)
+  })
+
+  it("默认并发数为两个任务", async () => {
+    const gates = new Map<string, ReturnType<typeof deferred<void>>>()
+    mocks.runBatchImportTask.mockImplementation(async (task: BatchImportTask) => {
+      const gate = deferred<void>()
+      gates.set(task.id, gate)
+      await gate.promise
+      return success(task)
+    })
+    const scheduler = createBatchImportScheduler({ projectPath: PROJECT_PATH })
+    scheduler.enqueue([makeTask("task-1"), makeTask("task-2"), makeTask("task-3")])
+
+    await waitFor(() => mocks.runBatchImportTask.mock.calls.length === 2)
+    expect(mocks.runBatchImportTask.mock.calls.map(([task]) => task.id)).toEqual(["task-1", "task-2"])
+    gates.get("task-1")?.resolve()
+    await waitFor(() => mocks.runBatchImportTask.mock.calls.length === 3)
+    gates.get("task-2")?.resolve()
+    gates.get("task-3")?.resolve()
+  })
+
+  it.each([Number.NaN, Number.POSITIVE_INFINITY])(
+    "非有限并发值 %s 回退为默认并发 2",
+    async (invalidConcurrency) => {
+      const gate = deferred<void>()
+      mocks.runBatchImportTask.mockImplementation(async (task: BatchImportTask) => {
+        await gate.promise
+        return success(task)
+      })
+      const scheduler = createBatchImportScheduler({
+        projectPath: PROJECT_PATH,
+        concurrency: invalidConcurrency,
+      })
+      scheduler.enqueue([makeTask("task-1"), makeTask("task-2"), makeTask("task-3")])
+
+      await waitFor(() => mocks.runBatchImportTask.mock.calls.length === 2)
+      expect(mocks.runBatchImportTask).toHaveBeenCalledTimes(2)
+      gate.resolve()
+    },
+  )
+
+  it("持久化完成前不通知新状态,完成后通知与持久化一致的状态", async () => {
+    const saveGate = deferred<void>()
+    const runGate = deferred<ReturnType<typeof success>>()
+    mocks.saveBatchImportTask
+      .mockReturnValueOnce(saveGate.promise)
+      .mockResolvedValue(undefined)
+    mocks.runBatchImportTask.mockReturnValue(runGate.promise)
+    const scheduler = createBatchImportScheduler({ projectPath: PROJECT_PATH })
+    const listener = vi.fn()
+    scheduler.subscribe(listener)
+    scheduler.enqueue([makeTask("task-1")])
+
+    await waitFor(() => mocks.saveBatchImportTask.mock.calls.length === 1)
+    const persistedRunningTask = mocks.saveBatchImportTask.mock.calls[0][0]
+    expect(persistedRunningTask.status).toBe("splitting")
+    expect(listener).toHaveBeenCalledTimes(1)
+    expect(listener.mock.calls[0][0]).toEqual([])
+    expect(mocks.runBatchImportTask).not.toHaveBeenCalled()
+
+    saveGate.resolve()
+    await waitFor(() => listener.mock.calls.length === 2)
+    expect(listener.mock.calls[1][0]).toEqual([persistedRunningTask])
+    expect(mocks.runBatchImportTask).toHaveBeenCalledWith(
+      persistedRunningTask,
+      expect.objectContaining({ signal: expect.any(AbortSignal) }),
+    )
+    runGate.resolve(success(persistedRunningTask))
+    await waitFor(() => listener.mock.calls.some(([tasks]) => (
+      tasks.some((task: BatchImportTask) => task.status === "completed")
+    )))
+  })
+
+  it("重复 enqueue 同一数组、等待任务和运行任务都只运行一次", async () => {
+    const gates = new Map<string, ReturnType<typeof deferred<void>>>()
+    mocks.runBatchImportTask.mockImplementation(async (task: BatchImportTask) => {
+      const gate = deferred<void>()
+      gates.set(task.id, gate)
+      await gate.promise
+      return success(task)
+    })
+    const scheduler = createBatchImportScheduler({ projectPath: PROJECT_PATH, concurrency: 1 })
+    const getTasks = latestTasks(scheduler)
+    const task1 = makeTask("task-1")
+    const task2 = makeTask("task-2")
+    const batch = [task1, task2]
+
+    scheduler.enqueue(batch)
+    scheduler.enqueue(batch)
+    scheduler.enqueue([task1, task2])
+    await waitFor(() => mocks.runBatchImportTask.mock.calls.length === 1)
+    scheduler.enqueue(batch)
+    scheduler.enqueue([task1])
+    expect(mocks.runBatchImportTask.mock.calls.map(([task]) => task.id)).toEqual(["task-1"])
+
+    gates.get("task-1")?.resolve()
+    await waitFor(() => mocks.runBatchImportTask.mock.calls.length === 2)
+    scheduler.enqueue(batch)
+    scheduler.enqueue([task2])
+    expect(mocks.runBatchImportTask.mock.calls.map(([task]) => task.id)).toEqual(["task-1", "task-2"])
+    gates.get("task-2")?.resolve()
+    await waitFor(() => getTasks().filter((task) => task.status === "completed").length === 2)
+    expect(mocks.runBatchImportTask).toHaveBeenCalledTimes(2)
+  })
+  it("传入超过上限的并发数时仍最多同时运行两个任务", async () => {
+    let active = 0
+    let maxActive = 0
+    const gates = new Map<string, ReturnType<typeof deferred<void>>>()
+    mocks.runBatchImportTask.mockImplementation(async (task: BatchImportTask) => {
+      active += 1
+      maxActive = Math.max(maxActive, active)
+      const gate = deferred<void>()
+      gates.set(task.id, gate)
+      await gate.promise
+      active -= 1
+      return success(task)
+    })
+    const scheduler = createBatchImportScheduler({ projectPath: PROJECT_PATH, concurrency: 99 })
+    const getTasks = latestTasks(scheduler)
+    scheduler.enqueue(Array.from({ length: 5 }, (_, index) => makeTask(`task-${index + 1}`)))
+
+    await waitFor(() => mocks.runBatchImportTask.mock.calls.length === 2)
+    expect(maxActive).toBe(2)
+    gates.get("task-1")?.resolve()
+    await waitFor(() => mocks.runBatchImportTask.mock.calls.length === 3)
+    gates.get("task-2")?.resolve()
+    gates.get("task-3")?.resolve()
+    await waitFor(() => mocks.runBatchImportTask.mock.calls.length === 5)
+    gates.get("task-4")?.resolve()
+    gates.get("task-5")?.resolve()
+    await waitFor(() => getTasks().every((task) => task.status === "completed"))
+    expect(maxActive).toBe(2)
+  })
+
+  it("引擎已提交 completed 后不二次持久化且订阅异常不影响内存状态", async () => {
+    mocks.saveBatchImportTask.mockImplementation(async (task: BatchImportTask) => {
+      if (task.status === "completed") throw new Error("不允许二次保存 completed")
+    })
+    mocks.runBatchImportTask.mockImplementation(async (task: BatchImportTask) => success(task))
+    const scheduler = createBatchImportScheduler({ projectPath: PROJECT_PATH })
+    const healthySnapshots: BatchImportTask[][] = []
+    scheduler.subscribe((tasks) => {
+      if (tasks.some((task) => task.status === "completed")) {
+        throw new Error("订阅者渲染失败")
+      }
+    })
+    scheduler.subscribe((tasks) => {
+      healthySnapshots.push(tasks)
+    })
+    scheduler.enqueue([makeTask("task-1")])
+
+    await waitFor(() => (
+      healthySnapshots.some((tasks) => tasks.some((task) => task.status === "completed"))
+      || mocks.saveBatchImportTask.mock.calls.some(([task]) => task.status === "failed")
+    ))
+
+    expect(healthySnapshots.at(-1)?.find((task) => task.id === "task-1")?.status).toBe("completed")
+    expect(mocks.saveBatchImportTask.mock.calls.map(([task]) => task.status)).toEqual(["splitting"])
+    expect(mocks.saveBatchImportTask.mock.calls.some(([task]) => (
+      task.status === "completed" || task.status === "failed" || task.status === "cancelled"
+    ))).toBe(false)
+  })
+  it("按 FIFO 顺序启动等待任务", async () => {
+    const started: string[] = []
+    const gates = new Map<string, ReturnType<typeof deferred<void>>>()
+    mocks.runBatchImportTask.mockImplementation(async (task: BatchImportTask) => {
+      started.push(task.id)
+      const gate = deferred<void>()
+      gates.set(task.id, gate)
+      await gate.promise
+      return success(task)
+    })
+    const scheduler = createBatchImportScheduler({ projectPath: PROJECT_PATH })
+    scheduler.enqueue([makeTask("task-1"), makeTask("task-2"), makeTask("task-3"), makeTask("task-4")])
+
+    await waitFor(() => started.length === 2)
+    expect(started).toEqual(["task-1", "task-2"])
+    gates.get("task-2")?.resolve()
+    await waitFor(() => started.length === 3)
+    expect(started).toEqual(["task-1", "task-2", "task-3"])
+    gates.get("task-1")?.resolve()
+    await waitFor(() => started.length === 4)
+    expect(started).toEqual(["task-1", "task-2", "task-3", "task-4"])
+    gates.get("task-3")?.resolve()
+    gates.get("task-4")?.resolve()
+  })
+
+  it("单本失败后标记中文错误并继续下一个任务", async () => {
+    mocks.runBatchImportTask
+      .mockRejectedValueOnce(new Error("章节识别失败"))
+      .mockImplementation(async (task: BatchImportTask) => success(task))
+    const scheduler = createBatchImportScheduler({ projectPath: PROJECT_PATH, concurrency: 1 })
+    const getTasks = latestTasks(scheduler)
+    scheduler.enqueue([makeTask("task-1"), makeTask("task-2")])
+
+    await waitFor(() => getTasks().find((task) => task.id === "task-2")?.status === "completed")
+    expect(mocks.runBatchImportTask).toHaveBeenCalledTimes(2)
+    expect(getTasks().find((task) => task.id === "task-1")).toMatchObject({
+      status: "failed",
+      error: "章节识别失败",
+    })
+  })
+
+  it("重复点击继续只运行一次", async () => {
+    const gate = deferred<void>()
+    mocks.runBatchImportTask.mockImplementation(async (task: BatchImportTask) => {
+      await gate.promise
+      return success(task)
+    })
+    const scheduler = createBatchImportScheduler({ projectPath: PROJECT_PATH })
+    scheduler.enqueue([makeTask("task-1", { status: "interrupted" })])
+
+    const first = scheduler.continueTask("task-1")
+    const second = scheduler.continueTask("task-1")
+    await waitFor(() => mocks.runBatchImportTask.mock.calls.length === 1)
+    expect(mocks.runBatchImportTask).toHaveBeenCalledTimes(1)
+    gate.resolve()
+    await Promise.all([first, second])
+  })
+
+  it("引擎在 Abort 后抛出取消错误时持久化 cancelled", async () => {
+    let engineExited = false
+    mocks.runBatchImportTask.mockImplementation((_task: BatchImportTask, options: { signal: AbortSignal }) => (
+      new Promise((_resolve, reject) => {
+        options.signal.addEventListener("abort", () => {
+          engineExited = true
+          reject(new Error("用户取消导入"))
+        }, { once: true })
+      })
+    ))
+    const scheduler = createBatchImportScheduler({ projectPath: PROJECT_PATH })
+    const getTasks = latestTasks(scheduler)
+    scheduler.enqueue([makeTask("task-1")])
+    await waitFor(() => mocks.runBatchImportTask.mock.calls.length === 1)
+
+    await scheduler.cancelTask("task-1")
+
+    expect(engineExited).toBe(true)
+    expect(getTasks().find((task) => task.id === "task-1")?.status).toBe("cancelled")
+    const lastSaved = mocks.saveBatchImportTask.mock.calls.at(-1)?.[0]
+    expect(lastSaved?.status).toBe("cancelled")
+  })
+
+  it("取消单个等待任务后不会运行它", async () => {
+    const gate = deferred<void>()
+    mocks.runBatchImportTask.mockImplementation(async (task: BatchImportTask) => {
+      await gate.promise
+      return success(task)
+    })
+    const scheduler = createBatchImportScheduler({ projectPath: PROJECT_PATH, concurrency: 1 })
+    const getTasks = latestTasks(scheduler)
+    scheduler.enqueue([makeTask("task-1"), makeTask("task-2")])
+    await waitFor(() => mocks.runBatchImportTask.mock.calls.length === 1)
+
+    await scheduler.cancelTask("task-2")
+    gate.resolve()
+    await waitFor(() => getTasks().find((task) => task.id === "task-1")?.status === "completed")
+    expect(mocks.runBatchImportTask.mock.calls.map(([task]) => task.id)).toEqual(["task-1"])
+    expect(getTasks().find((task) => task.id === "task-2")?.status).toBe("cancelled")
+  })
+
+  it("取消批次等待任务时不 Abort 同批次运行任务", async () => {
+    const gate = deferred<void>()
+    let runningSignal: AbortSignal | undefined
+    mocks.runBatchImportTask.mockImplementation(async (task: BatchImportTask, options: { signal: AbortSignal }) => {
+      runningSignal = options.signal
+      await gate.promise
+      return success(task)
+    })
+    const scheduler = createBatchImportScheduler({ projectPath: PROJECT_PATH, concurrency: 1 })
+    const getTasks = latestTasks(scheduler)
+    scheduler.enqueue([
+      makeTask("running", { batchId: "batch-a" }),
+      makeTask("cancel-me", { batchId: "batch-a" }),
+      makeTask("keep-me", { batchId: "batch-b" }),
+    ])
+    await waitFor(() => mocks.runBatchImportTask.mock.calls.length === 1)
+
+    await scheduler.cancelAllQueued("batch-a")
+
+    expect(runningSignal?.aborted).toBe(false)
+    expect(getTasks().find((task) => task.id === "running")?.status).toBe("splitting")
+    expect(getTasks().find((task) => task.id === "cancel-me")?.status).toBe("cancelled")
+    expect(getTasks().find((task) => task.id === "keep-me")?.status).toBe("queued")
+    gate.resolve()
+  })
+
+  it("Abort 后引擎 resolve completed 时以引擎提交结果为准", async () => {
+    mocks.runBatchImportTask.mockImplementation((task: BatchImportTask, options: { signal: AbortSignal }) => (
+      new Promise((resolve) => {
+        options.signal.addEventListener("abort", () => resolve(success(task)), { once: true })
+      })
+    ))
+    const scheduler = createBatchImportScheduler({ projectPath: PROJECT_PATH })
+    const getTasks = latestTasks(scheduler)
+    scheduler.enqueue([makeTask("task-1")])
+    await waitFor(() => mocks.runBatchImportTask.mock.calls.length === 1)
+
+    await scheduler.cancelTask("task-1")
+
+    expect(getTasks().find((task) => task.id === "task-1")?.status).toBe("completed")
+    expect(mocks.saveBatchImportTask.mock.calls.map(([task]) => task.status)).toEqual(["splitting"])
+    expect(mocks.saveBatchImportTask.mock.calls.some(([task]) => (
+      task.status === "completed" || task.status === "cancelled" || task.status === "failed"
+    ))).toBe(false)
+  })
+
+  it("Abort 后引擎 resolve 非 completed 结果时持久化 cancelled", async () => {
+    mocks.runBatchImportTask.mockImplementation((task: BatchImportTask, options: { signal: AbortSignal }) => (
+      new Promise((resolve) => {
+        options.signal.addEventListener("abort", () => resolve({
+          ...success(task),
+          task: { ...task, status: "splitting" },
+        }), { once: true })
+      })
+    ))
+    const scheduler = createBatchImportScheduler({ projectPath: PROJECT_PATH })
+    const getTasks = latestTasks(scheduler)
+    scheduler.enqueue([makeTask("task-1")])
+    await waitFor(() => mocks.runBatchImportTask.mock.calls.length === 1)
+
+    await scheduler.cancelTask("task-1")
+
+    expect(getTasks().find((task) => task.id === "task-1")?.status).toBe("cancelled")
+    expect(mocks.saveBatchImportTask.mock.calls.at(-1)?.[0].status).toBe("cancelled")
+  })
+  it.each(["completed", "skipped", "failed", "cancelled", "splitting", "copying"] as const)(
+    "continueTask 仅允许 interrupted,拒绝 %s",
+    async (status) => {
+      const scheduler = createBatchImportScheduler({ projectPath: PROJECT_PATH })
+      scheduler.enqueue([makeTask("task-1", { status })])
+      await scheduler.continueTask("task-1")
+      expect(mocks.saveBatchImportTask).not.toHaveBeenCalled()
+      expect(mocks.runBatchImportTask).not.toHaveBeenCalled()
+    },
+  )
+
+  it.each(["completed", "skipped"] as const)("regenerateTask 拒绝终态 %s", async (status) => {
+    const scheduler = createBatchImportScheduler({ projectPath: PROJECT_PATH })
+    scheduler.enqueue([makeTask("task-1", { status })])
+    await scheduler.regenerateTask("task-1")
+    expect(mocks.resetBatchImportTask).not.toHaveBeenCalled()
+    expect(mocks.saveBatchImportTask).not.toHaveBeenCalled()
+    expect(mocks.runBatchImportTask).not.toHaveBeenCalled()
+  })
+
+  it("continueTask 持久化 queued 期间取消时保留取消意图且不启动引擎", async () => {
+    const queuedSave = deferred<void>()
+    mocks.saveBatchImportTask.mockImplementation((task: BatchImportTask) => (
+      task.status === "queued" ? queuedSave.promise : Promise.resolve()
+    ))
+    const scheduler = createBatchImportScheduler({ projectPath: PROJECT_PATH })
+    const getTasks = latestTasks(scheduler)
+    scheduler.enqueue([makeTask("task-1", { status: "interrupted" })])
+    const continuing = scheduler.continueTask("task-1")
+    await waitFor(() => mocks.saveBatchImportTask.mock.calls.some(([task]) => task.status === "queued"))
+    const cancelling = scheduler.cancelTask("task-1")
+    queuedSave.resolve()
+    await Promise.all([continuing, cancelling])
+    expect(getTasks().find((task) => task.id === "task-1")?.status).toBe("cancelled")
+    expect(mocks.saveBatchImportTask.mock.calls.at(-1)?.[0].status).toBe("cancelled")
+    expect(mocks.runBatchImportTask).not.toHaveBeenCalled()
+  })
+
+  it("显式取消的 cancelled 持久化失败会向调用方 reject", async () => {
+    const runningGate = deferred<void>()
+    mocks.runBatchImportTask.mockImplementationOnce(async (task: BatchImportTask) => {
+      await runningGate.promise
+      return success(task)
+    })
+    mocks.saveBatchImportTask.mockImplementation((task: BatchImportTask) => (
+      task.id === "task-2" && task.status === "cancelled"
+        ? Promise.reject(new Error("取消状态写盘失败"))
+        : Promise.resolve()
+    ))
+    const scheduler = createBatchImportScheduler({ projectPath: PROJECT_PATH, concurrency: 1 })
+    scheduler.enqueue([makeTask("task-1"), makeTask("task-2")])
+    await waitFor(() => mocks.runBatchImportTask.mock.calls.length === 1)
+    await expect(scheduler.cancelTask("task-2")).rejects.toThrow("取消状态写盘失败")
+    runningGate.resolve()
+  })
+  it("await dispose 后 cancelTask 和 cancelAllQueued 不再保存旧项目状态", async () => {
+    mocks.runBatchImportTask.mockImplementation((task: BatchImportTask, options: { signal: AbortSignal }) => (
+      new Promise((resolve) => options.signal.addEventListener("abort", () => resolve(success(task)), { once: true }))
+    ))
+    const scheduler = createBatchImportScheduler({ projectPath: PROJECT_PATH, concurrency: 1 })
+    scheduler.enqueue([makeTask("task-1"), makeTask("task-2")])
+    await waitFor(() => mocks.runBatchImportTask.mock.calls.length === 1)
+    await scheduler.dispose()
+    const savesBeforeCancel = mocks.saveBatchImportTask.mock.calls.length
+
+    await scheduler.cancelTask("task-2")
+    await scheduler.cancelAllQueued("batch-1")
+
+    expect(mocks.saveBatchImportTask).toHaveBeenCalledTimes(savesBeforeCancel)
+  })
+
+  it("dispose 进行中时 cancelTask 和 cancelAllQueued 不新增状态写入", async () => {
+    const resetGate = deferred<BatchImportTask>()
+    mocks.resetBatchImportTask.mockReturnValue(resetGate.promise)
+    mocks.runBatchImportTask.mockImplementation((task: BatchImportTask, options: { signal: AbortSignal }) => (
+      new Promise((resolve) => options.signal.addEventListener("abort", () => resolve(success(task)), { once: true }))
+    ))
+    const scheduler = createBatchImportScheduler({ projectPath: PROJECT_PATH, concurrency: 1 })
+    scheduler.enqueue([
+      makeTask("task-1"),
+      makeTask("task-2"),
+      makeTask("task-3", { status: "failed" }),
+    ])
+    await waitFor(() => mocks.runBatchImportTask.mock.calls.length === 1)
+    const regenerating = scheduler.regenerateTask("task-3")
+    await waitFor(() => mocks.resetBatchImportTask.mock.calls.length === 1)
+    const disposing = scheduler.dispose()
+    const savesBeforeCancel = mocks.saveBatchImportTask.mock.calls.length
+
+    await scheduler.cancelTask("task-2")
+    await scheduler.cancelAllQueued("batch-1")
+
+    expect(mocks.saveBatchImportTask).toHaveBeenCalledTimes(savesBeforeCancel)
+    resetGate.resolve(makeTask("task-3"))
+    await Promise.all([regenerating, disposing])
+  })
+  it("dispose 等待 regenerate 的 deferred reset 完成", async () => {
+    const resetGate = deferred<BatchImportTask>()
+    mocks.resetBatchImportTask.mockReturnValue(resetGate.promise)
+    const scheduler = createBatchImportScheduler({ projectPath: PROJECT_PATH })
+    scheduler.enqueue([makeTask("task-1", { status: "failed" })])
+    const regenerating = scheduler.regenerateTask("task-1")
+    await waitFor(() => mocks.resetBatchImportTask.mock.calls.length === 1)
+
+    let disposed = false
+    const disposing = scheduler.dispose().then(() => { disposed = true })
+    await new Promise((resolve) => setTimeout(resolve, 0))
+    expect(disposed).toBe(false)
+    resetGate.resolve(makeTask("task-1"))
+    await Promise.all([regenerating, disposing])
+    expect(disposed).toBe(true)
+  })
+
+  it.each(["cancelTask", "cancelAllQueued"] as const)("dispose 等待 %s 的 queued cancelled 保存完成", async (action) => {
+    const cancelSaveGate = deferred<void>()
+    mocks.runBatchImportTask.mockImplementation((task: BatchImportTask, options: { signal: AbortSignal }) => (
+      new Promise((resolve) => options.signal.addEventListener("abort", () => resolve(success(task)), { once: true }))
+    ))
+    mocks.saveBatchImportTask.mockImplementation((task: BatchImportTask) => (
+      task.id === "task-2" && task.status === "cancelled" ? cancelSaveGate.promise : Promise.resolve()
+    ))
+    const scheduler = createBatchImportScheduler({ projectPath: PROJECT_PATH, concurrency: 1 })
+    scheduler.enqueue([makeTask("task-1"), makeTask("task-2")])
+    await waitFor(() => mocks.runBatchImportTask.mock.calls.length === 1)
+    const mutation = action === "cancelTask"
+      ? scheduler.cancelTask("task-2")
+      : scheduler.cancelAllQueued("batch-1")
+    await waitFor(() => mocks.saveBatchImportTask.mock.calls.some(([task]) => task.id === "task-2" && task.status === "cancelled"))
+
+    let disposed = false
+    const disposing = scheduler.dispose().then(() => { disposed = true })
+    await new Promise((resolve) => setTimeout(resolve, 0))
+    expect(disposed).toBe(false)
+    cancelSaveGate.resolve()
+    await Promise.all([mutation, disposing])
+    expect(disposed).toBe(true)
+  })
+
+  it("后台 failed 终态保存失败时通知 interrupted 中文错误并停止运行", async () => {
+    const consoleError = vi.spyOn(console, "error").mockImplementation(() => undefined)
+    mocks.runBatchImportTask.mockRejectedValue(new Error("引擎失败"))
+    mocks.saveBatchImportTask.mockImplementation((task: BatchImportTask) => (
+      task.status === "failed" ? Promise.reject(new Error("失败状态写盘失败")) : Promise.resolve()
+    ))
+    const scheduler = createBatchImportScheduler({ projectPath: PROJECT_PATH })
+    const getTasks = latestTasks(scheduler)
+    scheduler.enqueue([makeTask("task-1")])
+
+    await waitFor(() => getTasks().some((task) => task.status === "interrupted"))
+    const task = getTasks().find((item) => item.id === "task-1")
+    expect(task).toMatchObject({
+      status: "interrupted",
+      error: "任务失败状态保存失败,请重新打开项目后继续",
+    })
+    expect(mocks.runBatchImportTask).toHaveBeenCalledTimes(1)
+    await new Promise((resolve) => setTimeout(resolve, 0))
+    expect(mocks.runBatchImportTask).toHaveBeenCalledTimes(1)
+    expect(consoleError).toHaveBeenCalledWith("批量导入:任务终态持久化失败", expect.any(Error))
+  })
+  it("重新生成只重置并重新排入当前任务", async () => {
+    const original = makeTask("task-1", { status: "failed", error: "旧错误" })
+    const reset = makeTask("task-1")
+    mocks.resetBatchImportTask.mockResolvedValue(reset)
+    mocks.runBatchImportTask.mockImplementation(async (task: BatchImportTask) => success(task))
+    const scheduler = createBatchImportScheduler({ projectPath: PROJECT_PATH })
+    const getTasks = latestTasks(scheduler)
+    scheduler.enqueue([original, makeTask("task-2", { status: "failed" })])
+
+    await scheduler.regenerateTask("task-1")
+    await waitFor(() => getTasks().find((task) => task.id === "task-1")?.status === "completed")
+
+    expect(mocks.resetBatchImportTask).toHaveBeenCalledWith(PROJECT_PATH, "task-1")
+    expect(mocks.runBatchImportTask.mock.calls.map(([task]) => task.id)).toEqual(["task-1"])
+    expect(getTasks().find((task) => task.id === "task-2")?.status).toBe("failed")
+  })
+
+  it("dispose 后引擎 resolve completed 时保留提交结果且取消订阅", async () => {
+    let signal: AbortSignal | undefined
+    let engineResolved = false
+    mocks.runBatchImportTask.mockImplementation((task: BatchImportTask, options: { signal: AbortSignal }) => {
+      signal = options.signal
+      return new Promise((resolve) => {
+        options.signal.addEventListener("abort", () => {
+          engineResolved = true
+          resolve(success(task))
+        }, { once: true })
+      })
+    })
+    const scheduler = createBatchImportScheduler({ projectPath: PROJECT_PATH })
+    const listener = vi.fn()
+    scheduler.subscribe(listener)
+    scheduler.enqueue([makeTask("task-1")])
+    await waitFor(() => mocks.runBatchImportTask.mock.calls.length === 1)
+    const callsBeforeDispose = listener.mock.calls.length
+
+    scheduler.dispose()
+    await waitFor(() => engineResolved)
+    await new Promise((resolve) => setTimeout(resolve, 0))
+
+    expect(signal?.aborted).toBe(true)
+    expect(listener).toHaveBeenCalledTimes(callsBeforeDispose)
+    expect(mocks.saveBatchImportTask.mock.calls.map(([task]) => task.status)).toEqual(["splitting"])
+    expect(mocks.saveBatchImportTask.mock.calls.some(([task]) => (
+      task.status === "completed" || task.status === "cancelled" || task.status === "failed"
+    ))).toBe(false)
+  })
+
+  it("dispose 后引擎抛出取消错误时持久化 cancelled", async () => {
+    mocks.runBatchImportTask.mockImplementation((_task: BatchImportTask, options: { signal: AbortSignal }) => (
+      new Promise((_resolve, reject) => {
+        options.signal.addEventListener("abort", () => reject(new Error("用户取消导入")), { once: true })
+      })
+    ))
+    const scheduler = createBatchImportScheduler({ projectPath: PROJECT_PATH })
+    scheduler.enqueue([makeTask("task-1")])
+    await waitFor(() => mocks.runBatchImportTask.mock.calls.length === 1)
+
+    scheduler.dispose()
+    await waitFor(() => mocks.saveBatchImportTask.mock.calls.some(([task]) => task.status === "cancelled"))
+
+    expect(mocks.saveBatchImportTask.mock.calls.at(-1)?.[0].status).toBe("cancelled")
+    expect(mocks.saveBatchImportTask.mock.calls.some(([task]) => task.status === "completed")).toBe(false)
+  })
+})

+ 351 - 0
src/lib/novel/book-analysis/batch-import-scheduler.ts

@@ -0,0 +1,351 @@
+import { runBatchImportTask } from "./batch-import-engine"
+import {
+  resetBatchImportTask,
+  saveBatchImportTask,
+} from "./batch-import-storage"
+import type { BatchImportTask } from "./batch-import-types"
+
+export interface BatchImportScheduler {
+  enqueue(tasks: BatchImportTask[]): void
+  continueTask(taskId: string): Promise<void>
+  regenerateTask(taskId: string): Promise<void>
+  cancelTask(taskId: string): Promise<void>
+  cancelAllQueued(batchId: string): Promise<void>
+  subscribe(listener: (tasks: BatchImportTask[]) => void): () => void
+  dispose(): Promise<void>
+}
+
+export function createBatchImportScheduler(options: {
+  projectPath: string
+  concurrency?: number
+}): BatchImportScheduler {
+  const requestedConcurrency = options.concurrency ?? 2
+  const concurrency = Number.isFinite(requestedConcurrency)
+    ? Math.max(1, Math.min(2, Math.floor(requestedConcurrency)))
+    : 2
+  const tasks = new Map<string, BatchImportTask>()
+  const queue: string[] = []
+  const queuedIds = new Set<string>()
+  const runningIds = new Set<string>()
+  const controllers = new Map<string, AbortController>()
+  const runPromises = new Map<string, Promise<void>>()
+  const continuePromises = new Map<string, Promise<void>>()
+  const cancelRequestedIds = new Set<string>()
+  const mutationPromises = new Set<Promise<void>>()
+  const listeners = new Set<(tasks: BatchImportTask[]) => void>()
+  let disposed = false
+
+  function snapshot(): BatchImportTask[] {
+    return Array.from(tasks.values(), (task) => ({ ...task }))
+  }
+
+  function notify(): void {
+    if (disposed) return
+    const nextTasks = snapshot()
+    for (const listener of listeners) {
+      try {
+        listener(nextTasks)
+      } catch (error) {
+        console.error("批量导入:任务状态订阅回调执行失败", error)
+      }
+    }
+  }
+
+  function updateAndNotify(task: BatchImportTask): void {
+    tasks.set(task.id, task)
+    notify()
+  }
+
+  async function persistAndNotify(task: BatchImportTask): Promise<void> {
+    await saveBatchImportTask(task)
+    updateAndNotify(task)
+  }
+
+  function removeFromQueue(taskId: string): void {
+    queuedIds.delete(taskId)
+    for (let index = queue.length - 1; index >= 0; index -= 1) {
+      if (queue[index] === taskId) queue.splice(index, 1)
+    }
+  }
+
+  function queueTask(taskId: string): void {
+    const task = tasks.get(taskId)
+    if (
+      disposed
+      || !task
+      || task.status !== "queued"
+      || runningIds.has(taskId)
+      || queuedIds.has(taskId)
+    ) {
+      return
+    }
+    queuedIds.add(taskId)
+    queue.push(taskId)
+  }
+
+  function failureMessage(error: unknown): string {
+    if (error instanceof Error && error.message.trim()) return error.message
+    return "批量导入任务失败"
+  }
+
+  async function executeTask(
+    queuedTask: BatchImportTask,
+    controller: AbortController,
+  ): Promise<void> {
+    const persistRunError = async (
+      runningTask: BatchImportTask,
+      error: unknown,
+    ): Promise<void> => {
+      const currentTask = tasks.get(queuedTask.id) ?? runningTask
+      const finishedAt = Date.now()
+      const failedTask: BatchImportTask = controller.signal.aborted || disposed
+        ? {
+            ...currentTask,
+            status: "cancelled",
+            error: null,
+            completedAt: finishedAt,
+            updatedAt: finishedAt,
+          }
+        : {
+            ...currentTask,
+            status: "failed",
+            error: failureMessage(error),
+            completedAt: finishedAt,
+            updatedAt: finishedAt,
+          }
+      try {
+        await persistAndNotify(failedTask)
+      } catch (persistError) {
+        updateAndNotify({
+          ...failedTask,
+          status: "interrupted",
+          error: "任务失败状态保存失败,请重新打开项目后继续",
+          completedAt: null,
+          updatedAt: Date.now(),
+        })
+        throw persistError
+      }
+    }
+
+    try {
+      const now = Date.now()
+      const runningTask: BatchImportTask = {
+        ...queuedTask,
+        status: "splitting",
+        error: null,
+        completedAt: null,
+        startedAt: queuedTask.startedAt ?? now,
+        updatedAt: now,
+      }
+      try {
+        await persistAndNotify(runningTask)
+      } catch (error) {
+        await persistRunError(runningTask, error)
+        return
+      }
+
+      let result: Awaited<ReturnType<typeof runBatchImportTask>>
+      try {
+        result = await runBatchImportTask(runningTask, {
+          signal: controller.signal,
+        })
+      } catch (error) {
+        await persistRunError(runningTask, error)
+        return
+      }
+
+      // 真实契约:引擎 resolve completed 前已完成正式发布和持久化;scheduler 只能同步内存,绝不能二次落盘。
+      if (result.task.status === "completed") {
+        updateAndNotify(result.task)
+      } else if (controller.signal.aborted || disposed) {
+        const cancelledAt = Date.now()
+        await persistAndNotify({
+          ...runningTask,
+          status: "cancelled",
+          error: null,
+          completedAt: cancelledAt,
+          updatedAt: cancelledAt,
+        })
+      } else {
+        await persistAndNotify(result.task)
+      }
+    } finally {
+      runningIds.delete(queuedTask.id)
+      controllers.delete(queuedTask.id)
+      runPromises.delete(queuedTask.id)
+      pump()
+    }
+  }
+  function pump(): void {
+    if (disposed) return
+    while (runningIds.size < concurrency && queue.length > 0) {
+      const taskId = queue.shift()
+      if (!taskId) continue
+      queuedIds.delete(taskId)
+      const task = tasks.get(taskId)
+      if (!task || task.status !== "queued" || runningIds.has(taskId)) continue
+
+      const controller = new AbortController()
+      runningIds.add(taskId)
+      controllers.set(taskId, controller)
+      const runPromise = executeTask(task, controller)
+      runPromises.set(taskId, runPromise)
+      void runPromise.catch((error) => {
+        console.error("批量导入:任务终态持久化失败", error)
+      })
+    }
+  }
+
+  function enqueue(nextTasks: BatchImportTask[]): void {
+    if (disposed) return
+    for (const task of nextTasks) {
+      if (tasks.has(task.id)) continue
+      tasks.set(task.id, task)
+      if (task.status === "queued") queueTask(task.id)
+    }
+    pump()
+  }
+
+  function continueTask(taskId: string): Promise<void> {
+    if (disposed) return Promise.resolve()
+    const continuing = continuePromises.get(taskId)
+    if (continuing) return continuing
+    const task = tasks.get(taskId)
+    if (!task || task.status !== "interrupted" || runningIds.has(taskId)) return Promise.resolve()
+
+    const operation = (async () => {
+      try {
+        const now = Date.now()
+        await persistAndNotify({
+          ...task,
+          status: "queued",
+          error: null,
+          completedAt: null,
+          updatedAt: now,
+        })
+        if (cancelRequestedIds.delete(taskId)) {
+          const cancelledAt = Date.now()
+          await persistAndNotify({
+            ...(tasks.get(taskId) ?? task),
+            status: "cancelled",
+            error: null,
+            completedAt: cancelledAt,
+            updatedAt: cancelledAt,
+          })
+          return
+        }
+        queueTask(taskId)
+        pump()
+      } finally {
+        continuePromises.delete(taskId)
+        cancelRequestedIds.delete(taskId)
+      }
+    })()
+    continuePromises.set(taskId, operation)
+    return operation
+  }
+
+  async function cancelTask(taskId: string): Promise<void> {
+    const continuing = continuePromises.get(taskId)
+    if (continuing) {
+      cancelRequestedIds.add(taskId)
+      await continuing
+      return
+    }
+    const task = tasks.get(taskId)
+    if (!task) return
+
+    const controller = controllers.get(taskId)
+    if (controller) {
+      controller.abort()
+      await runPromises.get(taskId)
+      return
+    }
+    if (task.status !== "queued") return
+
+    removeFromQueue(taskId)
+    const now = Date.now()
+    await persistAndNotify({
+      ...task,
+      status: "cancelled",
+      error: null,
+      completedAt: now,
+      updatedAt: now,
+    })
+  }
+
+  async function regenerateTask(taskId: string): Promise<void> {
+    if (disposed) return
+    const task = tasks.get(taskId)
+    if (!task || !["interrupted", "failed", "cancelled"].includes(task.status)) return
+
+    removeFromQueue(taskId)
+    const resetTask = await resetBatchImportTask(options.projectPath, taskId)
+    if (disposed) return
+    await persistAndNotify(resetTask)
+    queueTask(taskId)
+    pump()
+  }
+  async function cancelAllQueued(batchId: string): Promise<void> {
+    if (!batchId) return
+    const matchingTasks = Array.from(tasks.values()).filter((task) => (
+      task.batchId === batchId
+      && task.status === "queued"
+      && !runningIds.has(task.id)
+    ))
+    for (const task of matchingTasks) removeFromQueue(task.id)
+    for (const task of matchingTasks) {
+      const now = Date.now()
+      await persistAndNotify({
+        ...task,
+        status: "cancelled",
+        error: null,
+        completedAt: now,
+        updatedAt: now,
+      })
+    }
+  }
+
+  function subscribe(listener: (tasks: BatchImportTask[]) => void): () => void {
+    if (disposed) return () => undefined
+    listeners.add(listener)
+    listener(snapshot())
+    return () => {
+      listeners.delete(listener)
+    }
+  }
+
+  function trackMutation(operation: () => Promise<void>): Promise<void> {
+    const promise = operation()
+    mutationPromises.add(promise)
+    void promise.then(
+      () => { mutationPromises.delete(promise) },
+      () => { mutationPromises.delete(promise) },
+    )
+    return promise
+  }
+  async function dispose(): Promise<void> {
+    if (!disposed) {
+      disposed = true
+      queue.length = 0
+      queuedIds.clear()
+      listeners.clear()
+      for (const controller of controllers.values()) controller.abort()
+    }
+    await Promise.allSettled([
+      ...runPromises.values(),
+      ...continuePromises.values(),
+      ...mutationPromises.values(),
+    ])
+  }
+
+  return {
+    enqueue,
+    continueTask: (taskId) => trackMutation(() => continueTask(taskId)),
+    regenerateTask: (taskId) => trackMutation(() => regenerateTask(taskId)),
+    cancelTask: (taskId) => disposed ? Promise.resolve() : trackMutation(() => cancelTask(taskId)),
+    cancelAllQueued: (batchId) => disposed ? Promise.resolve() : trackMutation(() => cancelAllQueued(batchId)),
+    subscribe,
+    dispose,
+  }
+}

+ 845 - 0
src/lib/novel/book-analysis/batch-import-storage.spec.ts

@@ -0,0 +1,845 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
+import { __resetProjectLocksForTesting } from "@/lib/project-mutex"
+import type {
+  BatchImportBatch,
+  BatchImportCheckpoint,
+  BatchImportTask,
+} from "./batch-import-types"
+
+const fsMocks = vi.hoisted(() => ({
+  files: new Map<string, string>(),
+  directories: new Set<string>(),
+  readFile: vi.fn<(path: string) => Promise<string>>(),
+  writeFileAtomic: vi.fn<(path: string, contents: string) => Promise<void>>(),
+  createDirectory: vi.fn<(path: string) => Promise<void>>(),
+  copyFile: vi.fn<(source: string, destination: string) => Promise<void>>(),
+  deleteFile: vi.fn<(path: string) => Promise<void>>(),
+  fileExists: vi.fn<(path: string) => Promise<boolean>>(),
+  listDirectory: vi.fn(),
+}))
+
+vi.mock("@/commands/fs", () => ({
+  readFile: fsMocks.readFile,
+  writeFileAtomic: fsMocks.writeFileAtomic,
+  createDirectory: fsMocks.createDirectory,
+  copyFile: fsMocks.copyFile,
+  deleteFile: fsMocks.deleteFile,
+  fileExists: fsMocks.fileExists,
+  listDirectory: fsMocks.listDirectory,
+}))
+
+import {
+  cacheTaskSource,
+  cleanupCompletedTaskWorkspaceUnlocked,
+  importTaskDir,
+  importTasksRoot,
+  loadBatchImportBatches,
+  loadBatchImportTasks,
+  loadTaskCheckpoint,
+  resetBatchImportTask,
+  saveBatchImportBatch,
+  saveBatchImportTask,
+  saveBatchImportTaskUnlocked,
+  saveTaskCheckpoint,
+  saveTaskCheckpointUnlocked,
+  withBatchImportTaskLock,
+} from "./batch-import-storage"
+
+function makeTask(overrides: Partial<BatchImportTask> = {}): BatchImportTask {
+  return {
+    version: 1,
+    id: "task-1",
+    batchId: "batch-1",
+    projectPath: "E:/Novel",
+    originalPath: "E:/Sources/长夜.txt",
+    originalFileName: "长夜.txt",
+    cachedSourcePath: "E:/Novel/book-analysis/import-tasks/task-1/source.txt",
+    sourceSha256: null,
+    requestedTitle: "长夜",
+    finalTitle: null,
+    bookId: "book-1",
+    status: "queued",
+    completed: 0,
+    total: 0,
+    error: null,
+    skipReason: null,
+    createdAt: 100,
+    startedAt: null,
+    completedAt: null,
+    updatedAt: 100,
+    ...overrides,
+  }
+}
+
+function seedTask(task: BatchImportTask): void {
+  const dir = importTaskDir(task.projectPath, task.id)
+  fsMocks.directories.add(dir)
+  fsMocks.files.set(`${dir}/task.json`, JSON.stringify(task))
+}
+
+afterEach(() => {
+  vi.restoreAllMocks()
+})
+
+beforeEach(() => {
+  __resetProjectLocksForTesting()
+  vi.clearAllMocks()
+  fsMocks.files.clear()
+  fsMocks.directories.clear()
+
+  fsMocks.readFile.mockImplementation(async (path) => {
+    const contents = fsMocks.files.get(path)
+    if (contents === undefined) throw new Error(`文件不存在:${path}`)
+    return contents
+  })
+  fsMocks.writeFileAtomic.mockImplementation(async (path, contents) => {
+    fsMocks.files.set(path, contents)
+  })
+  fsMocks.createDirectory.mockImplementation(async (path) => {
+    fsMocks.directories.add(path)
+  })
+  fsMocks.copyFile.mockImplementation(async (source, destination) => {
+    const contents = fsMocks.files.get(source)
+    if (contents === undefined) throw new Error(`源文件不存在:${source}`)
+    fsMocks.files.set(destination, contents)
+  })
+  fsMocks.deleteFile.mockImplementation(async (path) => {
+    fsMocks.files.delete(path)
+    for (const filePath of [...fsMocks.files.keys()]) {
+      if (filePath.startsWith(`${path}/`)) fsMocks.files.delete(filePath)
+    }
+    fsMocks.directories.delete(path)
+  })
+  fsMocks.fileExists.mockImplementation(async (path) => (
+    fsMocks.files.has(path) || fsMocks.directories.has(path)
+  ))
+  fsMocks.listDirectory.mockResolvedValue([])
+})
+
+describe("batch import storage", () => {
+  it("生成稳定的批量导入任务目录", () => {
+    expect(importTasksRoot("E:\\Novel\\")).toBe("E:/Novel/book-analysis/import-tasks")
+    expect(importTaskDir("E:\\Novel\\", "task-1")).toBe(
+      "E:/Novel/book-analysis/import-tasks/task-1",
+    )
+  })
+
+  it("任务根目录不存在时返回空列表", async () => {
+    await expect(loadBatchImportTasks("E:/Novel")).resolves.toEqual([])
+    expect(fsMocks.listDirectory).not.toHaveBeenCalled()
+    expect(fsMocks.readFile).not.toHaveBeenCalled()
+  })
+
+  it("任务目录存在但 task.json 缺失时跳过该目录", async () => {
+    const root = importTasksRoot("E:/Novel")
+    const dir = importTaskDir("E:/Novel", "task-missing-json")
+    fsMocks.directories.add(root)
+    fsMocks.directories.add(dir)
+    fsMocks.listDirectory.mockResolvedValue([
+      { name: "task-missing-json", path: dir, is_dir: true },
+    ])
+
+    await expect(loadBatchImportTasks("E:/Novel")).resolves.toEqual([])
+
+    expect(fsMocks.fileExists).toHaveBeenCalledWith(`${dir}/task.json`)
+    expect(fsMocks.readFile).not.toHaveBeenCalled()
+  })
+
+  it("加载时把遗留运行状态改为已中断,等待中任务保持不变", async () => {
+    const copying = makeTask({ id: "task-copying", status: "copying" })
+    const splitting = makeTask({ id: "task-splitting", status: "splitting" })
+    const queued = makeTask({ id: "task-queued", status: "queued" })
+    for (const task of [copying, splitting, queued]) seedTask(task)
+
+    fsMocks.directories.add(importTasksRoot("E:/Novel"))
+    fsMocks.listDirectory.mockResolvedValue(
+      [copying, splitting, queued].map((task) => ({
+        name: task.id,
+        path: importTaskDir(task.projectPath, task.id),
+        is_dir: true,
+      })),
+    )
+    vi.spyOn(Date, "now").mockReturnValue(999)
+
+    const tasks = await loadBatchImportTasks("E:/Novel")
+
+    expect(tasks).toEqual([
+      expect.objectContaining({
+        id: "task-copying",
+        status: "interrupted",
+        error: "软件上次关闭时任务尚未完成",
+        updatedAt: 999,
+      }),
+      expect.objectContaining({
+        id: "task-splitting",
+        status: "interrupted",
+        error: "软件上次关闭时任务尚未完成",
+        updatedAt: 999,
+      }),
+      expect.objectContaining({
+        id: "task-queued",
+        status: "queued",
+        error: null,
+        updatedAt: 100,
+      }),
+    ])
+    expect(fsMocks.writeFileAtomic).toHaveBeenCalledTimes(2)
+    expect(fsMocks.copyFile).not.toHaveBeenCalled()
+  })
+
+  it("原子保存任务并创建任务与章节目录", async () => {
+    const task = makeTask()
+
+    await saveBatchImportTask(task)
+
+    const dir = importTaskDir(task.projectPath, task.id)
+    expect(fsMocks.createDirectory).toHaveBeenCalledWith(dir)
+    expect(fsMocks.createDirectory).toHaveBeenCalledWith(`${dir}/chapters`)
+    expect(fsMocks.writeFileAtomic).toHaveBeenCalledWith(
+      `${dir}/task.json`,
+      JSON.stringify(task, null, 2),
+    )
+  })
+
+  it("原子写入 source.txt 后重新读取缓存全文计算 SHA-256", async () => {
+    const task = makeTask({ cachedSourcePath: "", sourceSha256: null })
+    fsMocks.files.set(task.originalPath, "abc")
+    fsMocks.readFile.mockImplementation(async (path) => {
+      const contents = fsMocks.files.get(path)
+      if (contents === undefined) throw new Error(`文件不存在:${path}`)
+      if (path === task.originalPath) fsMocks.files.set(path, "复制后原路径已变化")
+      return contents
+    })
+
+    const saved = await cacheTaskSource(task)
+
+    const sourcePath = `${importTaskDir(task.projectPath, task.id)}/source.txt`
+    expect(fsMocks.copyFile).not.toHaveBeenCalled()
+    expect(fsMocks.writeFileAtomic).toHaveBeenCalledWith(sourcePath, "abc")
+    expect(fsMocks.readFile.mock.calls.map(([path]) => path)).toEqual([
+      task.originalPath,
+      sourcePath,
+    ])
+    expect(fsMocks.files.get(task.originalPath)).toBe("复制后原路径已变化")
+    expect(saved).toEqual(expect.objectContaining({
+      cachedSourcePath: sourcePath,
+      sourceSha256: "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad",
+    }))
+    expect(fsMocks.writeFileAtomic).toHaveBeenCalledWith(
+      `${importTaskDir(task.projectPath, task.id)}/task.json`,
+      JSON.stringify(saved, null, 2),
+    )
+  })
+
+  it("将批次写入 batches.json,并按批次 ID 更新已有记录", async () => {
+    const root = importTasksRoot("E:/Novel")
+    const oldBatch: BatchImportBatch = {
+      version: 1,
+      id: "batch-1",
+      projectPath: "E:/Novel",
+      taskIds: ["task-old"],
+      createdAt: 100,
+      updatedAt: 100,
+    }
+    const otherBatch: BatchImportBatch = {
+      ...oldBatch,
+      id: "batch-2",
+      taskIds: ["task-2"],
+    }
+    const updatedBatch: BatchImportBatch = {
+      ...oldBatch,
+      taskIds: ["task-1"],
+      updatedAt: 200,
+    }
+    fsMocks.directories.add(root)
+    fsMocks.files.set(`${root}/batches.json`, JSON.stringify([oldBatch, otherBatch]))
+
+    await saveBatchImportBatch(updatedBatch)
+
+    expect(fsMocks.writeFileAtomic).toHaveBeenCalledWith(
+      `${root}/batches.json`,
+      JSON.stringify([updatedBatch, otherBatch], null, 2),
+    )
+  })
+
+  it("batches.json 缺失时不读取旧批次并直接原子保存新批次", async () => {
+    const batch: BatchImportBatch = {
+      version: 1,
+      id: "batch-new",
+      projectPath: "E:/Novel",
+      taskIds: ["task-1"],
+      createdAt: 100,
+      updatedAt: 100,
+    }
+    const path = `${importTasksRoot(batch.projectPath)}/batches.json`
+
+    await saveBatchImportBatch(batch)
+
+    expect(fsMocks.fileExists).toHaveBeenCalledWith(path)
+    expect(fsMocks.readFile).not.toHaveBeenCalled()
+    expect(fsMocks.writeFileAtomic).toHaveBeenCalledWith(
+      path,
+      JSON.stringify([batch], null, 2),
+    )
+  })
+
+  it("检查点不存在时返回 null,保存与加载均使用任务检查点路径", async () => {
+    const task = makeTask()
+    const checkpoint: BatchImportCheckpoint = {
+      version: 1,
+      sourceSha256: "sha",
+      totalChapters: 2,
+      completedChapterIndexes: [0],
+      totalWords: 1200,
+      updatedAt: 200,
+    }
+
+    await expect(loadTaskCheckpoint(task)).resolves.toBeNull()
+    await saveTaskCheckpoint(task, checkpoint)
+    await expect(loadTaskCheckpoint(task)).resolves.toEqual(checkpoint)
+
+    const path = `${importTaskDir(task.projectPath, task.id)}/checkpoint.json`
+    expect(fsMocks.writeFileAtomic).toHaveBeenCalledWith(
+      path,
+      JSON.stringify(checkpoint, null, 2),
+    )
+  })
+
+  it("完成清理只删除当前任务 chapters 和 checkpoint 并保留 source 与 task", async () => {
+    const task = makeTask({ status: "completed", sourceSha256: "sha" })
+    seedTask(task)
+    const dir = importTaskDir(task.projectPath, task.id)
+    fsMocks.directories.add(`${dir}/chapters`)
+    fsMocks.files.set(`${dir}/source.txt`, "原文副本")
+    fsMocks.files.set(`${dir}/chapters/ch-0001.md`, "章节")
+    fsMocks.files.set(`${dir}/checkpoint.json`, "检查点")
+
+    await withBatchImportTaskLock(task.projectPath, task.id, async () => {
+      await cleanupCompletedTaskWorkspaceUnlocked(task)
+    })
+
+    expect(fsMocks.deleteFile.mock.calls.map(([path]) => path)).toEqual([
+      `${dir}/chapters`,
+      `${dir}/checkpoint.json`,
+    ])
+    expect(fsMocks.files.get(`${dir}/source.txt`)).toBe("原文副本")
+    expect(fsMocks.files.has(`${dir}/task.json`)).toBe(true)
+    expect(fsMocks.files.has(`${dir}/chapters/ch-0001.md`)).toBe(false)
+    expect(fsMocks.files.has(`${dir}/checkpoint.json`)).toBe(false)
+  })
+
+  it("失败任务拒绝完成清理并保留章节断点", async () => {
+    const task = makeTask({ status: "failed", error: "拆分失败" })
+    const dir = importTaskDir(task.projectPath, task.id)
+    fsMocks.directories.add(`${dir}/chapters`)
+    fsMocks.files.set(`${dir}/chapters/ch-0001.md`, "已完成章节")
+    fsMocks.files.set(`${dir}/checkpoint.json`, "检查点")
+
+    await expect(cleanupCompletedTaskWorkspaceUnlocked(task)).rejects.toThrow(
+      "只能清理已完成的批量导入任务工作区",
+    )
+
+    expect(fsMocks.files.has(`${dir}/chapters/ch-0001.md`)).toBe(true)
+    expect(fsMocks.files.has(`${dir}/checkpoint.json`)).toBe(true)
+    expect(fsMocks.deleteFile).not.toHaveBeenCalled()
+  })
+
+  it("reset 目标章节和检查点均缺失时不执行删除", async () => {
+    const task = makeTask({ status: "failed", error: "拆分失败" })
+    seedTask(task)
+
+    const reset = await resetBatchImportTask(task.projectPath, task.id)
+
+    expect(fsMocks.deleteFile).not.toHaveBeenCalled()
+    expect(reset.status).toBe("queued")
+  })
+
+  it("task.json 缺失时抛出中文错误且不执行删除", async () => {
+    const dir = importTaskDir("E:/Novel", "task-missing")
+    fsMocks.directories.add(dir)
+    fsMocks.directories.add(`${dir}/chapters`)
+    fsMocks.files.set(`${dir}/checkpoint.json`, "{}")
+
+    await expect(
+      resetBatchImportTask("E:/Novel", "task-missing"),
+    ).rejects.toThrow("找不到批量导入任务:task-missing")
+
+    expect(fsMocks.deleteFile).not.toHaveBeenCalled()
+  })
+
+  it("重新生成只删除当前任务的 chapters 和 checkpoint 并保留所有 source.txt", async () => {
+    const task = makeTask({
+      status: "failed",
+      completed: 2,
+      total: 5,
+      error: "拆分失败",
+      skipReason: "旧原因",
+      startedAt: 110,
+      completedAt: 120,
+    })
+    const otherTask = makeTask({
+      id: "task-2",
+      originalPath: "E:/Sources/星河.txt",
+      cachedSourcePath: "E:/Novel/book-analysis/import-tasks/task-2/source.txt",
+      requestedTitle: "星河",
+      bookId: "book-2",
+      status: "splitting",
+      completed: 1,
+      total: 3,
+    })
+    seedTask(task)
+    seedTask(otherTask)
+    const dir = importTaskDir(task.projectPath, task.id)
+    const otherDir = importTaskDir(otherTask.projectPath, otherTask.id)
+    fsMocks.directories.add(`${dir}/chapters`)
+    fsMocks.directories.add(`${otherDir}/chapters`)
+    fsMocks.files.set(`${dir}/chapters/0001.md`, "当前任务章节")
+    fsMocks.files.set(`${dir}/checkpoint.json`, "{}")
+    fsMocks.files.set(`${dir}/source.txt`, "当前任务完整原文")
+    fsMocks.files.set(`${otherDir}/chapters/0001.md`, "另一任务章节")
+    fsMocks.files.set(`${otherDir}/checkpoint.json`, "{\"other\":true}")
+    fsMocks.files.set(`${otherDir}/source.txt`, "另一任务完整原文")
+    const otherTaskJsonBefore = fsMocks.files.get(`${otherDir}/task.json`)
+    vi.spyOn(Date, "now").mockReturnValue(999)
+
+    const reset = await resetBatchImportTask(task.projectPath, task.id)
+
+    expect(fsMocks.deleteFile).toHaveBeenCalledTimes(2)
+    expect(fsMocks.deleteFile).toHaveBeenNthCalledWith(1, `${dir}/chapters`)
+    expect(fsMocks.deleteFile).toHaveBeenNthCalledWith(2, `${dir}/checkpoint.json`)
+    expect(fsMocks.files.get(`${dir}/source.txt`)).toBe("当前任务完整原文")
+    expect(fsMocks.files.get(`${otherDir}/source.txt`)).toBe("另一任务完整原文")
+    expect(fsMocks.files.get(`${otherDir}/chapters/0001.md`)).toBe("另一任务章节")
+    expect(fsMocks.files.get(`${otherDir}/checkpoint.json`)).toBe("{\"other\":true}")
+    expect(fsMocks.files.get(`${otherDir}/task.json`)).toBe(otherTaskJsonBefore)
+    expect(fsMocks.createDirectory).toHaveBeenCalledWith(`${dir}/chapters`)
+    expect(reset).toEqual(expect.objectContaining({
+      status: "queued",
+      completed: 0,
+      total: 0,
+      error: null,
+      skipReason: null,
+      startedAt: null,
+      completedAt: null,
+      updatedAt: 999,
+    }))
+    expect(fsMocks.writeFileAtomic).toHaveBeenCalledWith(
+      `${dir}/task.json`,
+      JSON.stringify(reset, null, 2),
+    )
+  })
+  it.each(["", "../escape", "a/b", "a\\b", "含空格"]) (
+    "拒绝非法任务 ID:%s",
+    async (taskId) => {
+      expect(() => importTaskDir("E:/Novel", taskId)).toThrow("任务 ID 不合法")
+      await expect(
+        saveBatchImportTask(makeTask({ id: taskId })),
+      ).rejects.toThrow("任务 ID 不合法")
+      expect(fsMocks.createDirectory).not.toHaveBeenCalled()
+      expect(fsMocks.fileExists).not.toHaveBeenCalled()
+    },
+  )
+
+  it("加载时隔离坏 JSON、身份不匹配和基本字段非法任务并稳定排序", async () => {
+    const root = importTasksRoot("E:/Novel")
+    const documents: Array<[string, string]> = [
+      ["bad-json", "{坏JSON"],
+      ["wrong-id", JSON.stringify(makeTask({ id: "another-id" }))],
+      ["wrong-project", JSON.stringify(makeTask({ id: "wrong-project", projectPath: "E:/Other" }))],
+      ["bad-version", JSON.stringify({ ...makeTask({ id: "bad-version" }), version: 2 })],
+      ["bad-status", JSON.stringify({ ...makeTask({ id: "bad-status" }), status: "running" })],
+      ["missing-field", JSON.stringify({ ...makeTask({ id: "missing-field" }), originalPath: "" })],
+      ["late", JSON.stringify(makeTask({ id: "late", createdAt: 300 }))],
+      ["early-first", JSON.stringify(makeTask({ id: "early-first", createdAt: 100 }))],
+      ["early-second", JSON.stringify(makeTask({ id: "early-second", createdAt: 100, projectPath: "E:\\Novel\\" }))],
+    ]
+    fsMocks.directories.add(root)
+    fsMocks.listDirectory.mockResolvedValue(documents.map(([name]) => ({
+      name,
+      path: `${root}/${name}`,
+      is_dir: true,
+    })))
+    for (const [name, raw] of documents) {
+      const dir = `${root}/${name}`
+      fsMocks.directories.add(dir)
+      fsMocks.files.set(`${dir}/task.json`, raw)
+    }
+    const warn = vi.spyOn(console, "warn").mockImplementation(() => undefined)
+
+    const tasks = await loadBatchImportTasks("E:/Novel")
+
+    expect(tasks.map((task) => task.id)).toEqual(["early-first", "early-second", "late"])
+    expect(warn).toHaveBeenCalledTimes(6)
+  })
+
+  it("加载时拒绝越界目录名且不拼接该任务路径", async () => {
+    const root = importTasksRoot("E:/Novel")
+    fsMocks.directories.add(root)
+    fsMocks.listDirectory.mockResolvedValue([
+      { name: "../escape", path: `${root}/../escape`, is_dir: true },
+    ])
+    const warn = vi.spyOn(console, "warn").mockImplementation(() => undefined)
+
+    await expect(loadBatchImportTasks("E:/Novel")).resolves.toEqual([])
+
+    expect(warn).toHaveBeenCalledTimes(1)
+    expect(fsMocks.fileExists).toHaveBeenCalledTimes(1)
+    expect(fsMocks.fileExists).toHaveBeenCalledWith(root)
+  })
+
+  it("同项目并发保存批次时串行读改写并保留两个批次", async () => {
+    const firstBatch: BatchImportBatch = {
+      version: 1,
+      id: "batch-1",
+      projectPath: "E:\\Novel\\",
+      taskIds: ["task-1"],
+      createdAt: 100,
+      updatedAt: 100,
+    }
+    const secondBatch: BatchImportBatch = {
+      ...firstBatch,
+      id: "batch-2",
+      projectPath: "E:/Novel",
+      taskIds: ["task-2"],
+    }
+    let notifyFirstWrite!: () => void
+    const firstWriteStarted = new Promise<void>((resolve) => { notifyFirstWrite = resolve })
+    let releaseFirstWrite!: () => void
+    const firstWriteGate = new Promise<void>((resolve) => { releaseFirstWrite = resolve })
+    fsMocks.writeFileAtomic.mockImplementationOnce(async (path, contents) => {
+      notifyFirstWrite()
+      await firstWriteGate
+      fsMocks.files.set(path, contents)
+    })
+
+    const first = saveBatchImportBatch(firstBatch)
+    await firstWriteStarted
+    const second = saveBatchImportBatch(secondBatch)
+    await new Promise((resolve) => setTimeout(resolve, 0))
+
+    expect(fsMocks.writeFileAtomic).toHaveBeenCalledTimes(1)
+    releaseFirstWrite()
+    await Promise.all([first, second])
+
+    const batchesPath = `${importTasksRoot("E:/Novel")}/batches.json`
+    const batches = JSON.parse(fsMocks.files.get(batchesPath)!) as BatchImportBatch[]
+    expect(batches.map((batch) => batch.id)).toEqual(["batch-1", "batch-2"])
+  })
+
+  it("缓存 source.txt 原子保存失败时不保存任务且不留下缓存", async () => {
+    const task = makeTask({ cachedSourcePath: "", sourceSha256: null })
+    fsMocks.files.set(task.originalPath, "abc")
+    fsMocks.writeFileAtomic.mockRejectedValueOnce(new Error("缓存保存失败"))
+
+    await expect(cacheTaskSource(task)).rejects.toThrow("缓存保存失败")
+
+    const dir = importTaskDir(task.projectPath, task.id)
+    expect(fsMocks.copyFile).not.toHaveBeenCalled()
+    expect(fsMocks.files.has(`${dir}/source.txt`)).toBe(false)
+    expect(fsMocks.files.has(`${dir}/task.json`)).toBe(false)
+    expect(fsMocks.writeFileAtomic).toHaveBeenCalledTimes(1)
+  })
+
+  it("同一任务并发缓存时按任务锁串行执行", async () => {
+    const task = makeTask({ cachedSourcePath: "", sourceSha256: null })
+    fsMocks.files.set(task.originalPath, "abc")
+    let originalReads = 0
+    let notifyFirstRead!: () => void
+    const firstReadStarted = new Promise<void>((resolve) => { notifyFirstRead = resolve })
+    let releaseFirstRead!: () => void
+    const firstReadGate = new Promise<void>((resolve) => { releaseFirstRead = resolve })
+    fsMocks.readFile.mockImplementation(async (path) => {
+      const contents = fsMocks.files.get(path)
+      if (contents === undefined) throw new Error(`文件不存在:${path}`)
+      if (path === task.originalPath) {
+        originalReads += 1
+        if (originalReads === 1) {
+          notifyFirstRead()
+          await firstReadGate
+        }
+      }
+      return contents
+    })
+
+    const first = cacheTaskSource(task)
+    await firstReadStarted
+    const second = cacheTaskSource(task)
+    await new Promise((resolve) => setTimeout(resolve, 0))
+
+    expect(originalReads).toBe(1)
+    releaseFirstRead()
+    await Promise.all([first, second])
+    expect(originalReads).toBe(2)
+  })
+
+  it("reset 读取后再次验证任务身份,身份不匹配时不写不删", async () => {
+    const dir = importTaskDir("E:/Novel", "task-1")
+    fsMocks.directories.add(dir)
+    fsMocks.directories.add(`${dir}/chapters`)
+    fsMocks.files.set(`${dir}/task.json`, JSON.stringify(makeTask({ id: "task-2" })))
+    fsMocks.files.set(`${dir}/checkpoint.json`, "{}")
+
+    await expect(
+      resetBatchImportTask("E:/Novel", "task-1"),
+    ).rejects.toThrow("任务身份不匹配")
+
+    expect(fsMocks.writeFileAtomic).not.toHaveBeenCalled()
+    expect(fsMocks.deleteFile).not.toHaveBeenCalled()
+  })
+
+  it("reset 拒绝项目身份不匹配的任务且不写不删", async () => {
+    const dir = importTaskDir("E:/Novel", "task-1")
+    fsMocks.directories.add(dir)
+    fsMocks.directories.add(`${dir}/chapters`)
+    fsMocks.files.set(`${dir}/task.json`, JSON.stringify(makeTask({ projectPath: "E:/Other" })))
+
+    await expect(
+      resetBatchImportTask("E:/Novel", "task-1"),
+    ).rejects.toThrow("任务身份不匹配")
+
+    expect(fsMocks.writeFileAtomic).not.toHaveBeenCalled()
+    expect(fsMocks.deleteFile).not.toHaveBeenCalled()
+  })
+  it("reset 先原子保存 queued 状态,清理失败时任务仍保持 queued", async () => {
+    const task = makeTask({ status: "failed", completed: 2, total: 5, error: "失败" })
+    seedTask(task)
+    const dir = importTaskDir(task.projectPath, task.id)
+    fsMocks.directories.add(`${dir}/chapters`)
+    fsMocks.files.set(`${dir}/checkpoint.json`, "{}")
+    fsMocks.deleteFile.mockRejectedValueOnce(new Error("清理失败"))
+
+    await expect(resetBatchImportTask(task.projectPath, task.id)).rejects.toThrow("清理失败")
+
+    const saved = JSON.parse(fsMocks.files.get(`${dir}/task.json`)!) as BatchImportTask
+    expect(saved).toEqual(expect.objectContaining({
+      status: "queued",
+      completed: 0,
+      total: 0,
+      error: null,
+    }))
+    expect(fsMocks.writeFileAtomic.mock.invocationCallOrder[0]).toBeLessThan(
+      fsMocks.deleteFile.mock.invocationCallOrder[0],
+    )
+  })
+
+  it("同一任务并发 reset 时按任务锁串行执行", async () => {
+    const task = makeTask({ status: "failed", error: "失败" })
+    seedTask(task)
+    const dir = importTaskDir(task.projectPath, task.id)
+    fsMocks.directories.add(`${dir}/chapters`)
+    fsMocks.files.set(`${dir}/checkpoint.json`, "{}")
+    let deleteCalls = 0
+    let notifyFirstDelete!: () => void
+    const firstDeleteStarted = new Promise<void>((resolve) => { notifyFirstDelete = resolve })
+    let releaseFirstDelete!: () => void
+    const firstDeleteGate = new Promise<void>((resolve) => { releaseFirstDelete = resolve })
+    fsMocks.deleteFile.mockImplementation(async (path) => {
+      deleteCalls += 1
+      if (deleteCalls === 1) {
+        notifyFirstDelete()
+        await firstDeleteGate
+      }
+      fsMocks.files.delete(path)
+      for (const filePath of [...fsMocks.files.keys()]) {
+        if (filePath.startsWith(`${path}/`)) fsMocks.files.delete(filePath)
+      }
+      fsMocks.directories.delete(path)
+    })
+
+    const first = resetBatchImportTask(task.projectPath, task.id)
+    await firstDeleteStarted
+    const second = resetBatchImportTask(task.projectPath, task.id)
+    await new Promise((resolve) => setTimeout(resolve, 0))
+
+    const taskPath = `${dir}/task.json`
+    expect(deleteCalls).toBe(1)
+    expect(fsMocks.writeFileAtomic.mock.calls.filter(([path]) => path === taskPath)).toHaveLength(1)
+    releaseFirstDelete()
+    await Promise.all([first, second])
+    expect(fsMocks.writeFileAtomic.mock.calls.filter(([path]) => path === taskPath)).toHaveLength(2)
+  })
+  it("reset 等待已开始的公开任务状态保存,旧写入不能越过 reset", async () => {
+    const persisted = makeTask({ status: "failed", error: "旧失败" })
+    const oldState = makeTask({ status: "splitting", completed: 2, total: 5, error: null })
+    seedTask(persisted)
+    const dir = importTaskDir(persisted.projectPath, persisted.id)
+    fsMocks.directories.add(`${dir}/chapters`)
+    let notifyOldWrite!: () => void
+    const oldWriteStarted = new Promise<void>((resolve) => { notifyOldWrite = resolve })
+    let releaseOldWrite!: () => void
+    const oldWriteGate = new Promise<void>((resolve) => { releaseOldWrite = resolve })
+    fsMocks.writeFileAtomic.mockImplementationOnce(async (path, contents) => {
+      notifyOldWrite()
+      await oldWriteGate
+      fsMocks.files.set(path, contents)
+    })
+
+    const oldSave = saveBatchImportTask(oldState)
+    await oldWriteStarted
+    const reset = resetBatchImportTask(persisted.projectPath, persisted.id)
+    await new Promise((resolve) => setTimeout(resolve, 0))
+
+    expect(fsMocks.deleteFile).not.toHaveBeenCalled()
+    releaseOldWrite()
+    await Promise.all([oldSave, reset])
+
+    const finalTask = JSON.parse(fsMocks.files.get(`${dir}/task.json`)!) as BatchImportTask
+    expect(finalTask.status).toBe("queued")
+    expect(finalTask.completed).toBe(0)
+  })
+
+  it("reset 等待已开始的公开 checkpoint 保存并在之后清除旧检查点", async () => {
+    const task = makeTask({ status: "failed", error: "旧失败" })
+    const checkpoint: BatchImportCheckpoint = {
+      version: 1,
+      sourceSha256: "sha",
+      totalChapters: 2,
+      completedChapterIndexes: [0],
+      totalWords: 100,
+      updatedAt: 200,
+    }
+    seedTask(task)
+    const dir = importTaskDir(task.projectPath, task.id)
+    fsMocks.directories.add(`${dir}/chapters`)
+    let notifyCheckpointWrite!: () => void
+    const checkpointWriteStarted = new Promise<void>((resolve) => { notifyCheckpointWrite = resolve })
+    let releaseCheckpointWrite!: () => void
+    const checkpointWriteGate = new Promise<void>((resolve) => { releaseCheckpointWrite = resolve })
+    fsMocks.writeFileAtomic.mockImplementationOnce(async (path, contents) => {
+      notifyCheckpointWrite()
+      await checkpointWriteGate
+      fsMocks.files.set(path, contents)
+    })
+
+    const oldSave = saveTaskCheckpoint(task, checkpoint)
+    await checkpointWriteStarted
+    const reset = resetBatchImportTask(task.projectPath, task.id)
+    await new Promise((resolve) => setTimeout(resolve, 0))
+
+    expect(fsMocks.deleteFile).not.toHaveBeenCalled()
+    releaseCheckpointWrite()
+    await Promise.all([oldSave, reset])
+    expect(fsMocks.files.has(`${dir}/checkpoint.json`)).toBe(false)
+  })
+
+  it("已持有任务锁时 unlocked 保存任务和 checkpoint 不会嵌套死锁", async () => {
+    const task = makeTask()
+    const checkpoint: BatchImportCheckpoint = {
+      version: 1,
+      sourceSha256: "sha",
+      totalChapters: 1,
+      completedChapterIndexes: [],
+      totalWords: 0,
+      updatedAt: 100,
+    }
+
+    await withBatchImportTaskLock(task.projectPath, task.id, async () => {
+      await saveBatchImportTaskUnlocked(task)
+      await saveTaskCheckpointUnlocked(task, checkpoint)
+    })
+
+    const dir = importTaskDir(task.projectPath, task.id)
+    expect(fsMocks.files.has(`${dir}/task.json`)).toBe(true)
+    expect(fsMocks.files.has(`${dir}/checkpoint.json`)).toBe(true)
+  })
+
+  it("不同任务的任务锁可以并行进入", async () => {
+    let notifyFirstEntered!: () => void
+    const firstEntered = new Promise<void>((resolve) => { notifyFirstEntered = resolve })
+    let releaseFirst!: () => void
+    const firstGate = new Promise<void>((resolve) => { releaseFirst = resolve })
+    let secondEntered = false
+
+    const first = withBatchImportTaskLock("E:/Novel", "task-1", async () => {
+      notifyFirstEntered()
+      await firstGate
+    })
+    await firstEntered
+    const second = withBatchImportTaskLock("E:/Novel", "task-2", async () => {
+      secondEntered = true
+    })
+    await new Promise((resolve) => setTimeout(resolve, 0))
+
+    expect(secondEntered).toBe(true)
+    releaseFirst()
+    await Promise.all([first, second])
+  })
+
+
+  it("加载批次时逐条过滤非法数据并按批次 ID 去重", async () => {
+    const root = importTasksRoot("E:/Novel")
+    const first: BatchImportBatch = {
+      version: 1,
+      id: "batch-same",
+      projectPath: "E:\\Novel\\",
+      taskIds: ["task-missing", "task-1"],
+      createdAt: 100,
+      updatedAt: 100,
+    }
+    const duplicate = { ...first, taskIds: ["task-2"], updatedAt: 200 }
+    const valid: BatchImportBatch = { ...first, id: "batch-valid", taskIds: ["unknown-task"] }
+    fsMocks.directories.add(root)
+    fsMocks.files.set(`${root}/batches.json`, JSON.stringify([
+      null,
+      first,
+      { ...first, id: "bad id" },
+      { ...first, projectPath: "E:/Other" },
+      duplicate,
+      valid,
+    ]))
+
+    await expect(loadBatchImportBatches("E:/Novel")).resolves.toEqual([first, valid])
+  })
+
+  it.each([
+    ["损坏 JSON", "{bad-json"],
+    ["损坏根结构", JSON.stringify({ batches: [] })],
+  ])("保存批次遇到%s时中文警告并从空列表恢复", async (_label, contents) => {
+    const root = importTasksRoot("E:/Novel")
+    const batch: BatchImportBatch = {
+      version: 1,
+      id: "batch-new",
+      projectPath: "E:/Novel",
+      taskIds: ["task-1"],
+      createdAt: 100,
+      updatedAt: 100,
+    }
+    fsMocks.directories.add(root)
+    fsMocks.files.set(`${root}/batches.json`, contents)
+    const warn = vi.spyOn(console, "warn").mockImplementation(() => undefined)
+
+    await saveBatchImportBatch(batch)
+
+    expect(warn).toHaveBeenCalledWith(
+      expect.stringContaining("批次记录损坏"),
+      expect.anything(),
+    )
+    expect(JSON.parse(fsMocks.files.get(`${root}/batches.json`)!)).toEqual([batch])
+  })
+  it("保存批次时过滤 batches.json 中的 null 和非法元素", async () => {
+    const root = importTasksRoot("E:/Novel")
+    const validBatch: BatchImportBatch = {
+      version: 1,
+      id: "batch-valid",
+      projectPath: "E:/Novel",
+      taskIds: ["task-1"],
+      createdAt: 100,
+      updatedAt: 100,
+    }
+    const newBatch: BatchImportBatch = {
+      ...validBatch,
+      id: "batch-new",
+      taskIds: ["task-2"],
+    }
+    fsMocks.directories.add(root)
+    fsMocks.files.set(
+      `${root}/batches.json`,
+      JSON.stringify([null, validBatch, { id: "bad" }]),
+    )
+
+    await saveBatchImportBatch(newBatch)
+
+    const saved = JSON.parse(fsMocks.files.get(`${root}/batches.json`)!) as BatchImportBatch[]
+    expect(saved.map((batch) => batch.id)).toEqual(["batch-valid", "batch-new"])
+  })
+})

+ 417 - 0
src/lib/novel/book-analysis/batch-import-storage.ts

@@ -0,0 +1,417 @@
+import {
+  createDirectory,
+  deleteFile,
+  fileExists,
+  listDirectory,
+  readFile,
+  writeFileAtomic,
+} from "@/commands/fs"
+import { normalizePath } from "@/lib/path-utils"
+import { withProjectLock } from "@/lib/project-mutex"
+import { hashNormalizedNovel } from "./batch-import-hash"
+import type {
+  BatchImportBatch,
+  BatchImportCheckpoint,
+  BatchImportTask,
+  BatchImportTaskStatus,
+} from "./batch-import-types"
+
+const INTERRUPTED_ERROR = "软件上次关闭时任务尚未完成"
+const TASK_ID_PATTERN = /^[A-Za-z0-9_-]+$/
+const TASK_STATUSES = new Set<BatchImportTaskStatus>([
+  "queued",
+  "copying",
+  "splitting",
+  "interrupted",
+  "failed",
+  "cancelled",
+  "skipped",
+  "completed",
+])
+
+function normalizeProjectKey(projectPath: string): string {
+  const normalized = normalizePath(projectPath).replace(/\/+$/, "")
+  if (!normalized) throw new Error("项目路径不能为空")
+  return normalized
+}
+
+function isValidTaskId(taskId: unknown): taskId is string {
+  return typeof taskId === "string" && TASK_ID_PATTERN.test(taskId)
+}
+
+function assertValidTaskId(taskId: string): void {
+  if (!isValidTaskId(taskId)) {
+    throw new Error(`任务 ID 不合法:${taskId || "空值"}`)
+  }
+}
+
+function isRecord(value: unknown): value is Record<string, unknown> {
+  return typeof value === "object" && value !== null && !Array.isArray(value)
+}
+
+function isNonEmptyString(value: unknown): value is string {
+  return typeof value === "string" && value.length > 0
+}
+
+function isNullableString(value: unknown): value is string | null {
+  return value === null || typeof value === "string"
+}
+
+function isNonNegativeNumber(value: unknown): value is number {
+  return typeof value === "number" && Number.isFinite(value) && value >= 0
+}
+
+function isNullableNonNegativeNumber(value: unknown): value is number | null {
+  return value === null || isNonNegativeNumber(value)
+}
+
+function validatePersistedTask(
+  value: unknown,
+  expectedProjectKey: string,
+  expectedTaskId: string,
+): string | null {
+  if (!isRecord(value)) return `批量导入任务数据无效:${expectedTaskId}`
+  if (value.id !== expectedTaskId) return `任务身份不匹配:${expectedTaskId}`
+  if (!isNonEmptyString(value.projectPath)) {
+    return `批量导入任务数据无效:${expectedTaskId}`
+  }
+  if (normalizeProjectKey(value.projectPath) !== expectedProjectKey) {
+    return `任务身份不匹配:${expectedTaskId}`
+  }
+  if (
+    value.version !== 1
+    || !isValidTaskId(value.id)
+    || !isNonEmptyString(value.batchId)
+    || !isNonEmptyString(value.originalPath)
+    || !isNonEmptyString(value.originalFileName)
+    || typeof value.cachedSourcePath !== "string"
+    || !isNullableString(value.sourceSha256)
+    || !isNonEmptyString(value.requestedTitle)
+    || !isNullableString(value.finalTitle)
+    || !isNonEmptyString(value.bookId)
+    || typeof value.status !== "string"
+    || !TASK_STATUSES.has(value.status as BatchImportTaskStatus)
+    || !isNonNegativeNumber(value.completed)
+    || !isNonNegativeNumber(value.total)
+    || !isNullableString(value.error)
+    || !isNullableString(value.skipReason)
+    || !isNonNegativeNumber(value.createdAt)
+    || !isNullableNonNegativeNumber(value.startedAt)
+    || !isNullableNonNegativeNumber(value.completedAt)
+    || !isNonNegativeNumber(value.updatedAt)
+  ) {
+    return `批量导入任务数据无效:${expectedTaskId}`
+  }
+  return null
+}
+
+function assertTaskForPath(task: BatchImportTask): string {
+  assertValidTaskId(task.id)
+  const projectKey = normalizeProjectKey(task.projectPath)
+  const validationError = validatePersistedTask(task, projectKey, task.id)
+  if (validationError) throw new Error(validationError)
+  return projectKey
+}
+
+function isBatchImportBatch(
+  value: unknown,
+  expectedProjectKey: string,
+): value is BatchImportBatch {
+  if (!isRecord(value)) return false
+  if (
+    value.version !== 1
+    || !isValidTaskId(value.id)
+    || !isNonEmptyString(value.projectPath)
+    || !Array.isArray(value.taskIds)
+    || !value.taskIds.every(isValidTaskId)
+    || !isNonNegativeNumber(value.createdAt)
+    || !isNonNegativeNumber(value.updatedAt)
+  ) {
+    return false
+  }
+  try {
+    return normalizeProjectKey(value.projectPath) === expectedProjectKey
+  } catch {
+    return false
+  }
+}
+
+function assertBatchImportBatch(batch: BatchImportBatch, projectKey: string): void {
+  const batchId = batch.id
+  if (!isBatchImportBatch(batch, projectKey)) {
+    throw new Error(`批量导入批次数据无效:${batchId || "未知批次"}`)
+  }
+}
+
+export async function withBatchImportTaskLock<T>(
+  projectPath: string,
+  taskId: string,
+  fn: () => Promise<T>,
+): Promise<T> {
+  assertValidTaskId(taskId)
+  const projectKey = normalizeProjectKey(projectPath)
+  return withProjectLock(`${projectKey}#${taskId}`, fn)
+}
+
+export function importTasksRoot(projectPath: string): string {
+  return `${normalizeProjectKey(projectPath)}/book-analysis/import-tasks`
+}
+
+export function importTaskDir(projectPath: string, taskId: string): string {
+  assertValidTaskId(taskId)
+  return `${importTasksRoot(projectPath)}/${taskId}`
+}
+
+function taskFilePath(projectPath: string, taskId: string): string {
+  return `${importTaskDir(projectPath, taskId)}/task.json`
+}
+
+function taskCheckpointPath(task: BatchImportTask): string {
+  return `${importTaskDir(task.projectPath, task.id)}/checkpoint.json`
+}
+
+async function ensureTaskBaseDirectories(projectPath: string, taskId: string): Promise<void> {
+  assertValidTaskId(taskId)
+  const root = importTasksRoot(projectPath)
+  const taskDir = importTaskDir(projectPath, taskId)
+  await createDirectory(root)
+  await createDirectory(taskDir)
+}
+
+async function ensureTaskDirectories(projectPath: string, taskId: string): Promise<void> {
+  await ensureTaskBaseDirectories(projectPath, taskId)
+  await createDirectory(`${importTaskDir(projectPath, taskId)}/chapters`)
+}
+
+export async function loadBatchImportTasks(
+  projectPath: string,
+): Promise<BatchImportTask[]> {
+  const projectKey = normalizeProjectKey(projectPath)
+  const root = importTasksRoot(projectKey)
+  if (!(await fileExists(root))) return []
+
+  const entries = await listDirectory(root)
+  const tasks: BatchImportTask[] = []
+
+  for (const entry of entries) {
+    if (!entry.is_dir) continue
+    if (!isValidTaskId(entry.name)) {
+      console.warn(`批量导入:已跳过非法任务目录“${entry.name}”`)
+      continue
+    }
+
+    const path = taskFilePath(projectKey, entry.name)
+    try {
+      if (!(await fileExists(path))) continue
+      const parsed = JSON.parse(await readFile(path)) as unknown
+      const validationError = validatePersistedTask(parsed, projectKey, entry.name)
+      if (validationError) throw new Error(validationError)
+      const task = parsed as unknown as BatchImportTask
+
+      if (task.status === "copying" || task.status === "splitting") {
+        const interruptedTask: BatchImportTask = {
+          ...task,
+          status: "interrupted",
+          error: INTERRUPTED_ERROR,
+          updatedAt: Date.now(),
+        }
+        await saveBatchImportTask(interruptedTask)
+        tasks.push(interruptedTask)
+      } else {
+        tasks.push(task)
+      }
+    } catch (error) {
+      console.warn(`批量导入:已跳过损坏任务“${entry.name}”`, error)
+    }
+  }
+
+  return tasks.sort((left, right) => left.createdAt - right.createdAt)
+}
+
+export async function saveBatchImportTaskUnlocked(
+  task: BatchImportTask,
+  options: { ensureChaptersDirectory?: boolean } = {},
+): Promise<void> {
+  assertTaskForPath(task)
+  if (options.ensureChaptersDirectory === false) {
+    await ensureTaskBaseDirectories(task.projectPath, task.id)
+  } else {
+    await ensureTaskDirectories(task.projectPath, task.id)
+  }
+  await writeFileAtomic(
+    taskFilePath(task.projectPath, task.id),
+    JSON.stringify(task, null, 2),
+  )
+}
+
+export async function saveBatchImportTask(task: BatchImportTask): Promise<void> {
+  await withBatchImportTaskLock(task.projectPath, task.id, async () => {
+    await saveBatchImportTaskUnlocked(task)
+  })
+}
+
+export async function loadBatchImportBatches(
+  projectPath: string,
+): Promise<BatchImportBatch[]> {
+  const projectKey = normalizeProjectKey(projectPath)
+  const path = `${importTasksRoot(projectKey)}/batches.json`
+  if (!(await fileExists(path))) return []
+
+  let parsed: unknown
+  try {
+    parsed = JSON.parse(await readFile(path)) as unknown
+  } catch (error) {
+    console.warn("批量导入:批次记录损坏,已忽略", error)
+    return []
+  }
+  if (!Array.isArray(parsed)) return []
+
+  const seenIds = new Set<string>()
+  const batches: BatchImportBatch[] = []
+  for (const item of parsed) {
+    if (!isBatchImportBatch(item, projectKey) || seenIds.has(item.id)) continue
+    seenIds.add(item.id)
+    batches.push(item)
+  }
+  return batches
+}
+export async function saveBatchImportBatch(batch: BatchImportBatch): Promise<void> {
+  const projectKey = normalizeProjectKey(batch.projectPath)
+  assertBatchImportBatch(batch, projectKey)
+
+  await withProjectLock(projectKey, async () => {
+    const root = importTasksRoot(projectKey)
+    const path = `${root}/batches.json`
+    await createDirectory(root)
+
+    let batches: BatchImportBatch[] = []
+    if (await fileExists(path)) {
+      try {
+        const parsed = JSON.parse(await readFile(path)) as unknown
+        if (!Array.isArray(parsed)) throw new Error("批次记录根结构不是数组")
+        batches = parsed.filter((item) => isBatchImportBatch(item, projectKey))
+      } catch (error) {
+        console.warn("批量导入:批次记录损坏,保存时已从空列表恢复", error)
+      }
+    }
+
+    const existingIndex = batches.findIndex((item) => item.id === batch.id)
+    if (existingIndex === -1) {
+      batches.push(batch)
+    } else {
+      batches[existingIndex] = batch
+    }
+
+    await writeFileAtomic(path, JSON.stringify(batches, null, 2))
+  })
+}
+
+export async function cacheTaskSource(
+  task: BatchImportTask,
+): Promise<BatchImportTask> {
+  const projectKey = assertTaskForPath(task)
+  return withBatchImportTaskLock(projectKey, task.id, async () => {
+    await ensureTaskDirectories(projectKey, task.id)
+    const cachedSourcePath = `${importTaskDir(projectKey, task.id)}/source.txt`
+    const sourceContent = await readFile(task.originalPath)
+    await writeFileAtomic(cachedSourcePath, sourceContent)
+    const cachedContent = await readFile(cachedSourcePath)
+    const cachedTask: BatchImportTask = {
+      ...task,
+      projectPath: projectKey,
+      cachedSourcePath,
+      sourceSha256: await hashNormalizedNovel(cachedContent),
+      updatedAt: Date.now(),
+    }
+    await saveBatchImportTaskUnlocked(cachedTask)
+    return cachedTask
+  })
+}
+
+export async function loadTaskCheckpoint(
+  task: BatchImportTask,
+): Promise<BatchImportCheckpoint | null> {
+  assertTaskForPath(task)
+  const path = taskCheckpointPath(task)
+  if (!(await fileExists(path))) return null
+  return JSON.parse(await readFile(path)) as BatchImportCheckpoint
+}
+
+export async function saveTaskCheckpointUnlocked(
+  task: BatchImportTask,
+  checkpoint: BatchImportCheckpoint,
+): Promise<void> {
+  assertTaskForPath(task)
+  await ensureTaskDirectories(task.projectPath, task.id)
+  await writeFileAtomic(taskCheckpointPath(task), JSON.stringify(checkpoint, null, 2))
+}
+
+export async function saveTaskCheckpoint(
+  task: BatchImportTask,
+  checkpoint: BatchImportCheckpoint,
+): Promise<void> {
+  await withBatchImportTaskLock(task.projectPath, task.id, async () => {
+    await saveTaskCheckpointUnlocked(task, checkpoint)
+  })
+}
+
+export async function cleanupCompletedTaskWorkspaceUnlocked(
+  task: BatchImportTask,
+): Promise<void> {
+  assertTaskForPath(task)
+  if (task.status !== "completed") {
+    throw new Error("只能清理已完成的批量导入任务工作区")
+  }
+
+  const dir = importTaskDir(task.projectPath, task.id)
+  const chaptersPath = `${dir}/chapters`
+  const checkpointPath = `${dir}/checkpoint.json`
+  if (await fileExists(chaptersPath)) await deleteFile(chaptersPath)
+  if (await fileExists(checkpointPath)) await deleteFile(checkpointPath)
+}
+
+export async function resetBatchImportTask(
+  projectPath: string,
+  taskId: string,
+): Promise<BatchImportTask> {
+  assertValidTaskId(taskId)
+  const projectKey = normalizeProjectKey(projectPath)
+  return withBatchImportTaskLock(projectKey, taskId, async () => {
+    const dir = importTaskDir(projectKey, taskId)
+    const path = taskFilePath(projectKey, taskId)
+    if (!(await fileExists(path))) {
+      throw new Error(`找不到批量导入任务:${taskId}`)
+    }
+
+    let parsed: unknown
+    try {
+      parsed = JSON.parse(await readFile(path)) as unknown
+    } catch {
+      throw new Error(`批量导入任务数据损坏:${taskId}`)
+    }
+    const validationError = validatePersistedTask(parsed, projectKey, taskId)
+    if (validationError) throw new Error(validationError)
+    const task = parsed as BatchImportTask
+    const resetTask: BatchImportTask = {
+      ...task,
+      projectPath: projectKey,
+      status: "queued",
+      completed: 0,
+      total: 0,
+      error: null,
+      skipReason: null,
+      startedAt: null,
+      completedAt: null,
+      updatedAt: Date.now(),
+    }
+
+    await saveBatchImportTaskUnlocked(resetTask, { ensureChaptersDirectory: false })
+
+    const chaptersPath = `${dir}/chapters`
+    const checkpointPath = `${dir}/checkpoint.json`
+    if (await fileExists(chaptersPath)) await deleteFile(chaptersPath)
+    if (await fileExists(checkpointPath)) await deleteFile(checkpointPath)
+    await createDirectory(chaptersPath)
+    return resetTask
+  })
+}

+ 56 - 0
src/lib/novel/book-analysis/batch-import-types.ts

@@ -0,0 +1,56 @@
+export type BatchImportTaskStatus =
+  | "queued"
+  | "copying"
+  | "splitting"
+  | "interrupted"
+  | "failed"
+  | "cancelled"
+  | "skipped"
+  | "completed"
+
+export interface BatchImportCandidate {
+  sourcePath: string
+  fileName: string
+  fileSize: number
+}
+
+export interface BatchImportCheckpoint {
+  version: 1
+  sourceSha256: string
+  totalChapters: number
+  completedChapterIndexes: number[]
+  totalWords: number
+  updatedAt: number
+}
+
+export interface BatchImportTask {
+  version: 1
+  id: string
+  batchId: string
+  projectPath: string
+  originalPath: string
+  originalFileName: string
+  cachedSourcePath: string
+  sourceSha256: string | null
+  requestedTitle: string
+  finalTitle: string | null
+  bookId: string
+  status: BatchImportTaskStatus
+  completed: number
+  total: number
+  error: string | null
+  skipReason: string | null
+  createdAt: number
+  startedAt: number | null
+  completedAt: number | null
+  updatedAt: number
+}
+
+export interface BatchImportBatch {
+  version: 1
+  id: string
+  projectPath: string
+  taskIds: string[]
+  createdAt: number
+  updatedAt: number
+}

+ 259 - 3
src/lib/novel/book-analysis/library-store.spec.ts

@@ -3,17 +3,66 @@
 import { describe, it, expect, beforeEach, vi } from "vitest"
 
 const memStore = new Map<string, string>()
+const unreadablePaths = new Set<string>()
 
 vi.mock("@/commands/fs", () => ({
-  readFile: vi.fn(async (path: string) => memStore.get(path) ?? ""),
+  readFile: vi.fn(async (path: string) => {
+    if (unreadablePaths.has(path)) throw new Error("文件不可读")
+    return memStore.get(path) ?? ""
+  }),
   writeFile: vi.fn(async (path: string, content: string) => { memStore.set(path, content) }),
+  writeFileAtomic: vi.fn(async (path: string, content: string) => { memStore.set(path, content) }),
   createDirectory: vi.fn(async () => undefined),
+  fileExists: vi.fn(async (path: string) => memStore.has(path)),
   listDirectory: vi.fn(async () => []),
 }))
 
-import { loadBookLibrary, upsertBookLibraryEntry, findBookLibraryEntry } from "./library-store"
+vi.mock("./batch-import-hash", async (importOriginal) => {
+  const actual = await importOriginal<typeof import("./batch-import-hash")>()
+  return { ...actual, hashNormalizedNovel: vi.fn(actual.hashNormalizedNovel) }
+})
+
+import { writeFileAtomic } from "@/commands/fs"
+import { __resetProjectLocksForTesting } from "@/lib/project-mutex"
+import { hashNormalizedNovel } from "./batch-import-hash"
+import {
+  findBookLibraryEntry,
+  findBookLibraryEntryBySha256,
+  loadBookLibrary,
+  removeBookLibraryEntry,
+  upsertBookLibraryEntry,
+} from "./library-store"
+
+const projectPath = "E:/Proj"
+const libraryFilePath = "E:/Proj/book-analysis/library.json"
+
+function entry(overrides: Record<string, unknown> = {}) {
+  return {
+    bookId: "book-1",
+    sourcePath: "E:/a.txt",
+    contentHash: "sample-hash",
+    title: "A",
+    totalChapters: 1,
+    totalWords: 100,
+    charactersCount: 0,
+    skillsCount: 0,
+    status: "completed" as const,
+    createdAt: 1,
+    updatedAt: 1,
+    ...overrides,
+  }
+}
 
-beforeEach(() => { memStore.clear() })
+function seedLibrary(entries: ReturnType<typeof entry>[]) {
+  memStore.set(libraryFilePath, JSON.stringify({ version: 1, entries }))
+}
+
+beforeEach(() => {
+  memStore.clear()
+  unreadablePaths.clear()
+  __resetProjectLocksForTesting()
+  vi.clearAllMocks()
+})
 
 describe("library-store", () => {
   it("loadBookLibrary: 文件不存在或损坏 → 返回空库", async () => {
@@ -49,6 +98,61 @@ describe("library-store", () => {
     expect(lib.entries[0].title).toBe("A (改)")
     expect(lib.entries[0].totalChapters).toBe(2)
   })
+  it("removeBookLibraryEntry: 原子删除指定条目并保留其他作品", async () => {
+    seedLibrary([
+      entry({ bookId: "book-1" }),
+      entry({ bookId: "book-2", title: "B" }),
+    ])
+
+    await removeBookLibraryEntry(projectPath, "book-1")
+
+    expect((await loadBookLibrary(projectPath)).entries.map((item) => item.bookId)).toEqual([
+      "book-2",
+    ])
+    expect(writeFileAtomic).toHaveBeenCalledTimes(1)
+    expect(writeFileAtomic).toHaveBeenCalledWith(
+      libraryFilePath,
+      JSON.stringify({ version: 1, entries: [entry({ bookId: "book-2", title: "B" })] }, null, 2),
+    )
+  })
+
+  it("removeBookLibraryEntry: 条目不存在时不重写索引", async () => {
+    seedLibrary([entry({ bookId: "book-2" })])
+
+    await removeBookLibraryEntry(projectPath, "book-missing")
+
+    expect((await loadBookLibrary(projectPath)).entries).toHaveLength(1)
+    expect(writeFileAtomic).not.toHaveBeenCalled()
+  })
+
+  it("removeBookLibraryEntry: 索引文件不存在时才按空库处理", async () => {
+    await expect(removeBookLibraryEntry(projectPath, "book-1")).resolves.toBeUndefined()
+    expect(writeFileAtomic).not.toHaveBeenCalled()
+  })
+
+  it("removeBookLibraryEntry: 已存在索引读取失败时拒绝且不写回", async () => {
+    seedLibrary([entry()])
+    unreadablePaths.add(libraryFilePath)
+
+    await expect(removeBookLibraryEntry(projectPath, "book-1")).rejects.toThrow("文件不可读")
+    expect(writeFileAtomic).not.toHaveBeenCalled()
+  })
+
+  it("removeBookLibraryEntry: 已存在索引 JSON 损坏时拒绝且不写回", async () => {
+    memStore.set(libraryFilePath, "{损坏 JSON")
+
+    await expect(removeBookLibraryEntry(projectPath, "book-1")).rejects.toBeInstanceOf(SyntaxError)
+    expect(writeFileAtomic).not.toHaveBeenCalled()
+  })
+
+  it("removeBookLibraryEntry: 已存在索引 schema 错误时拒绝且不写回", async () => {
+    memStore.set(libraryFilePath, JSON.stringify({ version: 1, entries: null }))
+
+    await expect(removeBookLibraryEntry(projectPath, "book-1")).rejects.toThrow(
+      "作品库索引数据无效",
+    )
+    expect(writeFileAtomic).not.toHaveBeenCalled()
+  })
   it("findBookLibraryEntry: 按 sourcePath 命中", async () => {
     await upsertBookLibraryEntry("E:/Proj", {
       bookId: "book-1", sourcePath: "E:/a.txt", contentHash: "abc",
@@ -69,4 +173,156 @@ describe("library-store", () => {
     const found = await findBookLibraryEntry("E:/Proj", "E:/a.txt", "different")
     expect(found).toBeUndefined()
   })
+  it("findBookLibraryEntryBySha256: 优先返回已有 contentSha256 并继续迁移旧条目", async () => {
+    seedLibrary([
+      entry({ sourcePath: "E:/existing.txt", contentSha256: "sha-a" }),
+      entry({ bookId: "book-2", sourcePath: "E:/legacy.txt", contentSha256: undefined }),
+    ])
+    memStore.set("E:/legacy.txt", "其他正文")
+
+    const found = await findBookLibraryEntryBySha256(projectPath, "sha-a")
+    const persisted = JSON.parse(memStore.get(libraryFilePath)!)
+
+    expect(found?.bookId).toBe("book-1")
+    expect(persisted.entries[1].contentSha256).toBe(await hashNormalizedNovel("其他正文"))
+    expect(writeFileAtomic).toHaveBeenCalledTimes(1)
+  })
+
+  it("loadBookLibrary: 旧索引没有 contentSha256 时仍可正常读取", async () => {
+    seedLibrary([entry({ contentSha256: undefined })])
+
+    const library = await loadBookLibrary(projectPath)
+
+    expect(library.entries).toHaveLength(1)
+    expect(library.entries[0].contentSha256).toBeUndefined()
+  })
+
+  it("findBookLibraryEntryBySha256: 旧条目完整正文哈希相同时回填并原子保存", async () => {
+    const sourceContent = "\ufeff第一章\r\n正文"
+    const contentSha256 = await hashNormalizedNovel(sourceContent)
+    seedLibrary([entry({ sourcePath: "E:/legacy.txt", contentSha256: undefined })])
+    memStore.set("E:/legacy.txt", sourceContent)
+
+    const found = await findBookLibraryEntryBySha256(projectPath, contentSha256)
+
+    expect(found?.bookId).toBe("book-1")
+    expect(found?.contentSha256).toBe(contentSha256)
+    expect(JSON.parse(memStore.get(libraryFilePath)!).entries[0].contentSha256).toBe(contentSha256)
+    expect(writeFileAtomic).toHaveBeenCalledTimes(1)
+  })
+
+  it("findBookLibraryEntryBySha256: 旧源文件不可读时不凭 contentHash 认定重复", async () => {
+    const contentSha256 = await hashNormalizedNovel("待导入正文")
+    seedLibrary([
+      entry({
+        sourcePath: "E:/missing.txt",
+        contentHash: contentSha256,
+        contentSha256: undefined,
+      }),
+    ])
+    unreadablePaths.add("E:/missing.txt")
+
+    const found = await findBookLibraryEntryBySha256(projectPath, contentSha256)
+
+    expect(found).toBeUndefined()
+    expect(JSON.parse(memStore.get(libraryFilePath)!).entries[0].contentSha256).toBeUndefined()
+    expect(writeFileAtomic).not.toHaveBeenCalled()
+  })
+
+  it("findBookLibraryEntryBySha256: 完整哈希不同时不认定重复但仍回填旧条目", async () => {
+    const contentSha256 = await hashNormalizedNovel("待导入正文")
+    const sourceSha256 = await hashNormalizedNovel("不同正文")
+    seedLibrary([
+      entry({
+        sourcePath: "E:/same-name.txt",
+        contentHash: contentSha256,
+        contentSha256: undefined,
+      }),
+    ])
+    memStore.set("E:/same-name.txt", "不同正文")
+
+    const found = await findBookLibraryEntryBySha256(projectPath, contentSha256)
+
+    expect(found).toBeUndefined()
+    expect(JSON.parse(memStore.get(libraryFilePath)!).entries[0].contentSha256).toBe(sourceSha256)
+    expect(writeFileAtomic).toHaveBeenCalledTimes(1)
+  })
+  it("findBookLibraryEntryBySha256: 回填原子保存失败时不认定重复", async () => {
+    const sourceContent = "旧条目正文"
+    const contentSha256 = await hashNormalizedNovel(sourceContent)
+    seedLibrary([entry({ sourcePath: "E:/legacy.txt", contentSha256: undefined })])
+    memStore.set("E:/legacy.txt", sourceContent)
+    vi.mocked(writeFileAtomic).mockRejectedValueOnce(new Error("保存失败"))
+
+    await expect(findBookLibraryEntryBySha256(projectPath, contentSha256)).rejects.toThrow("保存失败")
+    expect(JSON.parse(memStore.get(libraryFilePath)!).entries[0].contentSha256).toBeUndefined()
+  })
+  it("upsertBookLibraryEntry: 同项目并发交错时保留两个新增条目", async () => {
+    let notifyFirstWrite!: () => void
+    const firstWriteStarted = new Promise<void>((resolve) => { notifyFirstWrite = resolve })
+    let releaseFirstWrite!: () => void
+    const firstWriteGate = new Promise<void>((resolve) => { releaseFirstWrite = resolve })
+    vi.mocked(writeFileAtomic).mockImplementationOnce(async (path, content) => {
+      notifyFirstWrite()
+      await firstWriteGate
+      memStore.set(path, content)
+    })
+
+    const first = upsertBookLibraryEntry(projectPath, entry({ bookId: "book-1" }))
+    await firstWriteStarted
+    const second = upsertBookLibraryEntry(projectPath, entry({ bookId: "book-2" }))
+    await new Promise((resolve) => setTimeout(resolve, 0))
+    releaseFirstWrite()
+    await Promise.all([first, second])
+
+    const library = await loadBookLibrary(projectPath)
+    expect(library.entries.map((item) => item.bookId).sort()).toEqual(["book-1", "book-2"])
+  })
+
+  it("upsertBookLibraryEntry: 新建和更新时原样保存调用方提供的 contentSha256", async () => {
+    await upsertBookLibraryEntry(projectPath, entry({ contentSha256: "created-sha" }))
+    expect((await loadBookLibrary(projectPath)).entries[0].contentSha256).toBe("created-sha")
+
+    await upsertBookLibraryEntry(projectPath, entry({ contentSha256: "updated-sha" }))
+    const library = await loadBookLibrary(projectPath)
+    expect(library.entries).toHaveLength(1)
+    expect(library.entries[0].contentSha256).toBe("updated-sha")
+  })
+
+  it("findBookLibraryEntryBySha256: 一次扫描回填所有可读旧条目并只保存一次", async () => {
+    const firstContent = "第一本旧书"
+    const secondContent = "第二本旧书"
+    const firstSha256 = await hashNormalizedNovel(firstContent)
+    const secondSha256 = await hashNormalizedNovel(secondContent)
+    seedLibrary([
+      entry({ bookId: "book-1", sourcePath: "E:/first.txt", contentSha256: undefined }),
+      entry({ bookId: "book-2", sourcePath: "E:/second.txt", contentSha256: undefined }),
+      entry({ bookId: "book-3", sourcePath: "E:/missing.txt", contentSha256: undefined }),
+      entry({ bookId: "book-4", sourcePath: "E:/known.txt", contentSha256: "known-sha" }),
+    ])
+    memStore.set("E:/first.txt", firstContent)
+    memStore.set("E:/second.txt", secondContent)
+    unreadablePaths.add("E:/missing.txt")
+
+    const found = await findBookLibraryEntryBySha256(projectPath, firstSha256)
+    const persisted = JSON.parse(memStore.get(libraryFilePath)!)
+
+    expect(found?.bookId).toBe("book-1")
+    expect(persisted.entries.map((item: { contentSha256?: string }) => item.contentSha256)).toEqual([
+      firstSha256,
+      secondSha256,
+      undefined,
+      "known-sha",
+    ])
+    expect(writeFileAtomic).toHaveBeenCalledTimes(1)
+  })
+
+  it("findBookLibraryEntryBySha256: hashNormalizedNovel 异常向上传播", async () => {
+    seedLibrary([entry({ sourcePath: "E:/legacy.txt", contentSha256: undefined })])
+    memStore.set("E:/legacy.txt", "旧条目正文")
+    vi.mocked(hashNormalizedNovel).mockRejectedValueOnce(new Error("哈希失败"))
+
+    await expect(findBookLibraryEntryBySha256(projectPath, "target-sha")).rejects.toThrow("哈希失败")
+    expect(writeFileAtomic).not.toHaveBeenCalled()
+  })
 })

+ 93 - 13
src/lib/novel/book-analysis/library-store.ts

@@ -3,8 +3,10 @@
  * 拆书作品库索引(feature/book-analysis-reuse)
  * 存于 {projectPath}/book-analysis/library.json
  */
-import { readFile, writeFile, createDirectory } from "@/commands/fs"
+import { createDirectory, fileExists, readFile, writeFileAtomic } from "@/commands/fs"
 import { joinPath, normalizePath } from "@/lib/path-utils"
+import { withProjectLock } from "@/lib/project-mutex"
+import { hashNormalizedNovel } from "./batch-import-hash"
 import type { BookLibrary, BookLibraryEntry } from "./types"
 
 const LIBRARY_FILE = "library.json"
@@ -18,7 +20,7 @@ function emptyLibrary(): BookLibrary {
   return { version: VERSION, entries: [] }
 }
 
-export async function loadBookLibrary(projectPath: string): Promise<BookLibrary> {
+async function loadBookLibraryUnlocked(projectPath: string): Promise<BookLibrary> {
   try {
     const raw = await readFile(libraryPath(projectPath))
     if (!raw || !raw.trim()) return emptyLibrary()
@@ -33,22 +35,66 @@ export async function loadBookLibrary(projectPath: string): Promise<BookLibrary>
   }
 }
 
-export async function saveBookLibrary(projectPath: string, library: BookLibrary): Promise<void> {
-  await createDirectory(normalizePath(joinPath(projectPath, "book-analysis")))
-  await writeFile(libraryPath(projectPath), JSON.stringify(library, null, 2))
+export async function loadBookLibrary(projectPath: string): Promise<BookLibrary> {
+  return loadBookLibraryUnlocked(projectPath)
 }
 
-export async function upsertBookLibraryEntry(projectPath: string, entry: BookLibraryEntry): Promise<void> {
-  const library = await loadBookLibrary(projectPath)
-  const idx = library.entries.findIndex((e) => e.bookId === entry.bookId)
-  if (idx >= 0) {
-    library.entries[idx] = entry
-  } else {
-    library.entries.push(entry)
+async function loadBookLibraryStrictUnlocked(projectPath: string): Promise<BookLibrary> {
+  const path = libraryPath(projectPath)
+  if (!(await fileExists(path))) return emptyLibrary()
+
+  const parsed = JSON.parse(await readFile(path)) as unknown
+  if (
+    typeof parsed !== "object"
+    || parsed === null
+    || Array.isArray(parsed)
+    || (parsed as Partial<BookLibrary>).version !== VERSION
+    || !Array.isArray((parsed as Partial<BookLibrary>).entries)
+  ) {
+    throw new Error("作品库索引数据无效")
   }
-  await saveBookLibrary(projectPath, library)
+  return parsed as BookLibrary
 }
 
+async function saveBookLibraryUnlocked(
+  projectPath: string,
+  library: BookLibrary,
+): Promise<void> {
+  await createDirectory(normalizePath(joinPath(projectPath, "book-analysis")))
+  await writeFileAtomic(libraryPath(projectPath), JSON.stringify(library, null, 2))
+}
+
+export async function saveBookLibrary(projectPath: string, library: BookLibrary): Promise<void> {
+  await saveBookLibraryUnlocked(projectPath, library)
+}
+
+export async function upsertBookLibraryEntry(
+  projectPath: string,
+  entry: BookLibraryEntry,
+): Promise<void> {
+  await withProjectLock(normalizePath(projectPath), async () => {
+    const library = await loadBookLibraryUnlocked(projectPath)
+    const idx = library.entries.findIndex((e) => e.bookId === entry.bookId)
+    if (idx >= 0) {
+      library.entries[idx] = entry
+    } else {
+      library.entries.push(entry)
+    }
+    await saveBookLibraryUnlocked(projectPath, library)
+  })
+}
+
+export async function removeBookLibraryEntry(
+  projectPath: string,
+  bookId: string,
+): Promise<void> {
+  await withProjectLock(normalizePath(projectPath), async () => {
+    const library = await loadBookLibraryStrictUnlocked(projectPath)
+    const entries = library.entries.filter((entry) => entry.bookId !== bookId)
+    if (entries.length === library.entries.length) return
+    await saveBookLibraryUnlocked(projectPath, { ...library, entries })
+  })
+}
 export async function findBookLibraryEntry(
   projectPath: string,
   sourcePath: string,
@@ -60,3 +106,37 @@ export async function findBookLibraryEntry(
     (e) => normalizePath(e.sourcePath) === normalized && e.contentHash === contentHash,
   )
 }
+
+export async function findBookLibraryEntryBySha256(
+  projectPath: string,
+  contentSha256: string,
+): Promise<BookLibraryEntry | undefined> {
+  return withProjectLock(normalizePath(projectPath), async () => {
+    const library = await loadBookLibraryUnlocked(projectPath)
+    let matched = library.entries.find((entry) => entry.contentSha256 === contentSha256)
+    let changed = false
+
+    for (const entry of library.entries) {
+      if (entry.contentSha256 !== undefined) continue
+
+      let sourceContent: string
+      try {
+        sourceContent = await readFile(entry.sourcePath)
+      } catch {
+        continue
+      }
+
+      const sourceSha256 = await hashNormalizedNovel(sourceContent)
+      entry.contentSha256 = sourceSha256
+      changed = true
+      if (!matched && sourceSha256 === contentSha256) {
+        matched = entry
+      }
+    }
+
+    if (changed) {
+      await saveBookLibraryUnlocked(projectPath, library)
+    }
+    return matched
+  })
+}

+ 2 - 1
src/lib/novel/book-analysis/types.ts

@@ -284,7 +284,8 @@ export interface BookAnalysisLibrary {
 export interface BookLibraryEntry {
   bookId: string
   sourcePath: string         // 标准化路径
-  contentHash: string        // fingerprintFileSample 结果
+  contentHash: string        // 旧版 fingerprintFileSample 采样指纹,保留以兼容已有索引
+  contentSha256?: string      // 规范化完整正文的 SHA-256;旧版索引可能不存在
   title: string
   author?: string
   totalChapters: number

+ 647 - 0
src/stores/book-analysis-import-store.spec.ts

@@ -0,0 +1,647 @@
+import { beforeEach, describe, expect, it, vi } from "vitest"
+import type {
+  BatchImportBatch,
+  BatchImportCandidate,
+  BatchImportTask,
+} from "@/lib/novel/book-analysis/batch-import-types"
+
+const mocks = vi.hoisted(() => {
+  const schedulers: Array<{
+    enqueue: ReturnType<typeof vi.fn>
+    continueTask: ReturnType<typeof vi.fn>
+    regenerateTask: ReturnType<typeof vi.fn>
+    cancelTask: ReturnType<typeof vi.fn>
+    cancelAllQueued: ReturnType<typeof vi.fn>
+    subscribe: ReturnType<typeof vi.fn>
+    dispose: ReturnType<typeof vi.fn>
+    emit(tasks: BatchImportTask[]): void
+  }> = []
+  return {
+    schedulers,
+    loadBatchImportTasks: vi.fn(),
+    loadBatchImportBatches: vi.fn(),
+    saveBatchImportTask: vi.fn(),
+    saveBatchImportBatch: vi.fn(),
+    cacheTaskSource: vi.fn(),
+    loadBookLibrary: vi.fn(),
+    findBookLibraryEntryBySha256: vi.fn(),
+    fileExists: vi.fn(),
+    readFile: vi.fn(),
+  }
+})
+
+vi.mock("@/lib/novel/book-analysis/batch-import-scheduler", () => ({
+  createBatchImportScheduler: vi.fn(() => {
+    let listener: ((tasks: BatchImportTask[]) => void) | null = null
+    const scheduler = {
+      enqueue: vi.fn(),
+      continueTask: vi.fn().mockResolvedValue(undefined),
+      regenerateTask: vi.fn().mockResolvedValue(undefined),
+      cancelTask: vi.fn().mockResolvedValue(undefined),
+      cancelAllQueued: vi.fn().mockResolvedValue(undefined),
+      subscribe: vi.fn((nextListener: (tasks: BatchImportTask[]) => void) => {
+        listener = nextListener
+        nextListener([])
+        return vi.fn(() => { listener = null })
+      }),
+      dispose: vi.fn().mockResolvedValue(undefined),
+      emit(tasks: BatchImportTask[]) { listener?.(tasks) },
+    }
+    mocks.schedulers.push(scheduler)
+    return scheduler
+  }),
+}))
+
+vi.mock("@/lib/novel/book-analysis/batch-import-storage", () => ({
+  importTasksRoot: (projectPath: string) => `${projectPath}/book-analysis/import-tasks`,
+  loadBatchImportTasks: mocks.loadBatchImportTasks,
+  loadBatchImportBatches: mocks.loadBatchImportBatches,
+  saveBatchImportTask: mocks.saveBatchImportTask,
+  saveBatchImportBatch: mocks.saveBatchImportBatch,
+  cacheTaskSource: mocks.cacheTaskSource,
+}))
+
+vi.mock("@/lib/novel/book-analysis/library-store", () => ({
+  loadBookLibrary: mocks.loadBookLibrary,
+  findBookLibraryEntryBySha256: mocks.findBookLibraryEntryBySha256,
+}))
+
+vi.mock("@/commands/fs", () => ({
+  fileExists: mocks.fileExists,
+  readFile: mocks.readFile,
+}))
+
+import { createBookAnalysisImportStore } from "./book-analysis-import-store"
+
+const PROJECT_A = "E:/Novel-A"
+const PROJECT_B = "E:/Novel-B"
+function deferred<T>() {
+  let resolve!: (value: T) => void
+  const promise = new Promise<T>((done) => { resolve = done })
+  return { promise, resolve }
+}
+
+async function waitFor(predicate: () => boolean): Promise<void> {
+  for (let index = 0; index < 50; index += 1) {
+    if (predicate()) return
+    await new Promise((resolve) => setTimeout(resolve, 0))
+  }
+  throw new Error("等待异步状态超时")
+}
+
+function makeTask(id: string, overrides: Partial<BatchImportTask> = {}): BatchImportTask {
+  return {
+    version: 1,
+    id,
+    batchId: "batch-1",
+    projectPath: PROJECT_A,
+    originalPath: `E:/Sources/${id}.txt`,
+    originalFileName: `${id}.txt`,
+    cachedSourcePath: "",
+    sourceSha256: null,
+    requestedTitle: id,
+    finalTitle: null,
+    bookId: `book-${id}`,
+    status: "interrupted",
+    completed: 0,
+    total: 0,
+    error: null,
+    skipReason: null,
+    createdAt: 100,
+    startedAt: null,
+    completedAt: null,
+    updatedAt: 100,
+    ...overrides,
+  }
+}
+
+function candidate(fileName: string): BatchImportCandidate {
+  return { sourcePath: `E:/Sources/${fileName}`, fileName, fileSize: 10 }
+}
+
+function persistedBatch(projectPath: string): BatchImportBatch {
+  return { version: 1, id: "old-batch", projectPath, taskIds: ["old-task"], createdAt: 1, updatedAt: 1 }
+}
+
+describe("book analysis import store", () => {
+  beforeEach(() => {
+    vi.clearAllMocks()
+    mocks.schedulers.length = 0
+    mocks.loadBatchImportTasks.mockResolvedValue([])
+    mocks.loadBatchImportBatches.mockResolvedValue([])
+    mocks.saveBatchImportTask.mockResolvedValue(undefined)
+    mocks.saveBatchImportBatch.mockResolvedValue(undefined)
+    mocks.loadBookLibrary.mockResolvedValue({ version: 1, entries: [] })
+    mocks.findBookLibraryEntryBySha256.mockResolvedValue(undefined)
+    mocks.fileExists.mockResolvedValue(false)
+    mocks.readFile.mockResolvedValue("[]")
+    mocks.cacheTaskSource.mockImplementation(async (task: BatchImportTask) => ({
+      ...task,
+      cachedSourcePath: `${task.projectPath}/cache/${task.id}.txt`,
+      sourceSha256: `sha-${task.originalFileName}`,
+    }))
+  })
+
+  it("切换项目时释放旧调度器和订阅并重置面板手动状态", async () => {
+    const store = createBookAnalysisImportStore()
+    await store.getState().initializeProject(PROJECT_A)
+    store.getState().setPanelCollapsed(true)
+    const first = mocks.schedulers[0]
+    const unsubscribe = first.subscribe.mock.results[0]?.value
+
+    await store.getState().initializeProject(PROJECT_B)
+
+    expect(first.dispose).toHaveBeenCalledOnce()
+    expect(unsubscribe).toHaveBeenCalledOnce()
+    expect(store.getState().projectPath).toBe(PROJECT_B)
+    expect(store.getState().panelTouched).toBe(false)
+  })
+
+  it("项目切换会等待旧 scheduler dispose 完成后再创建新 scheduler", async () => {
+    const disposeGate = deferred<void>()
+    const store = createBookAnalysisImportStore()
+    await store.getState().initializeProject(PROJECT_A)
+    const first = mocks.schedulers[0]
+    first.dispose.mockReturnValue(disposeGate.promise)
+
+    let switched = false
+    const switching = store.getState().initializeProject(PROJECT_B).then(() => { switched = true })
+    await waitFor(() => first.dispose.mock.calls.length === 1)
+    await new Promise((resolve) => setTimeout(resolve, 0))
+
+    expect(switched).toBe(false)
+    expect(mocks.schedulers).toHaveLength(1)
+    disposeGate.resolve()
+    await switching
+    expect(mocks.schedulers).toHaveLength(2)
+    expect(store.getState().projectPath).toBe(PROJECT_B)
+  })
+  it("initializeProject 调用后未 await 时立即动作不触达旧 scheduler", async () => {
+    const oldTask = makeTask("old-task")
+    mocks.loadBatchImportTasks.mockResolvedValueOnce([oldTask]).mockResolvedValueOnce([])
+    const store = createBookAnalysisImportStore()
+    await store.getState().initializeProject(PROJECT_A)
+    const oldScheduler = mocks.schedulers[0]
+
+    const switching = store.getState().initializeProject(PROJECT_B)
+    const continuing = store.getState().continueTask(oldTask.id)
+    const regenerating = store.getState().regenerateTask(oldTask.id)
+    const cancelling = store.getState().cancelTask(oldTask.id)
+
+    await Promise.all([
+      expect(continuing).rejects.toThrow("请先初始化拆书项目"),
+      expect(regenerating).rejects.toThrow("请先初始化拆书项目"),
+      expect(cancelling).rejects.toThrow("请先初始化拆书项目"),
+    ])
+    expect(oldScheduler.enqueue).not.toHaveBeenCalled()
+    expect(oldScheduler.continueTask).not.toHaveBeenCalled()
+    expect(oldScheduler.regenerateTask).not.toHaveBeenCalled()
+    expect(oldScheduler.cancelTask).not.toHaveBeenCalled()
+    await switching
+  })
+
+  it("dispose 调用后未 await 时立即动作不触达旧 scheduler", async () => {
+    const oldTask = makeTask("old-task")
+    mocks.loadBatchImportTasks.mockResolvedValue([oldTask])
+    const store = createBookAnalysisImportStore()
+    await store.getState().initializeProject(PROJECT_A)
+    const oldScheduler = mocks.schedulers[0]
+
+    const disposing = store.getState().dispose()
+    const continuing = store.getState().continueTask(oldTask.id)
+    const regenerating = store.getState().regenerateTask(oldTask.id)
+    const cancelling = store.getState().cancelTask(oldTask.id)
+
+    await Promise.all([
+      expect(continuing).rejects.toThrow("请先初始化拆书项目"),
+      expect(regenerating).rejects.toThrow("请先初始化拆书项目"),
+      expect(cancelling).rejects.toThrow("请先初始化拆书项目"),
+    ])
+    expect(oldScheduler.enqueue).not.toHaveBeenCalled()
+    expect(oldScheduler.continueTask).not.toHaveBeenCalled()
+    expect(oldScheduler.regenerateTask).not.toHaveBeenCalled()
+    expect(oldScheduler.cancelTask).not.toHaveBeenCalled()
+    await disposing
+  })
+  it("初始化加载任务与批次但不自动入队任何持久 queued 或 interrupted 任务", async () => {
+    const tasks = [
+      makeTask("queued-old", { status: "queued" }),
+      makeTask("interrupted-old", { status: "interrupted" }),
+    ]
+    mocks.loadBatchImportTasks.mockResolvedValue(tasks)
+    mocks.loadBatchImportBatches.mockResolvedValue([persistedBatch(PROJECT_A)])
+    const store = createBookAnalysisImportStore()
+
+    await store.getState().initializeProject(PROJECT_A)
+
+    expect(store.getState().tasks.map((task) => task.id)).toEqual(["queued-old", "interrupted-old"])
+    expect(store.getState().batches).toHaveLength(1)
+    expect(mocks.schedulers[0].enqueue).not.toHaveBeenCalled()
+  })
+
+  it("创建批次按完整 SHA 查重,并为已有作品名与本批同名作品连续编号", async () => {
+    mocks.loadBookLibrary.mockResolvedValue({
+      version: 1,
+      entries: [{ title: "长夜", contentSha256: "library-sha" }],
+    })
+    mocks.cacheTaskSource
+      .mockImplementationOnce(async (task: BatchImportTask) => ({ ...task, sourceSha256: "library-sha", cachedSourcePath: "cache/1" }))
+      .mockImplementationOnce(async (task: BatchImportTask) => ({ ...task, sourceSha256: "new-sha", cachedSourcePath: "cache/2" }))
+      .mockImplementationOnce(async (task: BatchImportTask) => ({ ...task, sourceSha256: "new-sha", cachedSourcePath: "cache/3" }))
+    mocks.findBookLibraryEntryBySha256.mockImplementation(async (_path: string, sha: string) => (
+      sha === "library-sha" ? { title: "长夜" } : undefined
+    ))
+    const store = createBookAnalysisImportStore()
+    await store.getState().initializeProject(PROJECT_A)
+
+    await store.getState().createBatch([candidate("长夜.txt"), candidate("长夜.txt"), candidate("长夜.txt")])
+
+    const tasks = store.getState().tasks
+    expect(tasks.map((task) => task.status)).toEqual(["skipped", "queued", "skipped"])
+    expect(tasks[0].skipReason).toContain("相同内容")
+    expect(tasks[1].finalTitle).toBe("长夜(2)")
+    expect(tasks[2].skipReason).toContain("本批次")
+    expect(tasks.every((task) => /^[A-Za-z0-9_-]+$/.test(task.id))).toBe(true)
+    expect(store.getState().batches[0].taskIds).toEqual(tasks.map((task) => task.id))
+    expect(mocks.schedulers[0].enqueue).toHaveBeenCalledWith([tasks[1]])
+  })
+
+  it("单文件缓存失败只标记该任务失败并继续处理其他文件", async () => {
+    mocks.cacheTaskSource
+      .mockRejectedValueOnce(new Error("磁盘不可读"))
+      .mockImplementationOnce(async (task: BatchImportTask) => ({ ...task, sourceSha256: "ok-sha", cachedSourcePath: "cache/ok" }))
+    const store = createBookAnalysisImportStore()
+    await store.getState().initializeProject(PROJECT_A)
+
+    await store.getState().createBatch([candidate("坏文件.txt"), candidate("好文件.txt")])
+
+    expect(store.getState().tasks.map((task) => task.status)).toEqual(["failed", "queued"])
+    expect(store.getState().tasks[0].error).toContain("缓存源文件失败")
+    expect(mocks.schedulers[0].enqueue).toHaveBeenCalledWith([store.getState().tasks[1]])
+  })
+
+  it("有待处理任务时默认展开,用户手动折叠后普通进度更新不覆盖", async () => {
+    mocks.loadBatchImportTasks.mockResolvedValue([makeTask("failed", { status: "failed" })])
+    const store = createBookAnalysisImportStore()
+    await store.getState().initializeProject(PROJECT_A)
+    expect(store.getState().panelCollapsed).toBe(false)
+
+    store.getState().setPanelCollapsed(true)
+    mocks.schedulers[0].emit([makeTask("running", { status: "splitting" })])
+
+    expect(store.getState().panelCollapsed).toBe(true)
+    expect(store.getState().panelTouched).toBe(true)
+  })
+
+  it("显式操作持久任务时才注册到调度器,并可取消未自动恢复的 queued", async () => {
+    const queued = makeTask("queued-old", { status: "queued", batchId: "batch-old" })
+    const failed = makeTask("failed-old", { status: "failed" })
+    mocks.loadBatchImportTasks.mockResolvedValue([queued, failed])
+    const store = createBookAnalysisImportStore()
+    await store.getState().initializeProject(PROJECT_A)
+    const scheduler = mocks.schedulers[0]
+
+    await store.getState().cancelTask(queued.id)
+    expect(store.getState().tasks.find((task) => task.id === queued.id)?.status).toBe("cancelled")
+    expect(mocks.saveBatchImportTask).toHaveBeenCalledWith(expect.objectContaining({
+      id: queued.id,
+      status: "cancelled",
+    }))
+    expect(scheduler.cancelTask).toHaveBeenCalledWith(queued.id)
+
+    await store.getState().regenerateTask(failed.id)
+    expect(scheduler.enqueue).toHaveBeenCalledWith([failed])
+    expect(scheduler.regenerateTask).toHaveBeenCalledWith(failed.id)
+  })
+  it("动作委托给当前项目调度器,完成任务时递增 revision 并支持释放", async () => {
+    const onRevision = vi.fn()
+    const store = createBookAnalysisImportStore({ onRevision })
+    await store.getState().initializeProject(PROJECT_A)
+    const scheduler = mocks.schedulers[0]
+
+    await store.getState().continueTask("task-1")
+    await store.getState().regenerateTask("task-2")
+    await store.getState().cancelTask("task-3")
+    await store.getState().cancelAllQueued("batch-9")
+    scheduler.emit([makeTask("done", { status: "completed" })])
+
+    expect(scheduler.continueTask).toHaveBeenCalledWith("task-1")
+    expect(scheduler.regenerateTask).toHaveBeenCalledWith("task-2")
+    expect(scheduler.cancelTask).toHaveBeenCalledWith("task-3")
+    expect(scheduler.cancelAllQueued).toHaveBeenCalledWith("batch-9")
+    expect(store.getState().revision).toBe(1)
+    expect(onRevision).toHaveBeenCalledOnce()
+
+    await store.getState().dispose()
+    expect(scheduler.dispose).toHaveBeenCalledOnce()
+    expect(store.getState().projectPath).toBeNull()
+  })
+  it("延迟初始化旧项目不会覆盖后初始化的新项目", async () => {
+    const gate = deferred<BatchImportTask[]>()
+    mocks.loadBatchImportTasks.mockImplementation((path: string) => (
+      path === PROJECT_A ? gate.promise : Promise.resolve([makeTask("project-b", { projectPath: PROJECT_B })])
+    ))
+    const store = createBookAnalysisImportStore()
+    const first = store.getState().initializeProject(PROJECT_A)
+    await store.getState().initializeProject(PROJECT_B)
+    gate.resolve([makeTask("project-a")])
+    await first
+
+    expect(store.getState().projectPath).toBe(PROJECT_B)
+    expect(store.getState().tasks.map((task) => task.id)).toEqual(["project-b"])
+    expect(mocks.schedulers).toHaveLength(1)
+  })
+
+  it("createBatch 缓存期间切项目会把当前 copying 落盘为 interrupted 且不入队", async () => {
+    const gate = deferred<BatchImportTask>()
+    mocks.cacheTaskSource.mockImplementationOnce(() => gate.promise)
+    const store = createBookAnalysisImportStore()
+    await store.getState().initializeProject(PROJECT_A)
+    const oldScheduler = mocks.schedulers[0]
+    const creating = store.getState().createBatch([candidate("长夜.txt"), candidate("星河.txt")])
+    await waitFor(() => mocks.cacheTaskSource.mock.calls.length === 1)
+
+    await store.getState().initializeProject(PROJECT_B)
+    const copying = mocks.cacheTaskSource.mock.calls[0][0] as BatchImportTask
+    gate.resolve({ ...copying, cachedSourcePath: "cache/source", sourceSha256: "sha" })
+    await creating
+
+    expect(mocks.saveBatchImportTask).toHaveBeenCalledWith(expect.objectContaining({
+      id: copying.id,
+      status: "interrupted",
+      error: expect.stringContaining("项目已切换"),
+    }))
+    expect(oldScheduler.enqueue).not.toHaveBeenCalled()
+  })
+
+  it("取消等待期间切项目不会把旧项目结果写入新项目内存", async () => {
+    const oldTask = makeTask("same-id", { status: "queued" })
+    const newTask = makeTask("same-id", { projectPath: PROJECT_B, status: "queued" })
+    mocks.loadBatchImportTasks.mockImplementation((path: string) => Promise.resolve(path === PROJECT_A ? [oldTask] : [newTask]))
+    const store = createBookAnalysisImportStore()
+    await store.getState().initializeProject(PROJECT_A)
+    const gate = deferred<void>()
+    mocks.schedulers[0].cancelTask.mockReturnValueOnce(gate.promise)
+    const cancelling = store.getState().cancelTask(oldTask.id)
+    await waitFor(() => mocks.schedulers[0].cancelTask.mock.calls.length === 1)
+
+    await store.getState().initializeProject(PROJECT_B)
+    gate.resolve()
+    await cancelling
+
+    expect(store.getState().tasks[0]).toMatchObject({ projectPath: PROJECT_B, status: "queued" })
+  })
+
+  it("completed 任务 regenerate 后再次 completed 会再次递增 revision", async () => {
+    const completed = makeTask("again", { status: "completed" })
+    const store = createBookAnalysisImportStore()
+    await store.getState().initializeProject(PROJECT_A)
+    const scheduler = mocks.schedulers[0]
+    scheduler.emit([completed])
+    expect(store.getState().revision).toBe(1)
+
+    await store.getState().regenerateTask(completed.id)
+    scheduler.emit([{ ...completed, status: "splitting" }])
+    scheduler.emit([completed])
+    expect(store.getState().revision).toBe(2)
+  })
+
+  it("批次先保存,任务先保存 queued 再保存 copying,之后才缓存源文件", async () => {
+    const store = createBookAnalysisImportStore()
+    await store.getState().initializeProject(PROJECT_A)
+    await store.getState().createBatch([candidate("顺序.txt")])
+
+    const batchOrder = mocks.saveBatchImportBatch.mock.invocationCallOrder[0]
+    const queuedCall = mocks.saveBatchImportTask.mock.calls.findIndex(([task]) => task.status === "queued")
+    const copyingCall = mocks.saveBatchImportTask.mock.calls.findIndex(([task]) => task.status === "copying")
+    expect(batchOrder).toBeLessThan(mocks.saveBatchImportTask.mock.invocationCallOrder[queuedCall])
+    expect(mocks.saveBatchImportTask.mock.invocationCallOrder[queuedCall]).toBeLessThan(
+      mocks.saveBatchImportTask.mock.invocationCallOrder[copyingCall],
+    )
+    expect(mocks.saveBatchImportTask.mock.invocationCallOrder[copyingCall]).toBeLessThan(
+      mocks.cacheTaskSource.mock.invocationCallOrder[0],
+    )
+  })
+
+  it("interrupted 任务仅在显式 continue 时注册并继续", async () => {
+    const interrupted = makeTask("resume", { status: "interrupted" })
+    mocks.loadBatchImportTasks.mockResolvedValue([interrupted])
+    const store = createBookAnalysisImportStore()
+    await store.getState().initializeProject(PROJECT_A)
+    const scheduler = mocks.schedulers[0]
+    expect(scheduler.enqueue).not.toHaveBeenCalled()
+
+    await store.getState().continueTask(interrupted.id)
+    expect(scheduler.enqueue).toHaveBeenCalledWith([interrupted])
+    expect(scheduler.continueTask).toHaveBeenCalledWith(interrupted.id)
+  })
+
+  it("持久 queued 批次 cancelAll 会逐项持久化取消并委托调度器", async () => {
+    const first = makeTask("queued-1", { status: "queued", batchId: "batch-old" })
+    const second = makeTask("queued-2", { status: "queued", batchId: "batch-old" })
+    mocks.loadBatchImportTasks.mockResolvedValue([first, second])
+    const store = createBookAnalysisImportStore()
+    await store.getState().initializeProject(PROJECT_A)
+
+    await store.getState().cancelAllQueued("batch-old")
+
+    expect(store.getState().tasks.map((task) => task.status)).toEqual(["cancelled", "cancelled"])
+    expect(mocks.schedulers[0].cancelAllQueued).toHaveBeenCalledWith("batch-old")
+  })
+
+  it("并发创建的跨批次同名作品按 store 队列串行保留名称", async () => {
+    let hashIndex = 0
+    mocks.cacheTaskSource.mockImplementation(async (task: BatchImportTask) => ({
+      ...task,
+      cachedSourcePath: `cache/${task.id}`,
+      sourceSha256: `sha-${++hashIndex}`,
+    }))
+    const store = createBookAnalysisImportStore()
+    await store.getState().initializeProject(PROJECT_A)
+
+    await Promise.all([
+      store.getState().createBatch([candidate("长夜.txt")]),
+      store.getState().createBatch([candidate("长夜.txt")]),
+    ])
+
+    expect(store.getState().tasks.map((task) => task.finalTitle)).toEqual(["长夜", "长夜(2)"])
+  })
+  it("切换项目后 cacheTaskSource reject 会保存 interrupted 而不是 failed", async () => {
+    const gate = deferred<never>()
+    mocks.cacheTaskSource.mockImplementationOnce(() => gate.promise)
+    const store = createBookAnalysisImportStore()
+    await store.getState().initializeProject(PROJECT_A)
+    const oldScheduler = mocks.schedulers[0]
+    const creating = store.getState().createBatch([candidate("缓存失败.txt"), candidate("不应继续.txt")])
+    await waitFor(() => mocks.cacheTaskSource.mock.calls.length === 1)
+    const copying = mocks.cacheTaskSource.mock.calls[0][0] as BatchImportTask
+
+    await store.getState().initializeProject(PROJECT_B)
+    gate.resolve(Promise.reject(new Error("旧项目缓存失败")) as never)
+    await creating
+
+    const savedStates = mocks.saveBatchImportTask.mock.calls
+      .map(([task]) => task as BatchImportTask)
+      .filter((task) => task.id === copying.id)
+    expect(savedStates.at(-1)).toMatchObject({
+      status: "interrupted",
+      error: expect.stringContaining("项目已切换"),
+    })
+    expect(savedStates.some((task) => task.status === "failed")).toBe(false)
+    expect(mocks.cacheTaskSource).toHaveBeenCalledTimes(1)
+    expect(oldScheduler.enqueue).not.toHaveBeenCalled()
+    expect(store.getState().projectPath).toBe(PROJECT_B)
+  })
+
+  it("切换项目后查重 reject 会保存 interrupted 而不是 failed", async () => {
+    const gate = deferred<never>()
+    mocks.findBookLibraryEntryBySha256.mockImplementationOnce(() => gate.promise)
+    const store = createBookAnalysisImportStore()
+    await store.getState().initializeProject(PROJECT_A)
+    const oldScheduler = mocks.schedulers[0]
+    const creating = store.getState().createBatch([candidate("查重失败.txt"), candidate("不应继续.txt")])
+    await waitFor(() => mocks.findBookLibraryEntryBySha256.mock.calls.length === 1)
+    const copying = mocks.cacheTaskSource.mock.calls[0][0] as BatchImportTask
+
+    await store.getState().initializeProject(PROJECT_B)
+    gate.resolve(Promise.reject(new Error("旧项目查重失败")) as never)
+    await creating
+
+    const savedStates = mocks.saveBatchImportTask.mock.calls
+      .map(([task]) => task as BatchImportTask)
+      .filter((task) => task.id === copying.id)
+    expect(savedStates.at(-1)).toMatchObject({
+      status: "interrupted",
+      error: expect.stringContaining("项目已切换"),
+    })
+    expect(savedStates.some((task) => task.status === "failed")).toBe(false)
+    expect(mocks.cacheTaskSource).toHaveBeenCalledTimes(1)
+    expect(oldScheduler.enqueue).not.toHaveBeenCalled()
+    expect(store.getState().projectPath).toBe(PROJECT_B)
+  })
+  it("failed 状态保存阻塞期间切换项目会以 interrupted 收尾且不继续旧批次", async () => {
+    const failedSaveGate = deferred<void>()
+    mocks.findBookLibraryEntryBySha256.mockRejectedValueOnce(new Error("当前项目查重失败"))
+    mocks.saveBatchImportTask.mockImplementation((task: BatchImportTask) => (
+      task.status === "failed" ? failedSaveGate.promise : Promise.resolve()
+    ))
+    const store = createBookAnalysisImportStore()
+    await store.getState().initializeProject(PROJECT_A)
+    const oldScheduler = mocks.schedulers[0]
+
+    const creating = store.getState().createBatch([candidate("查重失败.txt"), candidate("不应继续.txt")])
+    await waitFor(() => mocks.saveBatchImportTask.mock.calls.some(([task]) => task.status === "failed"))
+    const failedTask = mocks.saveBatchImportTask.mock.calls.find(([task]) => task.status === "failed")?.[0] as BatchImportTask
+    const switching = store.getState().initializeProject(PROJECT_B)
+    await switching
+    failedSaveGate.resolve()
+    await creating
+
+    const savedStates = mocks.saveBatchImportTask.mock.calls
+      .map(([task]) => task as BatchImportTask)
+      .filter((task) => task.id === failedTask.id)
+    expect(savedStates.at(-1)).toMatchObject({
+      status: "interrupted",
+      error: expect.stringContaining("项目已切换"),
+    })
+    expect(oldScheduler.enqueue).not.toHaveBeenCalled()
+    expect(mocks.cacheTaskSource).toHaveBeenCalledTimes(1)
+    expect(store.getState().projectPath).toBe(PROJECT_B)
+  })
+  it("cancelAllQueued 保存阻塞期间拒绝 continue 和 regenerate 启动同任务", async () => {
+    const queued = makeTask("queued-race", { status: "queued", batchId: "batch-race" })
+    const saveGate = deferred<void>()
+    mocks.loadBatchImportTasks.mockResolvedValue([queued])
+    mocks.saveBatchImportTask.mockImplementation((task: BatchImportTask) => (
+      task.id === queued.id && task.status === "cancelled" ? saveGate.promise : Promise.resolve()
+    ))
+    const store = createBookAnalysisImportStore()
+    await store.getState().initializeProject(PROJECT_A)
+    const scheduler = mocks.schedulers[0]
+
+    const cancellingAll = store.getState().cancelAllQueued(queued.batchId)
+    await waitFor(() => mocks.saveBatchImportTask.mock.calls.some(([task]) => task.id === queued.id && task.status === "cancelled"))
+    const continuing = store.getState().continueTask(queued.id)
+    const regenerating = store.getState().regenerateTask(queued.id)
+
+    await Promise.all([
+      expect(continuing).rejects.toThrow("任务正在取消"),
+      expect(regenerating).rejects.toThrow("任务正在取消"),
+    ])
+    expect(scheduler.enqueue).not.toHaveBeenCalled()
+    expect(scheduler.continueTask).not.toHaveBeenCalled()
+    expect(scheduler.regenerateTask).not.toHaveBeenCalled()
+    saveGate.resolve()
+    await cancellingAll
+    expect(store.getState().tasks[0].status).toBe("cancelled")
+  })
+
+  it("批次保存阻塞期间切换项目后只持久化 interrupted 且不处理旧任务", async () => {
+    const batchGate = deferred<void>()
+    mocks.saveBatchImportBatch.mockReturnValueOnce(batchGate.promise).mockResolvedValue(undefined)
+    const store = createBookAnalysisImportStore()
+    await store.getState().initializeProject(PROJECT_A)
+    const oldScheduler = mocks.schedulers[0]
+
+    const creating = store.getState().createBatch([candidate("旧一.txt"), candidate("旧二.txt")])
+    await waitFor(() => mocks.saveBatchImportBatch.mock.calls.length === 1)
+    const switching = store.getState().initializeProject(PROJECT_B)
+    batchGate.resolve()
+    await Promise.all([creating, switching])
+
+    const oldTaskSaves = mocks.saveBatchImportTask.mock.calls
+      .map(([task]) => task as BatchImportTask)
+      .filter((task) => task.projectPath === PROJECT_A)
+    expect(oldTaskSaves).toHaveLength(2)
+    expect(oldTaskSaves.every((task) => task.status === "interrupted")).toBe(true)
+    expect(mocks.cacheTaskSource).not.toHaveBeenCalled()
+    expect(oldScheduler.enqueue).not.toHaveBeenCalled()
+  })
+
+  it("copying 状态保存失败仅隔离当前文件并继续下一文件", async () => {
+    mocks.saveBatchImportTask.mockImplementation((task: BatchImportTask) => (
+      task.originalFileName === "坏复制.txt" && task.status === "copying"
+        ? Promise.reject(new Error("copying 写盘失败"))
+        : Promise.resolve()
+    ))
+    const store = createBookAnalysisImportStore()
+    await store.getState().initializeProject(PROJECT_A)
+
+    await store.getState().createBatch([candidate("坏复制.txt"), candidate("好复制.txt")])
+
+    expect(store.getState().tasks.map((task) => task.status)).toEqual(["failed", "queued"])
+    expect(mocks.cacheTaskSource).toHaveBeenCalledTimes(1)
+    expect((mocks.cacheTaskSource.mock.calls[0][0] as BatchImportTask).originalFileName).toBe("好复制.txt")
+    expect(mocks.schedulers[0].enqueue).toHaveBeenCalledWith([store.getState().tasks[1]])
+  })
+
+  it("初始 queued 与 fallback failed 都保存失败时从批次和内存排除缺失任务", async () => {
+    mocks.saveBatchImportTask.mockImplementation((task: BatchImportTask) => (
+      task.originalFileName === "双写失败.txt" && (task.status === "queued" || task.status === "failed")
+        ? Promise.reject(new Error("任务双写失败"))
+        : Promise.resolve()
+    ))
+    const store = createBookAnalysisImportStore()
+    await store.getState().initializeProject(PROJECT_A)
+
+    await store.getState().createBatch([candidate("双写失败.txt"), candidate("正常.txt")])
+
+    expect(store.getState().tasks.map((task) => task.originalFileName)).toEqual(["正常.txt"])
+    expect(store.getState().batches[0].taskIds).toEqual([store.getState().tasks[0].id])
+    expect(mocks.saveBatchImportBatch).toHaveBeenCalledTimes(2)
+    expect((mocks.saveBatchImportBatch.mock.calls[1][0] as BatchImportBatch).taskIds).toEqual([store.getState().tasks[0].id])
+  })
+  it("当前项目查重错误仍标记 failed 且继续处理下一任务", async () => {
+    mocks.findBookLibraryEntryBySha256
+      .mockRejectedValueOnce(new Error("当前项目查重失败"))
+      .mockResolvedValueOnce(undefined)
+    const store = createBookAnalysisImportStore()
+    await store.getState().initializeProject(PROJECT_A)
+
+    await store.getState().createBatch([candidate("失败.txt"), candidate("继续.txt")])
+
+    expect(store.getState().tasks.map((task) => task.status)).toEqual(["failed", "queued"])
+    expect(store.getState().tasks[0].error).toContain("缓存源文件失败")
+    expect(mocks.schedulers[0].enqueue).toHaveBeenCalledWith([store.getState().tasks[1]])
+  })
+})

+ 455 - 0
src/stores/book-analysis-import-store.ts

@@ -0,0 +1,455 @@
+import { create } from "zustand"
+import { normalizePath } from "@/lib/path-utils"
+import {
+  createBatchImportScheduler,
+  type BatchImportScheduler,
+} from "@/lib/novel/book-analysis/batch-import-scheduler"
+import { reserveUniqueTitle } from "@/lib/novel/book-analysis/batch-import-hash"
+import {
+  cacheTaskSource,
+  loadBatchImportBatches,
+  loadBatchImportTasks,
+  saveBatchImportBatch,
+  saveBatchImportTask,
+} from "@/lib/novel/book-analysis/batch-import-storage"
+import type {
+  BatchImportBatch,
+  BatchImportCandidate,
+  BatchImportTask,
+} from "@/lib/novel/book-analysis/batch-import-types"
+import {
+  findBookLibraryEntryBySha256,
+  loadBookLibrary,
+} from "@/lib/novel/book-analysis/library-store"
+
+const OPEN_PANEL_STATUSES = new Set(["queued", "copying", "splitting", "interrupted", "failed"])
+const SWITCHED_PROJECT_ERROR = "项目已切换,导入任务已中断"
+let idCounter = 0
+
+function safeId(prefix: "batch" | "task"): string {
+  idCounter += 1
+  const random = typeof crypto !== "undefined" && typeof crypto.randomUUID === "function"
+    ? crypto.randomUUID().replace(/[^A-Za-z0-9_-]/g, "")
+    : Math.random().toString(36).slice(2)
+  return `${prefix}-${Date.now().toString(36)}-${idCounter.toString(36)}-${random}`
+}
+
+function titleFromFile(fileName: string): string {
+  return fileName.replace(/\.txt$/i, "").trim() || "未命名作品"
+}
+
+function shouldExpand(tasks: BatchImportTask[]): boolean {
+  return tasks.some((task) => OPEN_PANEL_STATUSES.has(task.status))
+}
+
+function queuedTask(projectPath: string, batchId: string, candidate: BatchImportCandidate, now: number): BatchImportTask {
+  const id = safeId("task")
+  return {
+    version: 1,
+    id,
+    batchId,
+    projectPath,
+    originalPath: normalizePath(candidate.sourcePath),
+    originalFileName: candidate.fileName,
+    cachedSourcePath: "",
+    sourceSha256: null,
+    requestedTitle: titleFromFile(candidate.fileName),
+    finalTitle: null,
+    bookId: `book-${id}`,
+    status: "queued",
+    completed: 0,
+    total: 0,
+    error: null,
+    skipReason: null,
+    createdAt: now,
+    startedAt: null,
+    completedAt: null,
+    updatedAt: now,
+  }
+}
+
+export interface BookAnalysisImportState {
+  projectPath: string | null
+  batches: BatchImportBatch[]
+  tasks: BatchImportTask[]
+  panelCollapsed: boolean
+  panelTouched: boolean
+  revision: number
+  initializeProject(projectPath: string): Promise<void>
+  createBatch(candidates: BatchImportCandidate[]): Promise<void>
+  continueTask(taskId: string): Promise<void>
+  regenerateTask(taskId: string): Promise<void>
+  cancelTask(taskId: string): Promise<void>
+  cancelAllQueued(batchId: string): Promise<void>
+  setPanelCollapsed(collapsed: boolean): void
+  dispose(): Promise<void>
+}
+
+export function createBookAnalysisImportStore(options: { onRevision?: () => void } = {}) {
+  let scheduler: BatchImportScheduler | null = null
+  let unsubscribeScheduler: (() => void) | null = null
+  let generation = 0
+  let schedulerTaskIds = new Set<string>()
+  let completedTaskIds = new Set<string>()
+  let createBatchChain: Promise<void> = Promise.resolve()
+  let schedulerLifecycleChain: Promise<void> = Promise.resolve()
+  const cancellingTaskIds = new Set<string>()
+
+  function detachScheduler(): BatchImportScheduler | null {
+    unsubscribeScheduler?.()
+    unsubscribeScheduler = null
+    const previousScheduler = scheduler
+    scheduler = null
+    schedulerTaskIds = new Set()
+    completedTaskIds = new Set()
+    return previousScheduler
+  }
+
+  return create<BookAnalysisImportState>((set, get) => {
+    function isCurrent(token: number, projectPath: string, expectedScheduler?: BatchImportScheduler): boolean {
+      return token === generation
+        && get().projectPath === projectPath
+        && (expectedScheduler === undefined || scheduler === expectedScheduler)
+    }
+
+    async function interruptTask(task: BatchImportTask): Promise<BatchImportTask> {
+      const now = Date.now()
+      const interrupted: BatchImportTask = {
+        ...task,
+        status: "interrupted",
+        error: SWITCHED_PROJECT_ERROR,
+        completedAt: null,
+        updatedAt: now,
+      }
+      await saveBatchImportTask(interrupted)
+      return interrupted
+    }
+
+    async function runCreateBatch(
+      candidates: BatchImportCandidate[],
+      projectPath: string,
+      token: number,
+      expectedScheduler: BatchImportScheduler,
+    ): Promise<void> {
+      if (!isCurrent(token, projectPath, expectedScheduler) || candidates.length === 0) return
+      const library = await loadBookLibrary(projectPath)
+      if (!isCurrent(token, projectPath, expectedScheduler)) return
+
+      const reservedTitles = new Set(library.entries.map((entry) => entry.title))
+      for (const task of get().tasks) {
+        if (task.status !== "skipped" && task.status !== "cancelled" && task.finalTitle) {
+          reservedTitles.add(task.finalTitle)
+        }
+      }
+      const batchHashes = new Set<string>()
+      const createdAt = Date.now()
+      const batchId = safeId("batch")
+      let tasks = candidates.map((candidate) => queuedTask(projectPath, batchId, candidate, createdAt))
+      let batch: BatchImportBatch = {
+        version: 1,
+        id: batchId,
+        projectPath,
+        taskIds: tasks.map((task) => task.id),
+        createdAt,
+        updatedAt: createdAt,
+      }
+
+      await saveBatchImportBatch(batch)
+      const persistedTasks: BatchImportTask[] = []
+      for (const originalTask of tasks) {
+        let persistedTask: BatchImportTask = isCurrent(token, projectPath, expectedScheduler)
+          ? originalTask
+          : {
+              ...originalTask,
+              status: "interrupted",
+              error: SWITCHED_PROJECT_ERROR,
+              completedAt: null,
+              updatedAt: Date.now(),
+            }
+        try {
+          await saveBatchImportTask(persistedTask)
+        } catch (error) {
+          const now = Date.now()
+          persistedTask = isCurrent(token, projectPath, expectedScheduler)
+            ? {
+                ...originalTask,
+                status: "failed",
+                error: `创建导入任务失败:“${originalTask.originalFileName}”未能保存`,
+                completedAt: now,
+                updatedAt: now,
+              }
+            : {
+                ...originalTask,
+                status: "interrupted",
+                error: SWITCHED_PROJECT_ERROR,
+                completedAt: null,
+                updatedAt: now,
+              }
+          try {
+            await saveBatchImportTask(persistedTask)
+          } catch (fallbackError) {
+            console.error("批量导入:保存任务失败状态失败", fallbackError)
+            continue
+          }
+        }
+        if (!isCurrent(token, projectPath, expectedScheduler) && persistedTask.status !== "interrupted") {
+          try {
+            persistedTask = await interruptTask(persistedTask)
+          } catch (error) {
+            console.error("批量导入:保存项目切换中断状态失败", error)
+          }
+        }
+        persistedTasks.push(persistedTask)
+      }
+      tasks = persistedTasks
+      if (batch.taskIds.length !== tasks.length) {
+        batch = { ...batch, taskIds: tasks.map((task) => task.id), updatedAt: Date.now() }
+        await saveBatchImportBatch(batch)
+      }
+      if (!isCurrent(token, projectPath, expectedScheduler)) return
+
+      const unavailableTaskIds = new Set<string>()
+      for (let index = 0; index < tasks.length; index += 1) {
+        if (!isCurrent(token, projectPath, expectedScheduler)) break
+        if (tasks[index].status === "failed") continue
+        let task: BatchImportTask = { ...tasks[index], status: "copying", updatedAt: Date.now() }
+        try {
+          await saveBatchImportTask(task)
+          if (!isCurrent(token, projectPath, expectedScheduler)) {
+            tasks[index] = await interruptTask(task)
+            break
+          }
+          task = await cacheTaskSource(task)
+          if (!isCurrent(token, projectPath, expectedScheduler)) {
+            tasks[index] = await interruptTask(task)
+            break
+          }
+          if (!task.sourceSha256) throw new Error("源文件哈希为空")
+          const duplicateInBatch = batchHashes.has(task.sourceSha256)
+          const duplicateInLibrary = duplicateInBatch
+            ? undefined
+            : await findBookLibraryEntryBySha256(projectPath, task.sourceSha256)
+          if (!isCurrent(token, projectPath, expectedScheduler)) {
+            tasks[index] = await interruptTask(task)
+            break
+          }
+          const now = Date.now()
+          if (duplicateInBatch || duplicateInLibrary) {
+            task = {
+              ...task,
+              status: "skipped",
+              skipReason: duplicateInBatch
+                ? "与本批次已选择作品内容相同,已跳过"
+                : `与作品库“${duplicateInLibrary?.title ?? "已有作品"}”存在相同内容,已跳过`,
+              completedAt: now,
+              updatedAt: now,
+            }
+          } else {
+            batchHashes.add(task.sourceSha256)
+            task = {
+              ...task,
+              finalTitle: reserveUniqueTitle(task.requestedTitle, reservedTitles),
+              status: "queued",
+              updatedAt: now,
+            }
+          }
+          await saveBatchImportTask(task)
+          if (!isCurrent(token, projectPath, expectedScheduler)) {
+            if (task.status === "copying") tasks[index] = await interruptTask(task)
+            break
+          }
+        } catch (error) {
+          if (!isCurrent(token, projectPath, expectedScheduler)) {
+            tasks[index] = await interruptTask(task)
+            break
+          }
+          console.error(`批量导入:处理文件“${task.originalFileName}”失败`, error)
+          const now = Date.now()
+          task = {
+            ...task,
+            status: "failed",
+            error: `缓存源文件失败:“${task.originalFileName}”未能创建导入任务`,
+            completedAt: now,
+            updatedAt: now,
+          }
+          try {
+            await saveBatchImportTask(task)
+          } catch (fallbackError) {
+            console.error("批量导入:保存任务失败状态失败", fallbackError)
+            unavailableTaskIds.add(task.id)
+            continue
+          }
+          if (!isCurrent(token, projectPath, expectedScheduler)) {
+            tasks[index] = await interruptTask(task)
+            break
+          }
+        }
+        tasks[index] = task
+      }
+
+      if (unavailableTaskIds.size > 0) {
+        tasks = tasks.filter((task) => !unavailableTaskIds.has(task.id))
+        batch = { ...batch, taskIds: tasks.map((task) => task.id), updatedAt: Date.now() }
+        await saveBatchImportBatch(batch)
+      }
+      if (!isCurrent(token, projectPath, expectedScheduler)) return
+      set((state) => {
+        const nextTasks = [...state.tasks, ...tasks]
+        return {
+          batches: [...state.batches, { ...batch, updatedAt: Date.now() }],
+          tasks: nextTasks,
+          panelCollapsed: state.panelTouched ? state.panelCollapsed : !shouldExpand(nextTasks),
+        }
+      })
+      const runnable = tasks.filter((task) => task.status === "queued")
+      if (!isCurrent(token, projectPath, expectedScheduler) || runnable.length === 0) return
+      runnable.forEach((task) => schedulerTaskIds.add(task.id))
+      expectedScheduler.enqueue(runnable)
+    }
+
+    return {
+      projectPath: null,
+      batches: [],
+      tasks: [],
+      panelCollapsed: true,
+      panelTouched: false,
+      revision: 0,
+
+      initializeProject: (rawPath) => {
+        const projectPath = normalizePath(rawPath).replace(/\/+$/, "")
+        if (!projectPath) return Promise.reject(new Error("项目路径不能为空"))
+        generation += 1
+        const token = generation
+        const previousScheduler = detachScheduler()
+        set({ projectPath, batches: [], tasks: [], panelCollapsed: true, panelTouched: false })
+
+        const operation = schedulerLifecycleChain.then(async () => {
+          await previousScheduler?.dispose()
+          if (!isCurrent(token, projectPath)) return
+          const [tasks, batches] = await Promise.all([
+            loadBatchImportTasks(projectPath),
+            loadBatchImportBatches(projectPath),
+          ])
+          if (!isCurrent(token, projectPath)) return
+          completedTaskIds = new Set(tasks.filter((task) => task.status === "completed").map((task) => task.id))
+          set({ tasks, batches, panelCollapsed: !shouldExpand(tasks), panelTouched: false })
+
+          const currentScheduler = createBatchImportScheduler({ projectPath })
+          scheduler = currentScheduler
+          unsubscribeScheduler = currentScheduler.subscribe((next) => {
+            if (!isCurrent(token, projectPath, currentScheduler)) return
+            const updates = new Map(next.map((task) => [task.id, task]))
+            const merged = get().tasks.map((task) => updates.get(task.id) ?? task)
+            for (const task of next) {
+              if (!merged.some((item) => item.id === task.id)) merged.push(task)
+              if (task.status === "completed" && !completedTaskIds.has(task.id)) {
+                completedTaskIds.add(task.id)
+                set((state) => ({ revision: state.revision + 1 }))
+                options.onRevision?.()
+              }
+            }
+            set((state) => ({
+              tasks: merged,
+              panelCollapsed: state.panelTouched ? state.panelCollapsed : !shouldExpand(merged),
+            }))
+          })
+        })
+        schedulerLifecycleChain = operation.catch(() => undefined)
+        return operation
+      },
+      createBatch: (candidates) => {
+        const projectPath = get().projectPath
+        const expectedScheduler = scheduler
+        const token = generation
+        if (!projectPath || !expectedScheduler) return Promise.reject(new Error("请先初始化拆书项目"))
+        const operation = createBatchChain.then(() => runCreateBatch(candidates, projectPath, token, expectedScheduler))
+        createBatchChain = operation.catch(() => undefined)
+        return operation
+      },
+
+      continueTask: async (taskId) => {
+        const current = scheduler
+        if (!current) throw new Error("请先初始化拆书项目")
+        if (cancellingTaskIds.has(taskId)) throw new Error("任务正在取消,请稍后重试")
+        const task = get().tasks.find((item) => item.id === taskId)
+        if (task && !schedulerTaskIds.has(taskId)) {
+          schedulerTaskIds.add(taskId)
+          current.enqueue([task])
+        }
+        await current.continueTask(taskId)
+      },
+
+      regenerateTask: async (taskId) => {
+        const current = scheduler
+        if (!current) throw new Error("请先初始化拆书项目")
+        if (cancellingTaskIds.has(taskId)) throw new Error("任务正在取消,请稍后重试")
+        const task = get().tasks.find((item) => item.id === taskId)
+        if (task && !schedulerTaskIds.has(taskId)) {
+          schedulerTaskIds.add(taskId)
+          current.enqueue([task])
+        }
+        completedTaskIds.delete(taskId)
+        await current.regenerateTask(taskId)
+      },
+
+      cancelTask: async (taskId) => {
+        const current = scheduler
+        const projectPath = get().projectPath
+        const token = generation
+        if (!current || !projectPath) throw new Error("请先初始化拆书项目")
+        const task = get().tasks.find((item) => item.id === taskId)
+        let cancelled: BatchImportTask | null = null
+        if (task?.status === "queued" && !schedulerTaskIds.has(taskId)) {
+          const now = Date.now()
+          cancelled = { ...task, status: "cancelled", error: null, completedAt: now, updatedAt: now }
+          await saveBatchImportTask(cancelled)
+          if (!isCurrent(token, projectPath, current)) return
+        }
+        await current.cancelTask(taskId)
+        if (!isCurrent(token, projectPath, current)) return
+        if (cancelled) set((state) => ({ tasks: state.tasks.map((item) => item.id === taskId ? cancelled! : item) }))
+      },
+
+      cancelAllQueued: async (batchId) => {
+        const current = scheduler
+        const projectPath = get().projectPath
+        const token = generation
+        if (!current || !projectPath) throw new Error("请先初始化拆书项目")
+        if (!batchId) throw new Error("批次 ID 不能为空")
+        const matchingTasks = get().tasks.filter((task) => (
+          task.batchId === batchId && task.status === "queued" && !schedulerTaskIds.has(task.id)
+        ))
+        matchingTasks.forEach((task) => cancellingTaskIds.add(task.id))
+        try {
+          const replacements = new Map<string, BatchImportTask>()
+          for (const task of matchingTasks) {
+            const now = Date.now()
+            const cancelled: BatchImportTask = { ...task, status: "cancelled", error: null, completedAt: now, updatedAt: now }
+            await saveBatchImportTask(cancelled)
+            if (!isCurrent(token, projectPath, current)) return
+            replacements.set(task.id, cancelled)
+          }
+          await current.cancelAllQueued(batchId)
+          if (!isCurrent(token, projectPath, current)) return
+          if (replacements.size > 0) {
+            set((state) => ({ tasks: state.tasks.map((task) => replacements.get(task.id) ?? task) }))
+          }
+        } finally {
+          matchingTasks.forEach((task) => cancellingTaskIds.delete(task.id))
+        }
+      },
+
+      setPanelCollapsed: (panelCollapsed) => set({ panelCollapsed, panelTouched: true }),
+      dispose: () => {
+        generation += 1
+        const previousScheduler = detachScheduler()
+        set({ projectPath: null, batches: [], tasks: [], panelCollapsed: true, panelTouched: false })
+        const operation = schedulerLifecycleChain.then(() => previousScheduler?.dispose())
+        schedulerLifecycleChain = operation.catch(() => undefined)
+        return operation
+      },
+    }
+  })
+}
+
+export const useBookAnalysisImportStore = createBookAnalysisImportStore()