Parcourir la source

merge: 合并拆书库删除清理

# Conflicts:
#	src/lib/novel/book-analysis/batch-import-scheduler.ts
#	src/lib/novel/book-analysis/library-store.ts
#	src/stores/book-analysis-import-store.spec.ts
#	src/stores/book-analysis-import-store.ts
Mochocyang il y a 3 mois
Parent
commit
5a563f797f

+ 99 - 7
src/components/layout/book-analysis-sidebar-panel.spec.tsx

@@ -10,6 +10,11 @@ import { describe, it, expect, vi, beforeEach } from "vitest"
 import type { BookAnalysisTask } from "@/lib/novel/book-analysis/types"
 
 const mockSetSelectedLibraryBookId = vi.fn()
+const mockDeletePublishedBook = vi.fn()
+const mockWikiState = {
+  project: { id: "p1", name: "Novel", path: "/proj" } as { id: string; name: string; path: string } | null,
+  setActiveView: vi.fn(),
+}
 const mockBookAnalysisState = {
   setSelectedLibraryBookId: mockSetSelectedLibraryBookId,
   sidebarRefreshCounter: 0,
@@ -35,11 +40,10 @@ vi.mock("@/lib/novel/character-aura", () => ({
 }))
 
 vi.mock("@/stores/wiki-store", () => ({
-  useWikiStore: (selector: (state: any) => unknown) =>
-    selector({
-      project: { id: "p1", name: "Novel", path: "/proj" },
-      setActiveView: vi.fn(),
-    }),
+  useWikiStore: Object.assign(
+    (selector: (state: any) => unknown) => selector(mockWikiState),
+    { getState: () => mockWikiState },
+  ),
 }))
 
 vi.mock("@/stores/book-analysis-store", () => ({
@@ -47,8 +51,18 @@ vi.mock("@/stores/book-analysis-store", () => ({
     selector ? selector(mockBookAnalysisState) : mockBookAnalysisState,
 }))
 
+vi.mock("@/stores/book-analysis-import-store", () => ({
+  useBookAnalysisImportStore: (selector: (state: any) => unknown) =>
+    selector({ deletePublishedBook: mockDeletePublishedBook }),
+}))
+
+vi.mock("@/lib/toast", () => ({
+  toast: { success: vi.fn(), error: vi.fn() },
+}))
+
 import { BookAnalysisSidebarPanel } from "./book-analysis-sidebar-panel"
 import { listDirectory, readFile, deleteFile } from "@/commands/fs"
+import { toast } from "@/lib/toast"
 
 ;(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true
 
@@ -75,7 +89,9 @@ function renderPanel(): { cleanup: () => void } {
 
 beforeEach(async () => {
   vi.clearAllMocks()
+  mockWikiState.project = { id: "p1", name: "Novel", path: "/proj" }
   mockBookAnalysisState.tasks = []
+  mockDeletePublishedBook.mockResolvedValue(undefined)
   await flushAsync(20)
 })
 
@@ -158,7 +174,7 @@ describe("BookAnalysisSidebarPanel", () => {
     await flushAsync(20)
   })
 
-  it("点击删除按钮不触发整行 click,并调用 deleteFile", async () => {
+  it("删除作品时调用统一清理动作并说明会清理查重记录", async () => {
     vi.mocked(listDirectory).mockImplementation(async (dir) => {
       if (dir.endsWith("book-analysis")) {
         return [{ name: "book-2", is_dir: true, path: "/proj/book-analysis/book-2" }]
@@ -182,11 +198,87 @@ describe("BookAnalysisSidebarPanel", () => {
       await new Promise((r) => setTimeout(r, 0))
     })
     await flushAsync(50)
-    expect(deleteFile).toHaveBeenCalledTimes(1)
+    expect(confirmSpy).toHaveBeenCalledWith(expect.stringContaining("导入历史和内容查重记录"))
+    expect(mockDeletePublishedBook).toHaveBeenCalledWith("book-2")
+    expect(deleteFile).not.toHaveBeenCalled()
     // 整行 click 不应触发:setSelectedLibraryBookId 不应被调
     expect(mockSetSelectedLibraryBookId).not.toHaveBeenCalled()
     confirmSpy.mockRestore()
     cleanup()
     await flushAsync(20)
   })
+
+  it.each([
+    {
+      name: "活动导入任务",
+      error: new Error("作品正在导入或重新生成,请先取消任务后再删除。"),
+      expected: "作品正在导入或重新生成,请先取消任务后再删除。",
+    },
+    {
+      name: "内部英文异常",
+      error: new Error("Failed to delete directory"),
+      expected: "删除作品失败,请稍后重试",
+    },
+  ])("删除失败($name)时显示中文提示", async ({ error, expected }) => {
+    vi.mocked(listDirectory).mockImplementation(async (dir) => {
+      if (dir.endsWith("book-analysis")) {
+        return [{ name: "book-2", is_dir: true, path: "/proj/book-analysis/book-2" }]
+      }
+      return []
+    })
+    vi.mocked(readFile).mockResolvedValue(JSON.stringify({
+      title: "书2", totalChapters: 1, totalWords: 1,
+      sourceType: "file", createdAt: 0, updatedAt: 0,
+    }))
+    vi.spyOn(window, "confirm").mockReturnValue(true)
+    mockDeletePublishedBook.mockRejectedValueOnce(error)
+
+    const { cleanup } = renderPanel()
+    await flushAsync(50)
+    const deleteBtn = document.querySelector('[aria-label="删除作品"]') as HTMLButtonElement
+    await act(async () => {
+      deleteBtn.click()
+      await new Promise((r) => setTimeout(r, 0))
+    })
+    await flushAsync(50)
+
+    expect(toast.error).toHaveBeenCalledWith(expected)
+    cleanup()
+    await flushAsync(20)
+  })
+
+  it("删除等待期间切换项目时不使用旧结果刷新新项目界面", async () => {
+    vi.mocked(listDirectory).mockImplementation(async (dir) => {
+      if (dir.endsWith("book-analysis")) {
+        return [{ name: "book-2", is_dir: true, path: "/proj/book-analysis/book-2" }]
+      }
+      return []
+    })
+    vi.mocked(readFile).mockResolvedValue(JSON.stringify({
+      title: "书2", totalChapters: 1, totalWords: 1,
+      sourceType: "file", createdAt: 0, updatedAt: 0,
+    }))
+    vi.spyOn(window, "confirm").mockReturnValue(true)
+    let finishDelete!: () => void
+    mockDeletePublishedBook.mockReturnValueOnce(new Promise<void>((resolve) => {
+      finishDelete = resolve
+    }))
+
+    const { cleanup } = renderPanel()
+    await flushAsync(50)
+    const deleteBtn = document.querySelector('[aria-label="删除作品"]') as HTMLButtonElement
+    await act(async () => {
+      deleteBtn.click()
+      await Promise.resolve()
+    })
+    mockWikiState.project = { id: "p2", name: "Other", path: "/other" }
+    finishDelete()
+    await flushAsync(50)
+
+    expect(mockBookAnalysisState.triggerSidebarRefresh).not.toHaveBeenCalled()
+    expect(mockSetSelectedLibraryBookId).not.toHaveBeenCalled()
+    expect(toast.success).not.toHaveBeenCalled()
+    cleanup()
+    await flushAsync(20)
+  })
 })

+ 16 - 4
src/components/layout/book-analysis-sidebar-panel.tsx

@@ -6,9 +6,10 @@
 import { useEffect, useState } from "react"
 import { useWikiStore } from "@/stores/wiki-store"
 import { useBookAnalysisStore } from "@/stores/book-analysis-store"
+import { useBookAnalysisImportStore } from "@/stores/book-analysis-import-store"
 import { Button } from "@/components/ui/button"
 import { BookOpen, Trash2, RefreshCw, Loader2, Square, CheckCircle2 } from "lucide-react"
-import { listDirectory, readFile, deleteFile } from "@/commands/fs"
+import { listDirectory, readFile } from "@/commands/fs"
 import { joinPath, normalizePath } from "@/lib/path-utils"
 import { toast } from "@/lib/toast"
 import { deleteOrphanAurasForBook } from "@/lib/novel/book-analysis/aura-cleanup"
@@ -36,6 +37,7 @@ export function BookAnalysisSidebarPanel() {
   const tasks = useBookAnalysisStore((s) => s.tasks)
   const cancelTask = useBookAnalysisStore((s) => s.cancelTask)
   const requestReopenChapterSelection = useBookAnalysisStore((s) => s.requestReopenChapterSelection)
+  const deletePublishedBook = useBookAnalysisImportStore((s) => s.deletePublishedBook)
   const [books, setBooks] = useState<BookItem[]>([])
   const [loading, setLoading] = useState(false)
   const [selectedBookId, setSelectedBookId] = useState<string | null>(null)
@@ -167,7 +169,7 @@ export function BookAnalysisSidebarPanel() {
 
   const handleDeleteBook = async (book: BookItem) => {
     const confirmed = window.confirm(
-      `确认删除作品"${book.title}"吗?\n\n这将删除:\n- 所有角色信息\n- 所有生成的 Skills\n- 分析元数据\n\n此操作无法撤销。`
+      `确认删除作品"${book.title}"吗?\n\n这将删除:\n- 所有角色信息\n- 所有生成的 Skills\n- 分析元数据\n- 导入历史和内容查重记录\n\n此操作无法撤销。`
     )
     if (!confirmed) return
 
@@ -178,9 +180,15 @@ export function BookAnalysisSidebarPanel() {
     }
 
     try {
-      await deleteFile(book.path)
+      await deletePublishedBook(book.id)
       // 同步清理由该作品生成的孤儿灵魂(feature 优化 4)
       const cleaned = await deleteOrphanAurasForBook(projectPath, book.title).catch(() => 0)
+      const activeProjectPath = useWikiStore.getState().project?.path
+      if (
+        !activeProjectPath
+        || normalizePath(activeProjectPath).replace(/\/+$/, "")
+          !== normalizePath(projectPath).replace(/\/+$/, "")
+      ) return
       await loadBooks()
       if (selectedBookId === book.id) {
         setSelectedBookId(null)
@@ -195,7 +203,11 @@ export function BookAnalysisSidebarPanel() {
       }
     } catch (err) {
       console.error("Failed to delete book:", err)
-      toast.error("删除作品失败,请稍后重试")
+      toast.error(
+        err instanceof Error && err.message === "作品正在导入或重新生成,请先取消任务后再删除。"
+          ? err.message
+          : "删除作品失败,请稍后重试",
+      )
     }
   }
 

+ 38 - 2
src/components/novel/book-analysis-view.spec.ts

@@ -127,6 +127,7 @@ const mocks = vi.hoisted(() => {
     handleLibraryExtractStyle,
     handleLibraryReextractCharacters,
     loadBookStoryFrameworkChapters,
+    reloadedLibraryState: libraryState,
     libraryParams: null as any,
     inputDialogProps: null as any,
     taskPanelProps: null as any,
@@ -355,6 +356,7 @@ beforeEach(() => {
   mocks.importState.tasks = []
   mocks.importState.panelCollapsed = false
   mocks.importState.revision = 0
+  mocks.reloadedLibraryState = mocks.libraryState
   mocks.libraryParams = null
   mocks.inputDialogProps = null
   mocks.taskPanelProps = null
@@ -382,8 +384,9 @@ beforeEach(() => {
     { 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")
+    const next = mocks.reloadedLibraryState
+    mocks.libraryParams.setLibraryState(next)
+    mocks.libraryParams.setSelectedBookId((current: string | null) => current && next.books.some((book) => book.id === current) ? current : next.books[0]?.id ?? null)
   })
 })
 
@@ -475,6 +478,39 @@ describe("BookAnalysisView 批量导入运行时接线", () => {
     expect(host.querySelector('[data-testid="章节选择面板"]')).toBeNull()
   })
 
+  it("导入完成的 revision 只刷新一次并让新作品自动进入第二栏", async () => {
+    await renderView()
+    mocks.reloadLibraryState.mockClear()
+    mocks.triggerSidebarRefresh.mockClear()
+    const importedBook = {
+      ...mocks.libraryState.books[0],
+      id: "book-imported",
+      path: "E:/项目甲/book-analysis/book-imported",
+      metadata: {
+        ...mocks.libraryState.books[0].metadata,
+        title: "新导入作品",
+        createdAt: 10,
+        updatedAt: 10,
+      },
+    }
+    mocks.reloadedLibraryState = {
+      ...mocks.libraryState,
+      books: [...mocks.libraryState.books, importedBook],
+    }
+    mocks.importState.tasks = [{ id: "task-imported", bookId: importedBook.id, status: "completed" }] as any
+    mocks.importState.revision = 1
+
+    await rerenderView()
+    expect(mocks.triggerSidebarRefresh).toHaveBeenCalledTimes(1)
+    await rerenderView()
+
+    expect(mocks.reloadLibraryState).toHaveBeenCalledTimes(1)
+    expect(mocks.layoutProps.state.books.map((book: { id: string }) => book.id)).toContain("book-imported")
+    await rerenderView()
+    expect(mocks.triggerSidebarRefresh).toHaveBeenCalledTimes(1)
+    expect(mocks.reloadLibraryState).toHaveBeenCalledTimes(1)
+  })
+
   it("createBatch 失败时弹窗保持打开", async () => {
     mocks.createBatch.mockRejectedValue(new Error("磁盘不可用")); await renderView(); await clickButton("打开导入弹窗")
     try { await mocks.inputDialogProps.onSubmit(mocks.candidates) } catch {}

+ 185 - 0
src/components/novel/hooks/use-library-operations.spec.tsx

@@ -0,0 +1,185 @@
+// @vitest-environment jsdom
+
+import { act } from "react"
+import { createRoot, type Root } from "react-dom/client"
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
+
+const mocks = vi.hoisted(() => ({
+  deletePublishedBook: vi.fn(),
+  deleteFile: vi.fn(),
+  deleteOrphanAurasForBook: vi.fn(),
+  loadBookAnalysisLibraryState: vi.fn(),
+  toastSuccess: vi.fn(),
+  toastError: vi.fn(),
+  currentProjectPath: "E:/项目甲" as string | null,
+}))
+
+vi.mock("@/stores/book-analysis-import-store", () => ({
+  useBookAnalysisImportStore: (selector: (state: any) => unknown) =>
+    selector({ deletePublishedBook: mocks.deletePublishedBook }),
+}))
+
+vi.mock("@/stores/book-analysis-store", () => ({
+  useBookAnalysisStore: { getState: () => ({}) },
+}))
+
+vi.mock("@/stores/wiki-store", () => ({
+  useWikiStore: {
+    getState: () => ({
+      project: mocks.currentProjectPath
+        ? { id: "project", name: "项目", path: mocks.currentProjectPath }
+        : null,
+      llmConfig: {},
+      providerConfigs: [],
+    }),
+  },
+}))
+
+vi.mock("@/commands/fs", () => ({
+  deleteFile: mocks.deleteFile,
+  listDirectory: vi.fn(),
+  readFile: vi.fn(),
+}))
+
+vi.mock("@/lib/novel/book-analysis/aura-cleanup", () => ({
+  deleteOrphanAurasForBook: mocks.deleteOrphanAurasForBook,
+}))
+
+vi.mock("@/lib/novel/book-analysis/library-state", () => ({
+  loadBookAnalysisLibraryState: mocks.loadBookAnalysisLibraryState,
+}))
+
+vi.mock("@/lib/novel/book-analysis/style-extraction-engine", () => ({ analyzeWritingStyle: vi.fn() }))
+vi.mock("@/lib/novel/book-analysis/aura-adapter", () => ({ importBookAnalysisSkillsAsAuras: vi.fn() }))
+vi.mock("@/lib/novel/character-aura", () => ({ bindCharacterAura: vi.fn(), listBindableNovelCharacters: vi.fn() }))
+vi.mock("@/lib/novel/writing-style-store", () => ({ setEnabledWritingStyle: vi.fn(), upsertWritingStylePreset: vi.fn() }))
+vi.mock("@/lib/project-refresh", () => ({ refreshProjectState: vi.fn() }))
+vi.mock("@/lib/has-usable-llm", () => ({ hasUsableLlm: vi.fn(() => true) }))
+vi.mock("@/lib/toast", () => ({
+  toast: { success: mocks.toastSuccess, error: mocks.toastError, info: vi.fn() },
+}))
+
+import { useLibraryOperations } from "./use-library-operations"
+
+const libraryBook = {
+  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: [],
+}
+
+let host: HTMLDivElement
+let root: Root
+let operations: ReturnType<typeof useLibraryOperations> | null = null
+const setLibraryState = vi.fn()
+const setSelectedBookId = vi.fn()
+const setSelectedCharacterId = vi.fn()
+
+function Harness() {
+  operations = useLibraryOperations({
+    currentProjectPath: "E:/项目甲",
+    selectedLibraryBook: libraryBook,
+    libraryState: { books: [libraryBook], enabledStyle: null, bindings: [] },
+    setLibraryState,
+    setSelectedBookId,
+    setSelectedCharacterId,
+    setChapterSelectionData: vi.fn(),
+    llmConfig: {} as any,
+    providerConfigs: [],
+    startTask: vi.fn(),
+  })
+  return null
+}
+
+beforeEach(async () => {
+  ;(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true
+  vi.clearAllMocks()
+  operations = null
+  mocks.currentProjectPath = "E:/项目甲"
+  mocks.deletePublishedBook.mockResolvedValue(undefined)
+  mocks.deleteFile.mockResolvedValue(undefined)
+  mocks.deleteOrphanAurasForBook.mockResolvedValue(0)
+  mocks.loadBookAnalysisLibraryState.mockResolvedValue({ books: [], enabledStyle: null, bindings: [] })
+  vi.spyOn(window, "confirm").mockReturnValue(true)
+  host = document.createElement("div")
+  document.body.appendChild(host)
+  root = createRoot(host)
+  await act(async () => {
+    root.render(<Harness />)
+  })
+})
+
+afterEach(async () => {
+  await act(async () => root.unmount())
+  host.remove()
+  vi.restoreAllMocks()
+})
+
+describe("useLibraryOperations 删除作品", () => {
+  it("备用删除入口调用统一清理动作且不直接删除作品目录", async () => {
+    await act(async () => {
+      await operations!.handleLibraryDeleteBook("book-1", "book-1")
+    })
+
+    expect(window.confirm).toHaveBeenCalledWith(expect.stringContaining("导入历史和内容查重记录"))
+    expect(mocks.deletePublishedBook).toHaveBeenCalledWith("book-1")
+    expect(mocks.deleteFile).not.toHaveBeenCalled()
+    expect(mocks.deleteOrphanAurasForBook).toHaveBeenCalledWith("E:/项目甲", "长夜")
+    expect(setSelectedBookId).toHaveBeenCalledWith(null)
+    expect(setSelectedCharacterId).toHaveBeenCalledWith(null)
+  })
+
+  it.each([
+    {
+      name: "活动导入任务",
+      error: new Error("作品正在导入或重新生成,请先取消任务后再删除。"),
+      expected: "作品正在导入或重新生成,请先取消任务后再删除。",
+    },
+    {
+      name: "内部英文异常",
+      error: new Error("Failed to delete directory"),
+      expected: "删除作品失败,请稍后重试",
+    },
+  ])("统一清理动作拒绝删除($name)时显示中文原因且不刷新作品库", async ({ error, expected }) => {
+    mocks.deletePublishedBook.mockRejectedValueOnce(error)
+
+    await act(async () => {
+      await operations!.handleLibraryDeleteBook("book-1", "book-1")
+    })
+
+    expect(mocks.toastError).toHaveBeenCalledWith(expected)
+    expect(mocks.loadBookAnalysisLibraryState).not.toHaveBeenCalled()
+    expect(setSelectedBookId).not.toHaveBeenCalled()
+  })
+
+  it("删除等待期间切换项目时不把旧项目结果写入新界面", async () => {
+    let finishDelete!: () => void
+    mocks.deletePublishedBook.mockReturnValueOnce(new Promise<void>((resolve) => {
+      finishDelete = resolve
+    }))
+
+    const deleting = operations!.handleLibraryDeleteBook("book-1", "book-1")
+    await Promise.resolve()
+    mocks.currentProjectPath = "F:/项目乙"
+    finishDelete()
+    await deleting
+
+    expect(mocks.deleteOrphanAurasForBook).toHaveBeenCalledWith("E:/项目甲", "长夜")
+    expect(setSelectedBookId).not.toHaveBeenCalled()
+    expect(setSelectedCharacterId).not.toHaveBeenCalled()
+    expect(mocks.loadBookAnalysisLibraryState).not.toHaveBeenCalled()
+    expect(mocks.toastSuccess).not.toHaveBeenCalled()
+  })
+})

+ 18 - 6
src/components/novel/hooks/use-library-operations.ts

@@ -1,5 +1,6 @@
 import { useState, useCallback } from "react"
 import { useBookAnalysisStore } from "@/stores/book-analysis-store"
+import { useBookAnalysisImportStore } from "@/stores/book-analysis-import-store"
 import { useWikiStore } from "@/stores/wiki-store"
 import { analyzeWritingStyle } from "@/lib/novel/book-analysis/style-extraction-engine"
 import { importBookAnalysisSkillsAsAuras } from "@/lib/novel/book-analysis/aura-adapter"
@@ -12,8 +13,8 @@ import {
 import { bindCharacterAura, listBindableNovelCharacters } from "@/lib/novel/character-aura"
 import { setEnabledWritingStyle, upsertWritingStylePreset } from "@/lib/novel/writing-style-store"
 import { refreshProjectState } from "@/lib/project-refresh"
-import { readFile, listDirectory, deleteFile } from "@/commands/fs"
-import { joinPath } from "@/lib/path-utils"
+import { readFile, listDirectory } from "@/commands/fs"
+import { joinPath, normalizePath } from "@/lib/path-utils"
 import { toast } from "@/lib/toast"
 import { hasUsableLlm } from "@/lib/has-usable-llm"
 import type { AnalysisDepth } from "@/lib/novel/book-analysis/types"
@@ -53,6 +54,7 @@ export function useLibraryOperations({
 }: UseLibraryOperationsParams) {
   const [styleExtracting, setStyleExtracting] = useState(false)
   const [addingToSoul, setAddingToSoul] = useState(false)
+  const deletePublishedBook = useBookAnalysisImportStore((state) => state.deletePublishedBook)
 
   const reloadLibraryState = useCallback(async () => {
     if (!currentProjectPath) {
@@ -233,13 +235,19 @@ export function useLibraryOperations({
     const book = libraryState.books.find((item) => item.id === bookId)
     if (!book) return
     const confirmed = window.confirm(
-      `确认删除作品"${book.metadata.title}"吗?\n\n这将删除:\n- 所有角色信息\n- 所有生成的 Skills\n- 分析元数据\n\n此操作无法撤销。`,
+      `确认删除作品"${book.metadata.title}"吗?\n\n这将删除:\n- 所有角色信息\n- 所有生成的 Skills\n- 分析元数据\n- 导入历史和内容查重记录\n\n此操作无法撤销。`,
     )
     if (!confirmed) return
 
     try {
-      await deleteFile(book.path)
+      await deletePublishedBook(bookId)
       const cleaned = await deleteOrphanAurasForBook(currentProjectPath, book.metadata.title).catch(() => 0)
+      const activeProjectPath = useWikiStore.getState().project?.path
+      if (
+        !activeProjectPath
+        || normalizePath(activeProjectPath).replace(/\/+$/, "")
+          !== normalizePath(currentProjectPath).replace(/\/+$/, "")
+      ) return
       if (selectedBookId === bookId) {
         setSelectedBookId(null)
         setSelectedCharacterId(null)
@@ -252,9 +260,13 @@ export function useLibraryOperations({
       await reloadLibraryState()
     } catch (err) {
       console.error("Failed to delete book:", err)
-      toast.error("删除作品失败,请稍后重试")
+      toast.error(
+        err instanceof Error && err.message === "作品正在导入或重新生成,请先取消任务后再删除。"
+          ? err.message
+          : "删除作品失败,请稍后重试",
+      )
     }
-  }, [currentProjectPath, libraryState.books, setSelectedBookId, setSelectedCharacterId, reloadLibraryState])
+  }, [currentProjectPath, libraryState.books, deletePublishedBook, setSelectedBookId, setSelectedCharacterId, reloadLibraryState])
 
   const handleLibraryReextractCharacters = useCallback(async () => {
     if (!currentProjectPath || !selectedLibraryBook) return

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

@@ -114,6 +114,19 @@ describe("createBatchImportScheduler", () => {
     gates.get("task-3")?.resolve()
   })
 
+  it("removeTask 清除非运行任务且后续快照不会重新出现", () => {
+    const scheduler = createBatchImportScheduler({ projectPath: PROJECT_PATH })
+    const getTasks = latestTasks(scheduler)
+    scheduler.enqueue([
+      makeTask("task-completed", { status: "completed" }),
+      makeTask("task-failed", { status: "failed" }),
+    ])
+
+    expect(scheduler.removeTask("task-completed")).toBe(true)
+    expect(getTasks().map((task) => task.id)).toEqual(["task-failed"])
+    expect(scheduler.removeTask("task-completed")).toBe(false)
+  })
+
   it.each([Number.NaN, Number.POSITIVE_INFINITY])(
     "非有限并发值 %s 回退为默认并发 2",
     async (invalidConcurrency) => {

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

@@ -13,6 +13,7 @@ export interface BatchImportScheduler {
   cancelAllQueued(batchId: string): Promise<void>
   syncTerminalTask(task: BatchImportTask): void
   forgetTerminalTask(taskId: string): void
+  removeTask(taskId: string): boolean
   subscribe(listener: (tasks: BatchImportTask[]) => void): () => void
   dispose(): Promise<void>
 }
@@ -324,6 +325,15 @@ export function createBatchImportScheduler(options: {
     notify()
   }
 
+  function removeTask(taskId: string): boolean {
+    if (runningIds.has(taskId) || continuePromises.has(taskId)) return false
+    removeFromQueue(taskId)
+    cancelRequestedIds.delete(taskId)
+    const removed = tasks.delete(taskId)
+    if (removed) notify()
+    return removed
+  }
+
   function subscribe(listener: (tasks: BatchImportTask[]) => void): () => void {
     if (disposed) return () => undefined
     listeners.add(listener)
@@ -365,6 +375,7 @@ export function createBatchImportScheduler(options: {
     cancelAllQueued: (batchId) => disposed ? Promise.resolve() : trackMutation(() => cancelAllQueued(batchId)),
     syncTerminalTask,
     forgetTerminalTask,
+    removeTask,
     subscribe,
     dispose,
   }

+ 313 - 1
src/lib/novel/book-analysis/batch-import-storage.spec.ts

@@ -37,6 +37,8 @@ import {
   loadBatchImportBatches,
   loadBatchImportTasks,
   loadTaskCheckpoint,
+  pruneMissingCompletedBookHistory,
+  removeBatchImportHistoryForBook,
   resetBatchImportTask,
   saveBatchImportBatch,
   saveBatchImportTask,
@@ -73,6 +75,7 @@ function makeTask(overrides: Partial<BatchImportTask> = {}): BatchImportTask {
 }
 
 function seedTask(task: BatchImportTask): void {
+  fsMocks.directories.add(importTasksRoot(task.projectPath))
   const dir = importTaskDir(task.projectPath, task.id)
   fsMocks.directories.add(dir)
   fsMocks.files.set(`${dir}/task.json`, JSON.stringify(task))
@@ -114,7 +117,18 @@ beforeEach(() => {
   fsMocks.fileExists.mockImplementation(async (path) => (
     fsMocks.files.has(path) || fsMocks.directories.has(path)
   ))
-  fsMocks.listDirectory.mockResolvedValue([])
+  fsMocks.listDirectory.mockImplementation(async (path: string) => {
+    const prefix = `${path}/`
+    return [...fsMocks.directories]
+      .filter((directory) => directory.startsWith(prefix))
+      .map((directory) => directory.slice(prefix.length))
+      .filter((name) => name.length > 0 && !name.includes("/"))
+      .map((name) => ({
+        name,
+        path: `${path}/${name}`,
+        is_dir: true,
+      }))
+  })
 })
 
 describe("batch import storage", () => {
@@ -873,4 +887,302 @@ describe("batch import storage", () => {
     const saved = JSON.parse(fsMocks.files.get(`${root}/batches.json`)!) as BatchImportBatch[]
     expect(saved.map((batch) => batch.id)).toEqual(["batch-valid", "batch-new"])
   })
+
+  it("删除指定书籍导入历史时删一留一并只写回一次批次", async () => {
+    const removedTask = makeTask({ id: "task-remove", bookId: "book-remove" })
+    const keptTask = makeTask({
+      id: "task-keep",
+      bookId: "book-keep",
+      batchId: "batch-2",
+      createdAt: 200,
+    })
+    seedTask(removedTask)
+    seedTask(keptTask)
+    const root = importTasksRoot(removedTask.projectPath)
+    const batches: BatchImportBatch[] = [
+      {
+        version: 1,
+        id: "batch-1",
+        projectPath: removedTask.projectPath,
+        taskIds: [removedTask.id, keptTask.id],
+        createdAt: 100,
+        updatedAt: 100,
+      },
+      {
+        version: 1,
+        id: "batch-2",
+        projectPath: removedTask.projectPath,
+        taskIds: [keptTask.id],
+        createdAt: 200,
+        updatedAt: 200,
+      },
+    ]
+    fsMocks.files.set(`${root}/batches.json`, JSON.stringify(batches))
+
+    const result = await removeBatchImportHistoryForBook(
+      removedTask.projectPath,
+      removedTask.bookId,
+    )
+
+    const expectedBatches = [
+      { ...batches[0], taskIds: [keptTask.id] },
+      batches[1],
+    ]
+    expect(result).toEqual({
+      removedTaskIds: [removedTask.id],
+      tasks: [keptTask],
+      batches: expectedBatches,
+    })
+    expect(fsMocks.deleteFile).toHaveBeenCalledOnce()
+    expect(fsMocks.deleteFile).toHaveBeenCalledWith(
+      importTaskDir(removedTask.projectPath, removedTask.id),
+    )
+    expect(fsMocks.files.has(`${importTaskDir(removedTask.projectPath, removedTask.id)}/task.json`))
+      .toBe(false)
+    expect(fsMocks.files.has(`${importTaskDir(keptTask.projectPath, keptTask.id)}/task.json`))
+      .toBe(true)
+    expect(fsMocks.writeFileAtomic).toHaveBeenCalledOnce()
+    expect(fsMocks.writeFileAtomic).toHaveBeenCalledWith(
+      `${root}/batches.json`,
+      JSON.stringify(expectedBatches, null, 2),
+    )
+  })
+
+  it("删除批次中唯一任务时过滤空批次", async () => {
+    const task = makeTask({ id: "task-only", bookId: "book-remove" })
+    seedTask(task)
+    const root = importTasksRoot(task.projectPath)
+    fsMocks.files.set(`${root}/batches.json`, JSON.stringify([{
+      version: 1,
+      id: "batch-only",
+      projectPath: task.projectPath,
+      taskIds: [task.id],
+      createdAt: 100,
+      updatedAt: 100,
+    } satisfies BatchImportBatch]))
+
+    const result = await removeBatchImportHistoryForBook(task.projectPath, task.bookId)
+
+    expect(result).toEqual({
+      removedTaskIds: [task.id],
+      tasks: [],
+      batches: [],
+    })
+    expect(fsMocks.writeFileAtomic).toHaveBeenCalledWith(
+      `${root}/batches.json`,
+      JSON.stringify([], null, 2),
+    )
+  })
+
+  it("删除同一本书的多个导入任务及其批次引用", async () => {
+    const first = makeTask({
+      id: "task-remove-1",
+      bookId: "book-remove",
+      createdAt: 100,
+    })
+    const second = makeTask({
+      id: "task-remove-2",
+      bookId: "book-remove",
+      createdAt: 200,
+    })
+    const kept = makeTask({
+      id: "task-keep",
+      bookId: "book-keep",
+      createdAt: 300,
+    })
+    for (const task of [first, second, kept]) seedTask(task)
+    const root = importTasksRoot(first.projectPath)
+    const batch: BatchImportBatch = {
+      version: 1,
+      id: "batch-1",
+      projectPath: first.projectPath,
+      taskIds: [first.id, second.id, kept.id],
+      createdAt: 100,
+      updatedAt: 100,
+    }
+    fsMocks.files.set(`${root}/batches.json`, JSON.stringify([batch]))
+
+    const result = await removeBatchImportHistoryForBook(first.projectPath, first.bookId)
+
+    expect(result.removedTaskIds).toEqual([first.id, second.id])
+    expect(result.tasks).toEqual([kept])
+    expect(result.batches).toEqual([{ ...batch, taskIds: [kept.id] }])
+    expect(fsMocks.deleteFile.mock.calls.map(([path]) => path)).toEqual([
+      importTaskDir(first.projectPath, first.id),
+      importTaskDir(second.projectPath, second.id),
+    ])
+    expect(fsMocks.writeFileAtomic).toHaveBeenCalledOnce()
+  })
+
+  it("没有匹配任务时不删除、不写入且不创建 batches.json", async () => {
+    const task = makeTask({ id: "task-keep", bookId: "book-keep" })
+    seedTask(task)
+    const batchesPath = `${importTasksRoot(task.projectPath)}/batches.json`
+
+    const result = await removeBatchImportHistoryForBook(task.projectPath, "book-missing")
+
+    expect(result).toEqual({
+      removedTaskIds: [],
+      tasks: [task],
+      batches: [],
+    })
+    expect(fsMocks.deleteFile).not.toHaveBeenCalled()
+    expect(fsMocks.writeFileAtomic).not.toHaveBeenCalled()
+    expect(fsMocks.files.has(batchesPath)).toBe(false)
+  })
+
+  it("删除未分组任务时不创建缺失的 batches.json", async () => {
+    const task = makeTask({ id: "task-ungrouped", bookId: "book-remove" })
+    seedTask(task)
+    const batchesPath = `${importTasksRoot(task.projectPath)}/batches.json`
+
+    const result = await removeBatchImportHistoryForBook(task.projectPath, task.bookId)
+
+    expect(result).toEqual({
+      removedTaskIds: [task.id],
+      tasks: [],
+      batches: [],
+    })
+    expect(fsMocks.deleteFile).toHaveBeenCalledWith(
+      importTaskDir(task.projectPath, task.id),
+    )
+    expect(fsMocks.writeFileAtomic).not.toHaveBeenCalled()
+    expect(fsMocks.files.has(batchesPath)).toBe(false)
+  })
+
+  it("只清理已完成且书籍已不存在的历史并保留其他状态", async () => {
+    const missingCompleted = makeTask({
+      id: "task-completed-missing",
+      bookId: "book-missing",
+      status: "completed",
+      createdAt: 100,
+    })
+    const missingFailed = makeTask({
+      id: "task-failed-missing",
+      bookId: "book-failed",
+      status: "failed",
+      createdAt: 200,
+    })
+    const missingCopying = makeTask({
+      id: "task-copying-missing",
+      bookId: "book-copying",
+      status: "copying",
+      createdAt: 300,
+    })
+    const validCompleted = makeTask({
+      id: "task-completed-valid",
+      bookId: "book-valid",
+      status: "completed",
+      createdAt: 400,
+    })
+    const tasks = [missingCompleted, missingFailed, missingCopying, validCompleted]
+    for (const task of tasks) seedTask(task)
+    const root = importTasksRoot(missingCompleted.projectPath)
+    const batch: BatchImportBatch = {
+      version: 1,
+      id: "batch-1",
+      projectPath: missingCompleted.projectPath,
+      taskIds: tasks.map((task) => task.id),
+      createdAt: 100,
+      updatedAt: 100,
+    }
+    fsMocks.files.set(`${root}/batches.json`, JSON.stringify([batch]))
+
+    const result = await pruneMissingCompletedBookHistory(
+      missingCompleted.projectPath,
+      new Set([validCompleted.bookId]),
+    )
+
+    expect(result.removedTaskIds).toEqual([missingCompleted.id])
+    expect(result.tasks).toEqual([missingFailed, missingCopying, validCompleted])
+    expect(result.batches).toEqual([{
+      ...batch,
+      taskIds: [missingFailed.id, missingCopying.id, validCompleted.id],
+    }])
+    expect(fsMocks.deleteFile).toHaveBeenCalledWith(
+      importTaskDir(missingCompleted.projectPath, missingCompleted.id),
+    )
+    expect(fsMocks.writeFileAtomic).toHaveBeenCalledOnce()
+  })
+
+  it("任务目录已不存在时仍成功并修正已有批次", async () => {
+    const task = makeTask({ id: "task-missing-dir", bookId: "book-remove" })
+    seedTask(task)
+    const root = importTasksRoot(task.projectPath)
+    const dir = importTaskDir(task.projectPath, task.id)
+    fsMocks.files.set(`${root}/batches.json`, JSON.stringify([{
+      version: 1,
+      id: "batch-1",
+      projectPath: task.projectPath,
+      taskIds: [task.id],
+      createdAt: 100,
+      updatedAt: 100,
+    } satisfies BatchImportBatch]))
+    fsMocks.fileExists.mockImplementation(async (path) => (
+      path === dir ? false : fsMocks.files.has(path) || fsMocks.directories.has(path)
+    ))
+
+    const result = await removeBatchImportHistoryForBook(task.projectPath, task.bookId)
+
+    expect(result).toEqual({
+      removedTaskIds: [task.id],
+      tasks: [],
+      batches: [],
+    })
+    expect(fsMocks.writeFileAtomic).toHaveBeenCalledWith(
+      `${root}/batches.json`,
+      JSON.stringify([], null, 2),
+    )
+    expect(fsMocks.deleteFile).not.toHaveBeenCalled()
+  })
+
+  it("删除任务目录失败时向上传播且批次已先写回", async () => {
+    const task = makeTask({ id: "task-remove", bookId: "book-remove" })
+    seedTask(task)
+    const root = importTasksRoot(task.projectPath)
+    fsMocks.files.set(`${root}/batches.json`, JSON.stringify([{
+      version: 1,
+      id: "batch-1",
+      projectPath: task.projectPath,
+      taskIds: [task.id],
+      createdAt: 100,
+      updatedAt: 100,
+    } satisfies BatchImportBatch]))
+    fsMocks.deleteFile.mockRejectedValueOnce(new Error("删除失败"))
+
+    await expect(
+      removeBatchImportHistoryForBook(task.projectPath, task.bookId),
+    ).rejects.toThrow("删除失败")
+
+    expect(fsMocks.writeFileAtomic).toHaveBeenCalledWith(
+      `${root}/batches.json`,
+      JSON.stringify([], null, 2),
+    )
+    expect(fsMocks.writeFileAtomic.mock.invocationCallOrder[0]).toBeLessThan(
+      fsMocks.deleteFile.mock.invocationCallOrder[0],
+    )
+    expect(fsMocks.files.has(`${importTaskDir(task.projectPath, task.id)}/task.json`)).toBe(true)
+  })
+
+  it("批次写回失败时向上传播", async () => {
+    const task = makeTask({ id: "task-remove", bookId: "book-remove" })
+    seedTask(task)
+    const root = importTasksRoot(task.projectPath)
+    fsMocks.files.set(`${root}/batches.json`, JSON.stringify([{
+      version: 1,
+      id: "batch-1",
+      projectPath: task.projectPath,
+      taskIds: [task.id],
+      createdAt: 100,
+      updatedAt: 100,
+    } satisfies BatchImportBatch]))
+    fsMocks.writeFileAtomic.mockRejectedValueOnce(new Error("写回失败"))
+
+    await expect(
+      removeBatchImportHistoryForBook(task.projectPath, task.bookId),
+    ).rejects.toThrow("写回失败")
+
+    expect(fsMocks.deleteFile).not.toHaveBeenCalled()
+    expect(fsMocks.files.has(`${importTaskDir(task.projectPath, task.id)}/task.json`)).toBe(true)
+  })
 })

+ 90 - 2
src/lib/novel/book-analysis/batch-import-storage.ts

@@ -29,6 +29,12 @@ const TASK_STATUSES = new Set<BatchImportTaskStatus>([
   "completed",
 ])
 
+export interface RemovedBookImportHistory {
+  removedTaskIds: string[]
+  tasks: BatchImportTask[]
+  batches: BatchImportBatch[]
+}
+
 function normalizeProjectKey(projectPath: string): string {
   const normalized = normalizePath(projectPath).replace(/\/+$/, "")
   if (!normalized) throw new Error("项目路径不能为空")
@@ -183,8 +189,9 @@ async function ensureTaskDirectories(projectPath: string, taskId: string): Promi
   await createDirectory(`${importTaskDir(projectPath, taskId)}/chapters`)
 }
 
-export async function loadBatchImportTasks(
+async function loadPersistedBatchImportTasks(
   projectPath: string,
+  markRunningTasksInterrupted: boolean,
 ): Promise<BatchImportTask[]> {
   const projectKey = normalizeProjectKey(projectPath)
   const root = importTasksRoot(projectKey)
@@ -208,7 +215,10 @@ export async function loadBatchImportTasks(
       if (validationError) throw new Error(validationError)
       const task = parsed as unknown as BatchImportTask
 
-      if (task.status === "copying" || task.status === "splitting") {
+      if (
+        markRunningTasksInterrupted
+        && (task.status === "copying" || task.status === "splitting")
+      ) {
         const interruptedTask: BatchImportTask = {
           ...task,
           status: "interrupted",
@@ -228,6 +238,12 @@ export async function loadBatchImportTasks(
   return tasks.sort((left, right) => left.createdAt - right.createdAt)
 }
 
+export async function loadBatchImportTasks(
+  projectPath: string,
+): Promise<BatchImportTask[]> {
+  return loadPersistedBatchImportTasks(projectPath, true)
+}
+
 export async function saveBatchImportTaskUnlocked(
   task: BatchImportTask,
   options: { ensureChaptersDirectory?: boolean } = {},
@@ -275,6 +291,78 @@ export async function loadBatchImportBatches(
   }
   return batches
 }
+
+async function removeMatchingBatchImportHistory(
+  projectPath: string,
+  shouldRemove: (task: BatchImportTask) => boolean,
+): Promise<RemovedBookImportHistory> {
+  const projectKey = normalizeProjectKey(projectPath)
+  return withProjectLock(projectKey, async () => {
+    const tasks = await loadPersistedBatchImportTasks(projectKey, false)
+    const batchesPath = `${importTasksRoot(projectKey)}/batches.json`
+    const batchesFileExists = await fileExists(batchesPath)
+    const batches = await loadBatchImportBatches(projectKey)
+    const removedTaskIds = tasks.filter(shouldRemove).map((task) => task.id)
+
+    if (removedTaskIds.length === 0) {
+      return { removedTaskIds, tasks, batches }
+    }
+
+    const removedTaskIdSet = new Set(removedTaskIds)
+    const remainingTasks = tasks.filter((task) => !removedTaskIdSet.has(task.id))
+    const remainingBatches: BatchImportBatch[] = []
+    let batchesChanged = false
+    for (const batch of batches) {
+      const taskIds = batch.taskIds.filter((taskId) => !removedTaskIdSet.has(taskId))
+      if (taskIds.length === 0) {
+        batchesChanged = true
+        continue
+      }
+      if (taskIds.length !== batch.taskIds.length) {
+        batchesChanged = true
+        remainingBatches.push({ ...batch, taskIds })
+      } else {
+        remainingBatches.push(batch)
+      }
+    }
+
+    if (batchesFileExists && batchesChanged) {
+      await writeFileAtomic(batchesPath, JSON.stringify(remainingBatches, null, 2))
+    }
+
+    for (const taskId of removedTaskIds) {
+      const taskDir = importTaskDir(projectKey, taskId)
+      if (await fileExists(taskDir)) await deleteFile(taskDir)
+    }
+
+    return {
+      removedTaskIds,
+      tasks: remainingTasks,
+      batches: remainingBatches,
+    }
+  })
+}
+
+export async function removeBatchImportHistoryForBook(
+  projectPath: string,
+  bookId: string,
+): Promise<RemovedBookImportHistory> {
+  return removeMatchingBatchImportHistory(
+    projectPath,
+    (task) => task.bookId === bookId,
+  )
+}
+
+export async function pruneMissingCompletedBookHistory(
+  projectPath: string,
+  validBookIds: Set<string>,
+): Promise<RemovedBookImportHistory> {
+  return removeMatchingBatchImportHistory(
+    projectPath,
+    (task) => task.status === "completed" && !validBookIds.has(task.bookId),
+  )
+}
+
 export async function saveBatchImportBatch(batch: BatchImportBatch): Promise<void> {
   const projectKey = normalizeProjectKey(batch.projectPath)
   assertBatchImportBatch(batch, projectKey)

+ 120 - 0
src/lib/novel/book-analysis/book-deletion.spec.ts

@@ -0,0 +1,120 @@
+import { beforeEach, describe, expect, it, vi } from "vitest"
+
+const mocks = vi.hoisted(() => ({
+  fileExists: vi.fn(),
+  deleteFile: vi.fn(),
+  removeBookLibraryEntry: vi.fn(),
+  removeBatchImportHistoryForBook: vi.fn(),
+}))
+
+vi.mock("@/commands/fs", () => ({
+  fileExists: mocks.fileExists,
+  deleteFile: mocks.deleteFile,
+}))
+
+vi.mock("@/lib/novel/book-analysis/library-store", () => ({
+  removeBookLibraryEntry: mocks.removeBookLibraryEntry,
+}))
+
+vi.mock("@/lib/novel/book-analysis/batch-import-storage", () => ({
+  removeBatchImportHistoryForBook: mocks.removeBatchImportHistoryForBook,
+}))
+
+import { deleteBookAnalysisBook } from "./book-deletion"
+
+const EMPTY_HISTORY = {
+  removedTaskIds: [],
+  tasks: [],
+  batches: [],
+}
+
+describe("deleteBookAnalysisBook", () => {
+  beforeEach(() => {
+    vi.clearAllMocks()
+    mocks.fileExists.mockResolvedValue(true)
+    mocks.deleteFile.mockResolvedValue(undefined)
+    mocks.removeBookLibraryEntry.mockResolvedValue(undefined)
+    mocks.removeBatchImportHistoryForBook.mockResolvedValue(EMPTY_HISTORY)
+  })
+
+  it.each(["", "   "])("在任何文件操作前拒绝空项目路径:%j", async (projectPath) => {
+    await expect(deleteBookAnalysisBook(projectPath, "book-safe")).rejects.toThrow("项目路径不能为空")
+
+    expect(mocks.fileExists).not.toHaveBeenCalled()
+    expect(mocks.deleteFile).not.toHaveBeenCalled()
+    expect(mocks.removeBookLibraryEntry).not.toHaveBeenCalled()
+    expect(mocks.removeBatchImportHistoryForBook).not.toHaveBeenCalled()
+  })
+
+  it.each([
+    "book-../secret",
+    "../book-safe",
+    "book-safe/child",
+    "safe",
+    "book-",
+  ])("在任何文件操作前拒绝不合法作品 ID:%s", async (bookId) => {
+    await expect(deleteBookAnalysisBook("E:/Novel", bookId)).rejects.toThrow("作品 ID 不合法")
+
+    expect(mocks.deleteFile).not.toHaveBeenCalled()
+    expect(mocks.fileExists).not.toHaveBeenCalled()
+    expect(mocks.removeBookLibraryEntry).not.toHaveBeenCalled()
+    expect(mocks.removeBatchImportHistoryForBook).not.toHaveBeenCalled()
+  })
+
+  it("只从项目路径和作品 ID 推导目录,并按顺序删除目录、索引和导入历史", async () => {
+    const history = {
+      removedTaskIds: ["task-1"],
+      tasks: [],
+      batches: [],
+    }
+    mocks.removeBatchImportHistoryForBook.mockResolvedValueOnce(history)
+
+    const result = await deleteBookAnalysisBook("E:\\Novel\\", "book-safe_1")
+
+    expect(mocks.deleteFile).toHaveBeenCalledWith("E:/Novel/book-analysis/book-safe_1")
+    expect(mocks.removeBookLibraryEntry).toHaveBeenCalledWith("E:\\Novel\\", "book-safe_1")
+    expect(mocks.removeBatchImportHistoryForBook).toHaveBeenCalledWith(
+      "E:\\Novel\\",
+      "book-safe_1",
+    )
+    expect(mocks.deleteFile.mock.invocationCallOrder[0]).toBeLessThan(
+      mocks.removeBookLibraryEntry.mock.invocationCallOrder[0],
+    )
+    expect(mocks.removeBookLibraryEntry.mock.invocationCallOrder[0]).toBeLessThan(
+      mocks.removeBatchImportHistoryForBook.mock.invocationCallOrder[0],
+    )
+    expect(result).toBe(history)
+  })
+
+  it("作品目录已不存在时仍继续清理索引和导入历史以支持重试", async () => {
+    mocks.fileExists.mockResolvedValueOnce(false)
+
+    await expect(deleteBookAnalysisBook("E:/Novel", "book-safe")).resolves.toBe(EMPTY_HISTORY)
+
+    expect(mocks.deleteFile).not.toHaveBeenCalled()
+    expect(mocks.removeBookLibraryEntry).toHaveBeenCalledWith("E:/Novel", "book-safe")
+    expect(mocks.removeBatchImportHistoryForBook).toHaveBeenCalledWith("E:/Novel", "book-safe")
+  })
+
+  it.each([
+    "deleteFile",
+    "removeBookLibraryEntry",
+    "removeBatchImportHistoryForBook",
+  ] as const)("%s 失败时向上传播原错误并停止后续调用", async (failedStep) => {
+    const error = new Error(`${failedStep} 失败`)
+    mocks[failedStep].mockRejectedValueOnce(error)
+
+    await expect(deleteBookAnalysisBook("E:/Novel", "book-safe")).rejects.toBe(error)
+
+    if (failedStep === "deleteFile") {
+      expect(mocks.removeBookLibraryEntry).not.toHaveBeenCalled()
+      expect(mocks.removeBatchImportHistoryForBook).not.toHaveBeenCalled()
+    } else if (failedStep === "removeBookLibraryEntry") {
+      expect(mocks.deleteFile).toHaveBeenCalledOnce()
+      expect(mocks.removeBatchImportHistoryForBook).not.toHaveBeenCalled()
+    } else {
+      expect(mocks.deleteFile).toHaveBeenCalledOnce()
+      expect(mocks.removeBookLibraryEntry).toHaveBeenCalledOnce()
+    }
+  })
+})

+ 22 - 0
src/lib/novel/book-analysis/book-deletion.ts

@@ -0,0 +1,22 @@
+import { deleteFile, fileExists } from "@/commands/fs"
+import { joinPath } from "@/lib/path-utils"
+import {
+  removeBatchImportHistoryForBook,
+  type RemovedBookImportHistory,
+} from "@/lib/novel/book-analysis/batch-import-storage"
+import { removeBookLibraryEntry } from "@/lib/novel/book-analysis/library-store"
+
+const BOOK_ID_PATTERN = /^book-[A-Za-z0-9_-]+$/
+
+export async function deleteBookAnalysisBook(
+  projectPath: string,
+  bookId: string,
+): Promise<RemovedBookImportHistory> {
+  if (!projectPath.trim()) throw new Error("项目路径不能为空")
+  if (!BOOK_ID_PATTERN.test(bookId)) throw new Error("作品 ID 不合法")
+
+  const bookPath = joinPath(projectPath, "book-analysis", bookId)
+  if (await fileExists(bookPath)) await deleteFile(bookPath)
+  await removeBookLibraryEntry(projectPath, bookId)
+  return removeBatchImportHistoryForBook(projectPath, bookId)
+}

+ 68 - 0
src/lib/novel/book-analysis/library-store.spec.ts

@@ -29,6 +29,7 @@ import {
   findBookLibraryEntry,
   findBookLibraryEntryBySha256,
   loadBookLibrary,
+  reconcileBookLibrary,
   removeBookLibraryEntry,
   renameBookLibraryEntry,
   upsertBookLibraryEntry,
@@ -58,6 +59,10 @@ function seedLibrary(entries: ReturnType<typeof entry>[]) {
   memStore.set(libraryFilePath, JSON.stringify({ version: 1, entries }))
 }
 
+function seedMetadata(bookId: string) {
+  memStore.set(`${projectPath}/book-analysis/${bookId}/metadata.json`, "{}")
+}
+
 beforeEach(() => {
   memStore.clear()
   unreadablePaths.clear()
@@ -172,6 +177,61 @@ describe("library-store", () => {
     )
     expect(writeFileAtomic).not.toHaveBeenCalled()
   })
+
+  it("reconcileBookLibrary: 移除孤儿条目并保留 metadata 存在的条目", async () => {
+    const validEntry = entry({ bookId: "book-valid", title: "有效作品" })
+    seedLibrary([
+      entry({ bookId: "book-orphan", title: "孤儿作品" }),
+      validEntry,
+    ])
+    seedMetadata("book-valid")
+
+    const library = await reconcileBookLibrary(projectPath)
+
+    expect(library.entries).toEqual([validEntry])
+    expect(writeFileAtomic).toHaveBeenCalledTimes(1)
+    expect(writeFileAtomic).toHaveBeenCalledWith(
+      libraryFilePath,
+      JSON.stringify({ version: 1, entries: [validEntry] }, null, 2),
+    )
+  })
+
+  it("reconcileBookLibrary: 没有孤儿条目时不写回", async () => {
+    const entries = [entry({ bookId: "book-1" }), entry({ bookId: "book-2" })]
+    seedLibrary(entries)
+    seedMetadata("book-1")
+    seedMetadata("book-2")
+
+    const library = await reconcileBookLibrary(projectPath)
+
+    expect(library.entries).toEqual(entries)
+    expect(writeFileAtomic).not.toHaveBeenCalled()
+  })
+
+  it("reconcileBookLibrary: 索引文件不存在时返回空库且不写回", async () => {
+    await expect(reconcileBookLibrary(projectPath)).resolves.toEqual({
+      version: 1,
+      entries: [],
+    })
+    expect(writeFileAtomic).not.toHaveBeenCalled()
+  })
+
+  it("reconcileBookLibrary: 索引 JSON 损坏时拒绝且不写回", async () => {
+    memStore.set(libraryFilePath, "{损坏 JSON")
+
+    await expect(reconcileBookLibrary(projectPath)).rejects.toBeInstanceOf(SyntaxError)
+    expect(writeFileAtomic).not.toHaveBeenCalled()
+  })
+
+  it("reconcileBookLibrary: 索引 schema 损坏时拒绝且不写回", async () => {
+    memStore.set(libraryFilePath, JSON.stringify({ version: 1, entries: null }))
+
+    await expect(reconcileBookLibrary(projectPath)).rejects.toThrow(
+      "作品库索引数据无效",
+    )
+    expect(writeFileAtomic).not.toHaveBeenCalled()
+  })
+
   it("findBookLibraryEntry: 按 sourcePath 命中", async () => {
     await upsertBookLibraryEntry("E:/Proj", {
       bookId: "book-1", sourcePath: "E:/a.txt", contentHash: "abc",
@@ -344,4 +404,12 @@ describe("library-store", () => {
     await expect(findBookLibraryEntryBySha256(projectPath, "target-sha")).rejects.toThrow("哈希失败")
     expect(writeFileAtomic).not.toHaveBeenCalled()
   })
+
+  it("reconcileBookLibrary: 清理写回失败时向上传播", async () => {
+    seedLibrary([entry({ bookId: "book-orphan" })])
+    vi.mocked(writeFileAtomic).mockRejectedValueOnce(new Error("保存失败"))
+
+    await expect(reconcileBookLibrary(projectPath)).rejects.toThrow("保存失败")
+    expect(writeFileAtomic).toHaveBeenCalledTimes(1)
+  })
 })

+ 23 - 0
src/lib/novel/book-analysis/library-store.ts

@@ -141,6 +141,29 @@ export async function renameBookLibraryEntry(
     return renamedEntry
   })
 }
+
+export async function reconcileBookLibrary(projectPath: string): Promise<BookLibrary> {
+  return withProjectLock(normalizePath(projectPath), async () => {
+    const library = await loadBookLibraryStrictUnlocked(projectPath)
+    const entries: BookLibraryEntry[] = []
+
+    for (const entry of library.entries) {
+      const metadataPath = normalizePath(
+        joinPath(projectPath, "book-analysis", entry.bookId, "metadata.json"),
+      )
+      if (await fileExists(metadataPath)) {
+        entries.push(entry)
+      }
+    }
+
+    if (entries.length === library.entries.length) return library
+
+    const reconciled = { ...library, entries }
+    await saveBookLibraryUnlocked(projectPath, reconciled)
+    return reconciled
+  })
+}
+
 export async function findBookLibraryEntry(
   projectPath: string,
   sourcePath: string,

+ 220 - 3
src/stores/book-analysis-import-store.spec.ts

@@ -12,6 +12,9 @@ const mocks = vi.hoisted(() => {
     regenerateTask: ReturnType<typeof vi.fn>
     cancelTask: ReturnType<typeof vi.fn>
     cancelAllQueued: ReturnType<typeof vi.fn>
+    syncTerminalTask: ReturnType<typeof vi.fn>
+    forgetTerminalTask: ReturnType<typeof vi.fn>
+    removeTask: ReturnType<typeof vi.fn>
     subscribe: ReturnType<typeof vi.fn>
     dispose: ReturnType<typeof vi.fn>
     emit(tasks: BatchImportTask[]): void
@@ -20,13 +23,16 @@ const mocks = vi.hoisted(() => {
     schedulers,
     loadBatchImportTasks: vi.fn(),
     loadBatchImportBatches: vi.fn(),
+    pruneMissingCompletedBookHistory: vi.fn(),
     saveBatchImportTask: vi.fn(),
     saveBatchImportBatch: vi.fn(),
     deleteFailedBatchImportTask: vi.fn(),
     cacheTaskSource: vi.fn(),
     loadBookLibrary: vi.fn(),
+    reconcileBookLibrary: vi.fn(),
     findBookLibraryEntryBySha256: vi.fn(),
     renameBookLibraryEntry: vi.fn(),
+    deleteBookAnalysisBook: vi.fn(),
     fileExists: vi.fn(),
     readFile: vi.fn(),
   }
@@ -43,6 +49,7 @@ vi.mock("@/lib/novel/book-analysis/batch-import-scheduler", () => ({
       cancelAllQueued: vi.fn().mockResolvedValue(undefined),
       syncTerminalTask: vi.fn(),
       forgetTerminalTask: vi.fn(),
+      removeTask: vi.fn().mockReturnValue(true),
       subscribe: vi.fn((nextListener: (tasks: BatchImportTask[]) => void) => {
         listener = nextListener
         nextListener([])
@@ -60,6 +67,7 @@ vi.mock("@/lib/novel/book-analysis/batch-import-storage", () => ({
   importTasksRoot: (projectPath: string) => `${projectPath}/book-analysis/import-tasks`,
   loadBatchImportTasks: mocks.loadBatchImportTasks,
   loadBatchImportBatches: mocks.loadBatchImportBatches,
+  pruneMissingCompletedBookHistory: mocks.pruneMissingCompletedBookHistory,
   saveBatchImportTask: mocks.saveBatchImportTask,
   saveBatchImportBatch: mocks.saveBatchImportBatch,
   deleteFailedBatchImportTask: mocks.deleteFailedBatchImportTask,
@@ -68,10 +76,15 @@ vi.mock("@/lib/novel/book-analysis/batch-import-storage", () => ({
 
 vi.mock("@/lib/novel/book-analysis/library-store", () => ({
   loadBookLibrary: mocks.loadBookLibrary,
+  reconcileBookLibrary: mocks.reconcileBookLibrary,
   findBookLibraryEntryBySha256: mocks.findBookLibraryEntryBySha256,
   renameBookLibraryEntry: mocks.renameBookLibraryEntry,
 }))
 
+vi.mock("@/lib/novel/book-analysis/book-deletion", () => ({
+  deleteBookAnalysisBook: mocks.deleteBookAnalysisBook,
+}))
+
 vi.mock("@/commands/fs", () => ({
   fileExists: mocks.fileExists,
   readFile: mocks.readFile,
@@ -135,12 +148,23 @@ describe("book analysis import store", () => {
     mocks.schedulers.length = 0
     mocks.loadBatchImportTasks.mockResolvedValue([])
     mocks.loadBatchImportBatches.mockResolvedValue([])
+    mocks.pruneMissingCompletedBookHistory.mockResolvedValue({
+      removedTaskIds: [],
+      tasks: [],
+      batches: [],
+    })
     mocks.saveBatchImportTask.mockResolvedValue(undefined)
     mocks.saveBatchImportBatch.mockResolvedValue(undefined)
     mocks.deleteFailedBatchImportTask.mockResolvedValue(undefined)
     mocks.loadBookLibrary.mockResolvedValue({ version: 1, entries: [] })
+    mocks.reconcileBookLibrary.mockResolvedValue({ version: 1, entries: [] })
     mocks.findBookLibraryEntryBySha256.mockResolvedValue(undefined)
     mocks.renameBookLibraryEntry.mockImplementation(async (_path: string, bookId: string, title: string) => ({ bookId, title }))
+    mocks.deleteBookAnalysisBook.mockResolvedValue({
+      removedTaskIds: [],
+      tasks: [],
+      batches: [],
+    })
     mocks.fileExists.mockResolvedValue(false)
     mocks.readFile.mockResolvedValue("[]")
     mocks.cacheTaskSource.mockImplementation(async (task: BatchImportTask) => ({
@@ -247,11 +271,55 @@ describe("book analysis import store", () => {
     expect(mocks.schedulers[0].enqueue).not.toHaveBeenCalled()
   })
 
-  it("创建批次按完整 SHA 查重,并为已有作品名与本批同名作品连续编号", async () => {
-    mocks.loadBookLibrary.mockResolvedValue({
+  it("初始化先修复作品库并清理孤儿 completed 历史,再加载清理后的真实状态且不递增 revision", async () => {
+    const loadedTask = makeTask("loaded-interrupted", {
+      status: "interrupted",
+      bookId: "book-valid",
+      error: "应用重启,任务已中断",
+    })
+    const loadedBatch = { ...persistedBatch(PROJECT_A), taskIds: [loadedTask.id] }
+    mocks.reconcileBookLibrary.mockResolvedValueOnce({
       version: 1,
-      entries: [{ title: "长夜", contentSha256: "library-sha" }],
+      entries: [{ bookId: "book-valid", title: "有效作品", contentSha256: "sha-valid" }],
     })
+    mocks.pruneMissingCompletedBookHistory.mockResolvedValueOnce({
+      removedTaskIds: ["stale-completed"],
+      tasks: [makeTask("prune-result")],
+      batches: [],
+    })
+    mocks.loadBatchImportTasks.mockResolvedValueOnce([loadedTask])
+    mocks.loadBatchImportBatches.mockResolvedValueOnce([loadedBatch])
+    const onRevision = vi.fn()
+    const store = createBookAnalysisImportStore({ onRevision })
+
+    await store.getState().initializeProject(PROJECT_A)
+
+    expect(mocks.pruneMissingCompletedBookHistory).toHaveBeenCalledWith(
+      PROJECT_A,
+      new Set(["book-valid"]),
+    )
+    expect(mocks.reconcileBookLibrary.mock.invocationCallOrder[0]).toBeLessThan(
+      mocks.pruneMissingCompletedBookHistory.mock.invocationCallOrder[0],
+    )
+    expect(mocks.pruneMissingCompletedBookHistory.mock.invocationCallOrder[0]).toBeLessThan(
+      mocks.loadBatchImportTasks.mock.invocationCallOrder[0],
+    )
+    expect(mocks.pruneMissingCompletedBookHistory.mock.invocationCallOrder[0]).toBeLessThan(
+      mocks.loadBatchImportBatches.mock.invocationCallOrder[0],
+    )
+    expect(store.getState().tasks).toEqual([loadedTask])
+    expect(store.getState().batches).toEqual([loadedBatch])
+    expect(store.getState().revision).toBe(0)
+    expect(onRevision).not.toHaveBeenCalled()
+  })
+
+  it("创建批次按完整 SHA 查重,并为已有作品名与本批同名作品连续编号", async () => {
+    const library = {
+      version: 1,
+      entries: [{ bookId: "book-library", title: "长夜", contentSha256: "library-sha" }],
+    }
+    mocks.loadBookLibrary.mockResolvedValue(library)
+    mocks.reconcileBookLibrary.mockResolvedValue(library)
     mocks.cacheTaskSource
       .mockImplementationOnce(async (task: BatchImportTask) => ({ ...task, sourceSha256: "library-sha", cachedSourcePath: "cache/1" }))
       .mockImplementationOnce(async (task: BatchImportTask) => ({ ...task, sourceSha256: "new-sha", cachedSourcePath: "cache/2" }))
@@ -274,6 +342,155 @@ describe("book analysis import store", () => {
     expect(mocks.schedulers[0].enqueue).toHaveBeenCalledWith([tasks[1]])
   })
 
+  it("旧 completed 任务不再预留 finalTitle,新任务可使用原书名", async () => {
+    const completed = makeTask("completed-old", {
+      status: "completed",
+      finalTitle: "长夜",
+    })
+    mocks.loadBatchImportTasks.mockResolvedValueOnce([completed])
+    const store = createBookAnalysisImportStore()
+    await store.getState().initializeProject(PROJECT_A)
+
+    await store.getState().createBatch([candidate("长夜.txt")])
+
+    const created = store.getState().tasks.find((task) => task.id !== completed.id)
+    expect(created).toMatchObject({ status: "queued", finalTitle: "长夜" })
+  })
+
+  it("相同内容的孤儿索引被修复后仍以原书名创建 queued 任务", async () => {
+    mocks.loadBookLibrary.mockResolvedValue({
+      version: 1,
+      entries: [{ bookId: "book-orphan", title: "长夜", contentSha256: "orphan-sha" }],
+    })
+    mocks.reconcileBookLibrary.mockResolvedValue({ version: 1, entries: [] })
+    mocks.cacheTaskSource.mockImplementationOnce(async (task: BatchImportTask) => ({
+      ...task,
+      cachedSourcePath: "cache/orphan-source",
+      sourceSha256: "orphan-sha",
+    }))
+    const store = createBookAnalysisImportStore()
+    await store.getState().initializeProject(PROJECT_A)
+
+    await store.getState().createBatch([candidate("长夜.txt")])
+
+    expect(store.getState().tasks).toHaveLength(1)
+    expect(store.getState().tasks[0]).toMatchObject({ status: "queued", finalTitle: "长夜" })
+    expect(mocks.reconcileBookLibrary).toHaveBeenCalledTimes(2)
+    expect(mocks.loadBookLibrary).not.toHaveBeenCalled()
+  })
+
+  it("项目初始化完成前拒绝删除,避免旧读盘结果覆盖删除后的状态", async () => {
+    const store = createBookAnalysisImportStore()
+    const initializing = store.getState().initializeProject(PROJECT_A)
+
+    await expect(store.getState().deletePublishedBook("book-delete")).rejects.toThrow(
+      "拆书库正在加载,请稍后再试。",
+    )
+    expect(mocks.deleteBookAnalysisBook).not.toHaveBeenCalled()
+
+    await initializing
+  })
+
+  it.each(["queued", "copying", "splitting"] as const)(
+    "作品存在 %s 任务时拒绝删除且不调用领域服务",
+    async (status) => {
+      const active = makeTask(`active-${status}`, { status, bookId: "book-delete" })
+      mocks.loadBatchImportTasks.mockResolvedValueOnce([active])
+      const store = createBookAnalysisImportStore()
+      await store.getState().initializeProject(PROJECT_A)
+
+      await expect(store.getState().deletePublishedBook(active.bookId)).rejects.toThrow(
+        "作品正在导入或重新生成,请先取消任务后再删除。",
+      )
+
+      expect(mocks.deleteBookAnalysisBook).not.toHaveBeenCalled()
+    },
+  )
+
+  it("删除成功后按任务 ID 和 bookId 收敛任务与批次,并只递增一次 revision", async () => {
+    const removedById = makeTask("removed-by-id", {
+      status: "completed",
+      bookId: "book-delete",
+      batchId: "batch-mixed",
+    })
+    const removedByBook = makeTask("removed-by-book", {
+      status: "failed",
+      bookId: "book-delete",
+      batchId: "batch-empty",
+    })
+    const kept = makeTask("kept", {
+      status: "completed",
+      bookId: "book-keep",
+      batchId: "batch-mixed",
+    })
+    const batches: BatchImportBatch[] = [
+      {
+        version: 1,
+        id: "batch-mixed",
+        projectPath: PROJECT_A,
+        taskIds: [removedById.id, kept.id],
+        createdAt: 1,
+        updatedAt: 1,
+      },
+      {
+        version: 1,
+        id: "batch-empty",
+        projectPath: PROJECT_A,
+        taskIds: [removedByBook.id],
+        createdAt: 2,
+        updatedAt: 2,
+      },
+    ]
+    mocks.loadBatchImportTasks.mockResolvedValueOnce([removedById, removedByBook, kept])
+    mocks.loadBatchImportBatches.mockResolvedValueOnce(batches)
+    mocks.deleteBookAnalysisBook.mockResolvedValueOnce({
+      removedTaskIds: [removedById.id],
+      tasks: [kept],
+      batches: [{ ...batches[0], taskIds: [kept.id] }],
+    })
+    const onRevision = vi.fn()
+    const store = createBookAnalysisImportStore({ onRevision })
+    await store.getState().initializeProject(PROJECT_A)
+
+    await store.getState().deletePublishedBook("book-delete")
+
+    expect(mocks.deleteBookAnalysisBook).toHaveBeenCalledWith(PROJECT_A, "book-delete")
+    expect(store.getState().tasks).toEqual([kept])
+    expect(store.getState().batches).toEqual([{ ...batches[0], taskIds: [kept.id] }])
+    expect(store.getState().revision).toBe(1)
+    expect(onRevision).toHaveBeenCalledOnce()
+    expect(mocks.schedulers[0].removeTask.mock.calls.map(([taskId]) => taskId)).toEqual([
+      removedById.id,
+      removedByBook.id,
+    ])
+  })
+
+  it("删除等待期间切换项目不会把旧项目结果写入新项目状态", async () => {
+    const oldTask = makeTask("old-delete", { status: "completed", bookId: "book-delete" })
+    const newTask = makeTask("new-project", { projectPath: PROJECT_B, status: "failed" })
+    const deleteGate = deferred<{
+      removedTaskIds: string[]
+      tasks: BatchImportTask[]
+      batches: BatchImportBatch[]
+    }>()
+    mocks.loadBatchImportTasks.mockImplementation((path: string) => (
+      Promise.resolve(path === PROJECT_A ? [oldTask] : [newTask])
+    ))
+    mocks.deleteBookAnalysisBook.mockReturnValueOnce(deleteGate.promise)
+    const store = createBookAnalysisImportStore()
+    await store.getState().initializeProject(PROJECT_A)
+
+    const deleting = store.getState().deletePublishedBook(oldTask.bookId)
+    await waitFor(() => mocks.deleteBookAnalysisBook.mock.calls.length === 1)
+    await store.getState().initializeProject(PROJECT_B)
+    deleteGate.resolve({ removedTaskIds: [oldTask.id], tasks: [], batches: [] })
+    await deleting
+
+    expect(store.getState().projectPath).toBe(PROJECT_B)
+    expect(store.getState().tasks).toEqual([newTask])
+    expect(store.getState().revision).toBe(0)
+  })
+
   it("单文件缓存失败只标记该任务失败并继续处理其他文件", async () => {
     mocks.cacheTaskSource
       .mockRejectedValueOnce(new Error("磁盘不可读"))

+ 58 - 3
src/stores/book-analysis-import-store.ts

@@ -5,11 +5,13 @@ import {
   type BatchImportScheduler,
 } from "@/lib/novel/book-analysis/batch-import-scheduler"
 import { reserveUniqueTitle } from "@/lib/novel/book-analysis/batch-import-hash"
+import { deleteBookAnalysisBook } from "@/lib/novel/book-analysis/book-deletion"
 import {
   cacheTaskSource,
   deleteFailedBatchImportTask,
   loadBatchImportBatches,
   loadBatchImportTasks,
+  pruneMissingCompletedBookHistory,
   saveBatchImportBatch,
   saveBatchImportTask,
 } from "@/lib/novel/book-analysis/batch-import-storage"
@@ -20,11 +22,12 @@ import type {
 } from "@/lib/novel/book-analysis/batch-import-types"
 import {
   findBookLibraryEntryBySha256,
-  loadBookLibrary,
   renameBookLibraryEntry,
+  reconcileBookLibrary,
 } from "@/lib/novel/book-analysis/library-store"
 
 const OPEN_PANEL_STATUSES = new Set(["queued", "copying", "splitting", "interrupted", "failed"])
+const DELETE_BLOCKING_STATUSES = new Set(["queued", "copying", "splitting"])
 const SWITCHED_PROJECT_ERROR = "项目已切换,导入任务已中断"
 let idCounter = 0
 
@@ -79,6 +82,7 @@ export interface BookAnalysisImportState {
   revision: number
   initializeProject(projectPath: string): Promise<void>
   createBatch(candidates: BatchImportCandidate[]): Promise<void>
+  deletePublishedBook(bookId: string): Promise<void>
   continueTask(taskId: string): Promise<void>
   regenerateTask(taskId: string): Promise<void>
   cancelTask(taskId: string): Promise<void>
@@ -136,12 +140,17 @@ export function createBookAnalysisImportStore(options: { onRevision?: () => void
       expectedScheduler: BatchImportScheduler,
     ): Promise<void> {
       if (!isCurrent(token, projectPath, expectedScheduler) || candidates.length === 0) return
-      const library = await loadBookLibrary(projectPath)
+      const library = await reconcileBookLibrary(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) {
+        if (
+          task.status !== "completed"
+          && task.status !== "skipped"
+          && task.status !== "cancelled"
+          && task.finalTitle
+        ) {
           reservedTitles.add(task.finalTitle)
         }
       }
@@ -330,6 +339,13 @@ export function createBookAnalysisImportStore(options: { onRevision?: () => void
         const operation = schedulerLifecycleChain.then(async () => {
           await previousScheduler?.dispose()
           if (!isCurrent(token, projectPath)) return
+          const library = await reconcileBookLibrary(projectPath)
+          if (!isCurrent(token, projectPath)) return
+          await pruneMissingCompletedBookHistory(
+            projectPath,
+            new Set(library.entries.map((entry) => entry.bookId)),
+          )
+          if (!isCurrent(token, projectPath)) return
           const [tasks, batches] = await Promise.all([
             loadBatchImportTasks(projectPath),
             loadBatchImportBatches(projectPath),
@@ -371,6 +387,45 @@ export function createBookAnalysisImportStore(options: { onRevision?: () => void
         return operation
       },
 
+      deletePublishedBook: async (bookId) => {
+        const projectPath = get().projectPath
+        const currentScheduler = scheduler
+        const token = generation
+        if (!projectPath) throw new Error("请先初始化拆书项目")
+        if (!currentScheduler) throw new Error("拆书库正在加载,请稍后再试。")
+        if (get().tasks.some((task) => (
+          task.bookId === bookId && DELETE_BLOCKING_STATUSES.has(task.status)
+        ))) {
+          throw new Error("作品正在导入或重新生成,请先取消任务后再删除。")
+        }
+
+        const history = await deleteBookAnalysisBook(projectPath, bookId)
+        if (!isCurrent(token, projectPath, currentScheduler)) return
+
+        const removedTaskIds = new Set(history.removedTaskIds)
+        for (const task of get().tasks) {
+          if (task.bookId === bookId) removedTaskIds.add(task.id)
+        }
+        for (const taskId of removedTaskIds) {
+          completedTaskIds.delete(taskId)
+          schedulerTaskIds.delete(taskId)
+          currentScheduler.removeTask(taskId)
+        }
+        set((state) => {
+          const tasks = state.tasks.filter((task) => (
+            task.bookId !== bookId && !removedTaskIds.has(task.id)
+          ))
+          const batches: BatchImportBatch[] = []
+          for (const batch of state.batches) {
+            const taskIds = batch.taskIds.filter((taskId) => !removedTaskIds.has(taskId))
+            if (taskIds.length === 0) continue
+            batches.push(taskIds.length === batch.taskIds.length ? batch : { ...batch, taskIds })
+          }
+          return { tasks, batches, revision: state.revision + 1 }
+        })
+        options.onRevision?.()
+      },
+
       continueTask: async (taskId) => {
         const current = scheduler
         if (!current) throw new Error("请先初始化拆书项目")